Commit Graph
9 Commits
Author SHA1 Message Date
Jinwoo Hong 7538fa9466 feat(mobile): the phone's Workspace options sheet, with every desktop option (#28997)
* fix(mobile): phone server rows follow the desktop's other-client and sleeping filters

A phone created through a desktop now records whom it acted for, so the
desktop's Hide other-client workspaces filter keeps the user's own phone
workspaces (locally and on servers) and hides only other people's.
The desktop relay marks each server row its sidebar would hide, and the
rows its own browser or chat tabs keep awake; the phone applies both.

* test(e2e): dedupe sidebar row ids in the visibility spec

* fix(mobile): address review: owner naming, old-server relayed phones, scope trim

* refactor: move the sidebar's project section grouping into shared

The project sections the desktop sidebar draws under Group by Project are
built from the project/setup projection, the per-host catalog fold, the
endpoint host stamp, and the section labelling and ordering rules. Move the
pure parts of each into src/shared (no behaviour change) so the paired phone
can call the same code instead of guessing the grouping.

* fix(mobile): group by Project with the desktop sidebar's own sections

The phone grouped Project sections by repo display name, so it merged
checkouts the desktop keeps apart (several local stablyai/orca checkouts,
or a server's copy) and split what the desktop merges. The phone now reads
each host's repo.list, project.list and projectHostSetup.list (the desktop's
own and each server's through the relay), folds them with the renderer's
shared catalog fold and projection merge, and builds sections with the
desktop's shared grouping, labelling and ordering code. Keys, labels and
Recent order equal the sidebar's; a host that predates the project lists
gets the repo-derived projection, as the desktop does.

Header colour and icon come from the section's anchor repo and rows from
their own repo by id, as on the desktop. The repo filter lists every
catalog repo by id per host instead of merging same-named repos. Group-by
labels read Project and None. project.list and projectHostSetup.list are
routed for paired phones (read-only, workspace permission).

* refactor(mobile): record a phone's owner as creator and fold desktop tab activity into the host flag

A phone now records its owner as the creator (this host for a directly
paired phone, the relaying desktop for a delegated one), so the shared rule
is unchanged and onBehalfOf is gone. The relay ORs the desktop's saved
browser/chat tabs into hasHostSidebarActivity, so the phone's status dot
follows too, and local worktree.ps counts the same signals. The old-server
fallback is removed.

* test(e2e): poll the phone and the sidebar together in the visibility spec

* fix(mobile): one Project-section builder for desktop and phone, with the desktop's repo order

Review fixes. The sidebar's buildOrderedGroups now calls the shared
buildProjectSections for Group by Project (notice and pending repos are
optional inputs), so desktop and phone run one function. The phone ranks
manual header order by its folded catalog order, which is each host's
repo.list order with the desktop's own repos first, as the renderer loads
them; only the desktop's cross-host manualRepoOrder (pairing-local) is
unknown to it. The projection selector is now a memo over the shared
resolver. A transient project-list failure keeps that host's last catalog;
only forbidden or method_not_found falls back to the repo-derived
projection. Catalog rows are checked on every member grouping reads, and
the parity test answers the renderer's real catalog fetch, includes a
server's own project rows, and fixes Manual and Recent orders that differ.

* fix(mobile): use the repo-derived catalog until a host's project lists first answer

* fix(mobile): the desktop serves its sidebar's Project catalog instead of the phone fetching it

The phone fetched repo.list, project.list and projectHostSetup.list from
the desktop and every relay-ready server each minute and folded them
itself, so rows from offline or older servers fell back to repo sections
and keys moved on cold start.

The desktop renderer now publishes the inputs its own Project headers use
(state.repos in sidebar order, the projection selector's projects and
setups, projectOrderBy) on the runtime window graph it already sends to
main, only when they change. Main keeps the last one and answers it on a
new paired-desktop read, mobileRelay.projectSections. The phone reads it
with its repo metadata and groups with the shared builder; a desktop that
predates the read (forbidden or method_not_found) leaves the phone grouping
rows by repo from the desktop's repo.list, and a transient failure keeps
the last catalog. The payload carries only grouping and label fields, no
hooks, credentials or uploaded icons. The phone's own catalog fetching and
its project.list/projectHostSetup.list routes are removed.

* fix(mobile): keep uploaded repo icons on the phone, and isolate the snapshot tests

* test(e2e): the desktop window serves its sidebar's Project catalog to the phone

* chore(i18n): regenerate the runtime English catalog (drift already on main)

* fix(mobile): read the Project catalog as its own request, outside the recorded repo-metadata flow

The extra read inside the repo-metadata refresh stalled its recorded RPC
sequence. It now runs beside it with its own throttle. The repo-metadata
goldens are re-recorded for the intended state change: the name-keyed
colour, icon and id maps became desktopRepos.

* refactor(sidebar): one shared Workspace options filter and sort evaluator

The sidebar, the jump palette and the options badge each applied the
Workspace options filters with their own copy of the rules. They now
call one pure evaluator in src/shared/workspace-view, which reads each
row through a small fact reader so the phone can call it next with the
facts its host serves. The sort comparator moves there too, with each
surface supplying its own Agent activity rank.

No behaviour change: a parity test runs the old sidebar chain and the
new evaluator over every toggle combination, and the old comparator
against the new one for each sort mode.

* wip: workspace view descriptors

* refactor(sidebar): address review of the shared Workspace options evaluator

One predicate builder instead of a per-row scope argument; one
exemption-narrowing function taking the hide flag; consumers import the
shared predicates directly; Agent activity ties fall to the label in the
shared comparator. The activation filter lift reuses the shared
predicates. The parity test now covers other-client rows, live agent
attention classes and the sort-label map.

* fix(mobile): fetch the Project catalog at once when the screen moves to another host

The refresh window is now kept per client, so a host screen reused for another desktop doesn't wait out the previous host's 60s window before showing its Project headers.

* wip: desktop option items from shared choices

* refactor(sidebar): the Workspace options menu renders from one shared option list

src/shared/workspace-view/workspace-view-options.ts lists every Workspace
options control (Hosts, Projects, Group by, Sort by, Project order, Card
layout, Show properties or Card display, and each filter) with its
section, its English copy, its choices and when it shows. The desktop
menu walks that list and renders each option through a table keyed on
the option key, so a new option fails to compile until the desktop has
a control for it; the phone sheet will do the same.

The choice lists (sort modes, project order, card layout, agent activity
layout, card properties) are derived from the shared ids, and a test
checks the desktop's English copy matches the shared copy. The unused
classic branch of the card property list is removed.

No visible change.

* wip: row facts

* refactor(sidebar): address review of the shared Workspace options list

The show rules get their own tests with fixed expectations (the cases
the old filter-section test covered, plus the rest), and the menu test
checks each option's own control rather than any matching text. Card
display rows toggle one property each, with the folder-path copy in
shared. The unused Status and Tasks copy left by the removed classic
card list is deleted from the catalogs. Filter rows write through the
store directly; files are named for what they hold.

* feat(runtime): worktree.ps carries the Workspace options row facts

Each worktree.ps row now says whether it is the project's default-branch
workspace, created by an automation, created by the CLI, or on a
detached HEAD, answered by the same shared predicates the desktop
sidebar filters with. The phone has none of these facts today, so its
copies of the filters either guess (default branch) or are missing.

Additive and inert: optional fields that nothing reads yet. An older
host omits them; readers must treat absence as 'not that kind', so the
row stays visible. Server rows relayed through the desktop pass them
through unchanged.

* feat(mobile): the desktop's own phone reads and writes its other-client filter

ui.get and ui.set strip the pairing-local UI fields for every paired
client. That rule exists for clients with their own host catalog (a
desktop paired to a server, a web client). A phone has none: it shows
its desktop's view, so 'Hide other-client workspaces' must be one shared
value. For the host's own phone (clientKind mobile, no relaying parent)
ui.get now always answers the field and ui.set persists it; delegated
phones, paired desktops and web clients keep the stripping rule.

The phone's view-settings schema reads the rest of the Workspace options
values it does not read yet (the automation, CLI and detached-HEAD
filters, project order, card properties, agent activity layout), each
salvaged to absent so an older desktop keeps the phone's local value.

* feat(runtime): address review of the worktree.ps row facts

Name the fact defaultBranch, and say what absence means for it: a reader
keeps its own guess, because 'hide default branch' must not start hiding
or showing rows an older host never described. Folder workspace rows get
the facts too, so every row from a newer host carries them. The
desktopOtherClient comment now says it is a verdict, not a fact. The test
covers the rows where the host disagrees with the phone's current guess.

* wip: phone uses the shared evaluator

* feat(mobile): address review of the phone's other-client filter mirror

One isHostOwnPhone predicate decides both who shares the filter value
(ui.get/ui.set) and who gets the other-client row marks, so the two
cannot drift. The pairing-local field list notes the exception. Tests
cover a paired desktop still being stripped, and a phone write that
leaves the field out not touching the host value.

* feat(mobile): the phone filters and sorts with the desktop's shared Workspace options rules

The phone's own filter chain and comparator are replaced by the shared
evaluator, reading the facts the host stamps on each row. Phone filter
state takes the desktop's setting names and carries every filter; the
badge and Clear filters match the desktop's. The desktop's other-client
marks become facts the phone filters with the shared value.

A parity test runs the same workspaces through the desktop's reading
and the phone's (facts stamped as the host stamps them) over every
filter combination, folder projects included.

* wip: phone view state carries project order and card props

* fix(mobile): host scope in shared filters, patch-key writes, live view push

Folds the phone's host filter into the shared filters so Clear and the badge agree,
writes back only the touched filter keys, and pushes workspaceViewChanged so an open
phone list re-reads desktop Workspace options.

* fix(mobile): Clear sends only changed filters; skip a view re-read older than a local edit

An older host's strict ui.set refuses a key it lacks, so Clear names only what differs. A ui.get
that started before a local edit no longer overwrites it; the edit's own change event re-reads.
The change publisher's field list now lives with it and derives the filter fields.

* feat(mobile): the phone's Workspace options sheet, rendered from the desktop's shared option list

The Filter drawer becomes the desktop's Workspace options: hosts, projects with search, group,
sort, project order, card layout and properties, card display, and every filter. Each option
renders through a table keyed on the shared option keys, so a new desktop option fails to compile
until the phone has a control. Sort and group pickers use the desktop's words; Repo reads Project.

* fix(mobile): Workspace options sheet review fixes

Hosts read as on the desktop (none checked while all show; the last shown host is disabled).
Project order falls back to the served value until the shared one is read. Card layout and card
style changes now push to an open phone. Pick-one rows read as radio buttons. Simpler wiring:
flat card state, the preset flag rides the patch, shared choices used directly.

* fix(mobile): reset project order on host switch; simpler preset-flag handling

* fix(workspace-options): all hosts and clear reach the desktop; one filter key list drives every surface

Host visibility: the legacy host scope fallback moves to shared code; the phone reads it, and the
host resets the scope whenever a client writes the host list, so All hosts and Clear stick.
An older desktop that keeps the other-client filter to itself has its marks treated as hide.
Badge counts, is-anything-filtered, Clear and reveal derive from the shared filter keys on the
desktop sidebar and the kanban filter menu. The phone uses the desktop's group and sort values,
holds its view in one state value, and reads its UI fields from one shared list.

* feat(workspace-options): Status and PR grouping read the shared tables

The phone groups by status with the desktop's status helpers and default catalog (its own copy
had the defaults in reverse order) and by PR with a shared lane table, so its labels now read
In review / In progress as the desktop's do. The desktop's PR lane decision ends in the same
shared state-to-lane step.

* feat(mobile): phone rows follow Card layout, Show properties, Card display and agent activity

One shared helper (getWorktreeCardDetails) decides which card details show for the chosen
properties and layout; the desktop card's flags and the phone row both read it. The phone row
hides the PR badge, note/issue/Linear glyphs, host badge and agent rows the desktop turns off,
and the Full list agent layout shows every agent row.

* test(sidebar): folder rows survive restrictive filters; create grace ignores non-finite times

* fix(mobile): write the host scope with the host list, and wait for the first read before forcing the other-client filter

An older desktop narrows back to its legacy single-host scope when the phone
writes only a null host list, so the phone now also writes the scope for a null
or one-host list. The other-client fallback waits for the first ui.get so a new
desktop's marks don't hide rows before its filter value arrives. Inline the
clear-filters key list and note the PR state asymmetry.

* test(mobile): re-record view-settings goldens for the unknown other-client state

* fix(mobile): let a tall bottom drawer scroll on the page

The drag wrapper around the drawer's ScrollView had no flex limit, so it grew to
its content and the sheet clipped it instead of scrolling. The full Workspace
options sheet is the first drawer tall enough to hit this.

* fix(sidebar): reuse the palette's default host instead of reading the focus setting again

* fix(sidebar): read the host options once in Workspace options and pass them to the Hosts item

* fix: parse the ui.get reply into a named type, fix the row memo test's filters, prune the focus-read baseline

* test(mobile): give the project catalog fixture its project order

* feat(mobile): name the options chip Workspace options, as the desktop does

The chip keeps the active-filter count and reads to screen readers with the
desktop trigger's label. The pluralized filter count moves to shared code so
both surfaces word it the same way.

* fix(mobile): show the Workspace options count as a corner badge so the chip row fits

"Workspace options (2)" squeezed the Sort and Group chips to ellipses at phone
width. The count moves to a corner badge, as on the desktop trigger, and the
chip drops its icon, which the desktop trigger does not show either.

* refactor(sidebar): one switch-to-kind table, and fold the leftover helper files back

- The four kind switches come from WORKSPACE_KIND_SWITCH in workspace-kinds;
  the shared evaluator and main's phone-push check both apply it, and the filter
  type derives its switch fields from it.
- git-identity.ts folds back into git-identity-display.ts, as on main.
- SidebarFilterState moves into sidebar-filter-actions.ts; visible-worktree-kinds.ts is gone.
- buildWorkspaceRowPredicate is module-private.

* feat(mobile): Workspace options sheet in the desktop menu's shape

The sheet showed every choice of every option, so the filters sat screens down.
It now follows the desktop menu: Hosts and Projects rows with their value, Group by
as a segmented control, Sort by, Project order, Card layout and Show properties
as rows that open a page of choices, and the filters as switches. Each option's
control (an inline row, or a value plus a page) still comes from one table keyed
by the shared option list. The segmented control is the voice settings one,
extracted so both screens share it.

* fix(mobile): Workspace options review fixes

- A page whose option stops showing is forgotten, so it cannot reopen by itself.
- Only the switch takes the tap on a filter row, so the web page writes once.
- Sections split their rows in one pass; unused heading styles go.

* fix(mobile): neutral filter switches on the page

react-native-web paints an on-switch thumb teal unless given activeThumbColor,
which React Native's types omit. MobileSwitch applies the app's switch colors,
and its web sibling also sets that prop.

* refactor(mobile): one MobileSwitch file, adding the web-only thumb color on web

* fix(mobile): Show properties reads Hover under Compact, as the desktop does

The row's value now comes from getWorkspaceCardPropertiesValue in the shared
options, which the desktop trigger uses too: Hover under the compact layout,
otherwise how many properties show. Card display shows no value, as on the desktop.

* test(mobile): find the renamed Workspace options chip in the page drawer render check

* feat(mobile): drop the Sort and Group chips; the Workspace options chip covers both

The desktop has only its options button, and the sheet now holds Sort by and
Group by. The chips' pickers, their screen state and the sort label go with them.

* feat(mobile): option pages slide in from the right and keep the sheet's height

The main sheet stays laid out under an open page, so the drawer keeps its height
going in and out. The page slides in over 200 ms and back out on back (the row,
or Android's back key), instantly under reduced motion. The reduced-motion hook
moves to platform/ now that two screens use it.

* fix(mobile): option pages fill the sheet and open at its top

- The page layer grows to the pager's height, so no part of the main sheet shows below a short page.
- The drawer takes a scrollResetKey; the sheet passes the open page, so a page opened from a row low on a scrolled sheet starts at the top.
- The main sheet's height is measured under a page too, and the pager state lives in the sheet so it resets when the drawer closes.
- A misplaced doc comment goes back to its function.

* fix(mobile): phone rows show the Jira, automation and CLI glyphs; the options pager forgets a gone page in an effect

* fix(mobile): Jira glyph sits before Linear as on the desktop; Jira reader tests beside it

* fix(mobile): the phone leaves Ports out of Show properties and Card display

* refactor(mobile): clearer names and tests for the phone's hidden card properties

* refactor(mobile): one view state and one settings read for the phone's Workspace options; tighter card glyph wiring

- The phone's Card layout and card style now live in its view state; one parallel settings.get
  reads them with pinned placement beside ui.get.
- The Card layout preset flag is a write option, no longer a view field or a change-event field.
- getWorktreeCardDetails reads the raw desktop settings and resolves the layout once; the desktop
  card computes it once in its foundation.
- The row computes its glyph presence in one place; Ports is hidden through one set.
- Drops the vanished-page effect, the native-only a11y props and the web-only switch branch.

* fix(mobile): the list settings read applies each half on its own, with tests; pinned placement changes announce themselves

* fix(mobile): Card layout writes the setting before its preset; Card display names folder paths when the desktop has folder workspaces
2026-10-15 19:27:51 -07:00
Jinwoo Hong a134d1259e feat(mobile): tell users when a newer app binary is installable (#23755)
* feat(mobile): tell users when a newer app binary is installable

With OTA page updates, store releases get rare and users stop looking.
The shell now asks the channel that installed it. Android sideload
reads GitHub's mobile-android-v* tag refs and proves the release has
an APK. iOS reads the App Store lookup. A home card above Desktops,
dismissible per version, and Settings rows surface the result. The
check runs on the desktop updater's cadence: cold start, foreground
once 24 h have passed, and a 1 h retry after a failure.

The releases atom feed was not used because it lists only the 10
newest releases, which are all desktop builds, so it never carries a
mobile tag.

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

* refactor(mobile): parse update replies with zod schemas

The anti-slop gate refuses Reflect.get on dynamic input. The GitHub
refs, the release, the App Store lookup and the stored update record
are now parsed into named schemas before they are read.

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

* docs(mobile): say why the Android update source reads tag refs

Record why the Android source reads tag refs. The releases atom feed
and /releases?per_page=100 are both newest-first windows that desktop
releases fill. Either would silently report "current" once a run of
desktop builds pushes the newest mobile release out.

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

* refactor(mobile): load update state once and apply review rulings

Every check and dismissal now awaits one shared store load. This
replaces the merge that guessed whether a check had landed during the
load. A manual check that fails while the store loads therefore keeps
its 1 h retry instead of re-checking at once.

- checking is derived from the in-flight check.
- start() uses a per-start flag, so a StrictMode double start applies
  one load.
- A check that finishes after stop() writes nothing.
- A corrupt stored update record loses only itself.
- Tag refs are parsed with a single schema.
- The runtime wiring is folded into one file, and the card moves to
  home/.
- The recorded App Store fixture is oxfmt-formatted, with the same
  parsed value.

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

* fix(mobile): keep the update timer armed across a stop and restart

A check that stayed in flight across stop and restart returned
'failed' without rescheduling. The restart skipped arming because a
check was in flight, which left a live checker with no timer until
the next foreground. The stop counter is removed. schedule() already
arms nothing while no start is active, and saving a real result after
a stop is harmless.

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

* chore: retrigger CI after the RPC recording repin (#23757) landed on main

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

* refactor(mobile): trim the update checker and Settings rows

- The load sets prefs and the due time only. start() re-arms the
  schedule after it.
- The Settings result hides through one effect keyed on the result.
- onUpdate receives the URL.
- The version row is bound once.
- The retry and timeout constants are no longer exported.
- The unused AppUpdateChecker type is deleted.

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

* fix(mobile): run the update check when its timer fires

The armed timer is the due time. Re-checking the wall clock when the
timer fired meant a clock stepped back skipped the check and re-armed
nothing. The due-time guard now applies only on foreground.

The binary version still comes from expoConfig.version. SDK 55
removed Constants.nativeAppVersion, so the no-expo-updates invariant
is now named in the comment.

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

* fix(mobile): recover from a future check time and use Apple's page URL

If the device clock was ahead when a check ran and was corrected
later, the stored check time is in the future. Cold starts then armed
a timer for the whole skew, and foreground never came due. The stored
state now reads as never checked in that case.

The iOS link is the lookup's trackViewUrl instead of a URL built from
trackId.

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

* style(mobile): fit the future-check-time comment in the print width

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-28 22:28:50 -04:00
Jinwoo Hong 3e32b83522 refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7) (#21176)
* refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7)

Twenty-one unchecked reply readers across thirteen files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`, or
a card left "proven" over a reply that carried no rows. Deliberately a behaviour
change on malformed replies only.

What each domain required, and why it required no more:

- notifications (5 readers). All four call sites read the payload through `?.`,
  so every schema is nullish at the top level and no member is required. The
  test-push `reason` and the register `reason` become closed enums, because the
  two comparisons against them are the whole of what they decide and an arm this
  build does not know took the generic copy on main too. The stream unsubscribe
  and the unregister read no body at all.

- components (4). `repo.hooks` requires `source` and nothing else: the drawer
  assigns it straight into `SetupHookDetails.source`, whose type is
  `string | null`, with no guard in between — nullable so the "no hooks file"
  answer keeps its explicit null. `setupTrust` is nullable as well as optional
  because the `components-setup-ask` fixture sends an explicit null, and
  salvaging that would move a `normal` golden. `ui.get`'s trust record salvages
  per repo, so one unreadable repo cannot cost the others their approvals. The
  Codex redeem reply stays `z.unknown()`: `decodeResetResult` is a real
  scope-and-snapshot validator and splitting it would give one reply two refusal
  rules.

- terminal (4). The send verdict and the viewport pair keep main's exact
  `=== true` projections. `terminalSendAcceptedSchema` moves here from the
  session domain, which now re-exports it: terminal is the lower layer and two
  identical copies could drift on what "delivered" means.
  `terminal-send-rpc-response.ts` is deleted, its projection now being the
  schema's.

- transport (3). `status.get` declares its five members and requires the object;
  the three callers disagree about what an unreadable status means, so each keeps
  its own verdict behind a named reader — the gate wants the failure, and the
  probe and the pairing race must not have it, because both call `interpret`
  inside a `.then` fulfilment handler where a throw becomes a detached rejection.
  `capabilities` salvages whole rather than per element, which is main's own rule
  and what `transport-capability-probe-non-string-capabilities-drop` records.
  The two pairing readers are the shared credential contract itself, moved off
  the four call sites that each ran `.parse()` on the interpreted value; its
  `.strict()` is main's shipped rule for that released surface, not a new one.

- home (2), worktree (2), browser (1). The stats row is checked as an object and
  nothing more, `totalHomeStats` being the reader that says so itself; its
  per-host slot is now typed as the wire row it holds rather than as the computed
  total. `worktree.ps` cannot require `worktrees`: the host answers a union whose
  unchanged arm carries `{ unchanged, snapshotId }` and no rows. The twelve
  browser commands read no body; `browser.goto`'s settled URL stays nullish
  because `navigateToAddress` is inline in `MobileBrowserPane.tsx`, which no
  adapter mounts, and a move there would ship unevidenced.

Three fixtures were wrong and are corrected, each disclosed rather than worked
around: the runtime-context test kept a content hash directly under a repo key,
which is not a shape `ui.get` sends; and two snapshot-client tests ran their
reply list dry and handed `fetch` an absent result while claiming to model a
transport failure.

`push-test-envelope` is re-anchored at the same defect's new home, the cast
having been deleted. The boundary test's offender floor comes down from 20 to 10
with the list, which is what its own comment says it is for.

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

* test(mobile): repin the corpus and re-record step 7's checked reply readers

`baseline` moves to this branch's product commit, which is what `--record`
compares the fenced tree against, and every one of the 758 goldens is
re-recorded from it. The repin is what rewrites the `baseline` header on all of
them; nothing else about the corpus moves except the bodies disclosed below.

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

* test(mobile): mutate the workspace catalog's reader back to unchecked

The step-7 defect evidence needs a scenario whose reply is the one the change
moves. Every pilot scenario in the catalog family scripts a well-formed reply, so
a mutant that only changes how a *malformed* reply reads has nowhere to diverge —
which is why the pilot's own suite passed against an unchecked catalog reader
while its matrix golden failed.

`worktree-catalog-snapshot-unreadable` scripts `worktree.ps` answering
`{ ok: true }` with no result at all, which is what `result-absent` drives at the
matrix site, and records the fetch rejecting with `RpcIncompatibleReplyError`.
`worktree-catalog-unchecked-reader` then swaps the operation's reader for one that
answers `compatible: true` for every payload — main's reader, in one line — and
the recording moves back to a fulfilled fetch carrying
`admission: { kind: 'invalid' }`, which is the answer that let a broken catalog
render as an empty host (STA-3123).

One golden added and none moved: the manifest sits outside the fenced paths, the
family's matrix base is still `worktree-catalog-snapshot`, and the mutation
registry is not part of `recorderSha256`.

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

* test(mobile): pin the push-test reason arms the closed enum constrains

`pushDeliveryTestResultSchema.reason` closes over the four arms of the host's
`MobilePushTestResult` (src/shared/mobile-push-contract.ts:99), but no scenario
carried the member, so the corpus could not have caught a wrong vocabulary.
Three scenarios on the existing display-test mount carry it now: the two arms
the screen branches on and one arm no build knows.

Each golden was recorded first at the main pin 4b876758d3 and reproduces there
byte for byte, so the `normal` partition pins main's rendering rather than this
branch's. The unknown arm is the load-bearing one: main renders "Could not send
through Orca's push service." for an unrecognised string, and the salvage drop
renders the same sentence, so the closed enum costs a recorded `reply-salvage`
observation and no screen text.

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

* docs(mobile): cite the host union behind each closed reply enum

A closed `z.enum` is only as good as the vocabulary it was checked against, and
mobile's own declared types are written from memory. Each of the three enums now
names the host type it mirrors, so the next reader re-checks it in one grep
rather than trusting the arms.

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

* test(mobile): repin the corpus to the commit that carries the host-union citations

`--record` compares the fenced tree against `baseline`, so a comment in
`mobile/src` moves the pin like any other product edit. Every one of the 762
goldens changes by exactly its `baseline` line and nothing else, which is the
evidence that the citation commit is inert.

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

* fix(mobile): keep the agent-history panel rendering when the catalog omits its rows

`worktrees` is a salvaged member, so it is absent on the three envelopes that
read as an object without rows, and `use-mobile-agent-history-state.ts:61` calls
`.find` on it with no guard. The cast erased that and the corpus recorded the
result: `matrix-aivault.history-screen-worktree.ps-1` carried
`crash: Cannot read properties of undefined (reading 'find')` on
`inner-ok-missing`, `inner-false-string-error` and `inner-false-object-error`.

`?? []` is what the sibling Home card already does. The SAFETY note cited that
card's golden, which is the opposite site, and now cites this panel's own family.

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

* fix(mobile): keep a malformed worktree.ps reply reported as invalid_response

`host-workspace-list-states.tsx` prints this code to the user verbatim, so the
checked reader's throw landing in the generic catch renamed a host-payload
defect into a connectivity failure. STA-3123 exists to make a broken remote host
diagnosable, which `network_error` is not.

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

* refactor(mobile): give the browser page commands back their one-line form

Only `browser.goto` reads a reply body, so the reader belongs on a two-argument
wrapper rather than on all thirteen call sites. The exported type of every
command is unchanged, and the doc comment no longer promises a shape the file
did not have. 109 lines to 72.

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

* refactor(mobile): import the terminal send verdict from the terminal domain

The re-export forwarded to two callers, which a direct import already gives
them, and the session suite re-pinned three cases `terminal-reply-schema.test.ts`
owns. One definition, one pin, one file hop fewer.

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

* test(mobile): move the codex reset-credit pins next to their schema

They sat in the New Workspace suite, so a reader looking for the capability
whole-list drop by filename did not find it.

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

* test(mobile): repin and re-record after the round-1 fixes

Three checkpoints of `matrix-aivault.history-screen-worktree.ps-1` move from a
recorded white screen to the loading list: `inner-ok-missing`,
`inner-false-string-error` and `inner-false-object-error` answer the panel an
object with no rows, and the `?? []` now seats an empty list instead of letting
`.find` throw. Every other golden changes by its `baseline` line alone, which is
the evidence the other four fixes move nothing the recorder observes.

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

* refactor(mobile): stop requiring the stats row its own reader guards

Round-2 findings 1 to 3.

`homeHostStatsSchema` required an object that `totalHomeStats` already guards
(`if (!host || typeof host !== 'object') continue`), so the requirement bought
nothing at the read and cost the row upstream: the refusal reached
`fetchMobileHomeStats`'s `.catch`, the per-host slot was never written,
`hostIds.filter` found no host and the Home header drew no stats row where main
drew `0 / 0s / 0`. It takes `.nullish()`, and `HomeStatsRow` admits the
`null | undefined` main always had. The unit pin now says the slot keeps a null
summary and the total skips it, and sums one through `totalHomeStats` to show the
zeroed row survives.

The Home card's `SAFETY:` note claimed the reader proves `worktrees` is an array.
It does not; the `?? []` does. That is the same false sentence round 1 removed
from the agent-history panel, and a reader who believed it would delete the `??`
and reintroduce the white screen.

The `catalogError` branch on `RpcIncompatibleReplyError` had nothing holding it:
no adapter mounts the host screen, so no golden can reach it. One case in the
snapshot client pins the class the `catch` keys on. Mutation-checked by forwarding
the catalog schema as `z.unknown()`, which fails that case alone.

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

* test(mobile): repin and re-record after the round-2 fixes and main's merge

Pin moves to 0b8bd1c3c7, this branch's last fenced commit. The re-record
normalises the ten session create-terminal goldens main re-recorded in #20069
without repinning, and writes the Home stats family's bodies back to what main
records now that the summary reader no longer requires the object its own
consumer guards.

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

* test(mobile): repin and re-record the corpus at the merge with main

The merge took main's goldens verbatim at main's pin, so the four goldens this
branch adds were the only ones whose header did not name a commit in this
history. Repinning to the merge commit and re-recording gives all 764 one pin
and one recorder, which is what the new ancestry guard asks of the corpus.

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

* docs(mobile): point the reply schemas' citations at the lines they claim

Every `file:line` in this branch's diff was resolved against the tree and its
target line compared to the claim beside it. Fourteen were wrong. Most had
drifted one to four lines onto a closing brace or the line after the code they
quote, which reads exactly like a good citation and can only be caught by
resolving it.

Two were wrong in kind rather than by a few lines. The viewport comment
credited the `=== true` projection to the refit call site, which reads plain
truthiness; the rule was main's reader, and the refit's two decisions are the
lines now cited. The capability comment cited a line for main's
`every(typeof === 'string')` rule inside the code this change deletes, so it
resolved to an unrelated brace; it now states the behaviour and says why no
line carries it.

Two more pointed at the head or tail of the statement they named and are
tightened to the line that does the work.

Comment-only: no schema, no reader and no call site moves. The corpus is
repinned and re-recorded on top because the recorder fences `mobile/src`.

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

* test(mobile): repin and re-record after the citation fixes

The citation commit is comment-only, and the corpus says so: all 764 goldens
move on the `baseline` header line and nothing else. The re-record is needed
only because the recorder fences `mobile/src`, which a comment is inside.

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

* fix(mobile): keep the three unguarded-site reply schemas total so a malformed result cannot throw where main tolerated it

The ui.get reader is interpreted inside a fire-and-forget IIFE with no catch, and the home
stats and push delivery readers sit behind handlers that would print the reader's own sentence
where main drew a zeroed row or the generic copy. Each schema now decodes any unreadable
result as absent, which lands in the fallback main already took.

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

* test(mobile): repin and re-record the corpus over the total schemas

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

* docs(mobile): name the host-status gate reader by its export

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 12:40:18 -04:00
Jinwoo Hong b8d4cde09f refactor(mobile): send six screen-mounted call sites through typed RpcOperations (step 4, wave 3) (#20919)
* test(mobile): record six screen-mounted call sites before migrating them

Five new mount adapters and six scenarios, recorded against the pinned baseline's
product code so the goldens are main's behaviour, not the refactor's.

Each site is a screen the recorder could not previously mount:

- `home.host-accounts` mounts `fetchMobileHomeAccounts`, whose decoder is
  re-exported through `AccountUsage.tsx`. That module loads under the mount
  loader, so the inventory's "no recording can load it" was already stale.
- `notifications.display-test-screen` mounts the settings push probe and presses
  its button by reading the handler back off the rendered inert `Pressable`.
- `aiVault.history-screen` mounts the history panel, which is where the last
  `worktree.ps` lives. Split in two: the base stops once the worktree list has
  seeded the scopes, because a reply partition there changes the scopePaths the
  downstream `aiVault.listSessions` carries, and a matrix variant cannot assert
  params it moved. The full chain is a second scenario, driven as a pilot only.
- `tasks.route-repo-list` mounts the tasks screen-root hook and calls its own
  `ensureLoaded`, which is the only thing that fires `repo.list`.
- `linear.select-workspace-picker` calls the render helper the tasks surface
  calls and invokes the `onSelect` on the element it returns. The picker draws
  inside `BottomDrawer`, whose reanimated timing driver and gesture builder the
  recorder would have to impersonate for a row to exist; the closure is the same
  either way, and the workspace a selection carries comes from the scenario.

Five substitute members are added, each with the recording that reads it:
`react-native-safe-area-context.useSafeAreaInsets` and
`expo-router.useLocalSearchParams` for `tasks.route-repo-list`, and
`react-native.TextInput`, `.SectionList` and `.RefreshControl` for
`aiVault.history-screen` once its list renders. `useLocalSearchParams` answers one
pinned route for the same reason the window size is pinned: a screen's own address
is not a device reading, and the one screen that reads it sends `repo.list`, which
takes no params.

Touching the substitute table moves `recorderSha256`, so all 641 existing goldens
are re-recorded. Recorded from a detached worktree at the pinned baseline with this
branch's recorder laid over it: every pre-existing golden is header-only, verified
by resolving both sides through the value pool — 641 header-only, 0 body, 0 deleted,
one distinct `recorderSha256`, `baseline` and `lockfileSha256` across all of them.

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

* test(mobile): type the linear workspace picker's model fixture

`mobile/tsconfig.json` covers the recorder, and the fixture's setters were written
with the argument the product happens to pass rather than the `SetStateAction` the
model declares. Typing them moves `adapterSha256` on the two goldens recorded through
this module, so they are re-recorded here rather than in the refactor commit, which
must move none.

Re-recorded at the pinned baseline: `linear-select-workspace` and its reply matrix,
header-only, bodies unchanged.

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

* refactor(mobile): send six screen-mounted call sites through typed RpcOperations

Nine references off the raw request port, across six files. Every one is proven
against the goldens recorded in the previous commit from the pinned baseline's
product code: this commit moves no file under mobile/rpc-foundation/goldens.

Reused rather than redefined:

- `worktree.ps` in the history panel sends through `worktreeCatalogRead`. Same
  question, same acceptance — a refused list leaves the screen on what it holds.
- `repo.list` in the tasks screen-root hook sends through `newTabRepoListRead`.
  Its policy raises the host's message and its reader takes `repos` off the payload
  while preserving the property-read exception a null result used to throw at the
  cast, which is what this call site did by hand. Its name still says new-tab; a
  third consumer does not make renaming it this bucket's business.

Four operations are new, each because no existing reader on the method takes this
consumer's input:

- `files.read-directory-or-skip` and `files.legacy-explorer-list-or-skip` for the
  explorer. Both skip, because neither refusal is the operation's to decide: the
  readDir refusal code selects the legacy fallback and the list refusal supplies the
  message. The existing `files.list-or-skip` reads the `files` member alone, and the
  explorer also needs `truncated` for the "Showing first 5000" note.
- `accounts.home-snapshot-or-skip` for the Home card, decoded by
  `decodeAccountsSnapshot` at the call site as before.
- `notifications.test-push-or-skip` for the settings probe, whose `forbidden` and
  `method_not_found` refusals mean "try the next desktop".
- `linear.select-workspace-or-skip` for the filter sheet.

Two behaviours are preserved rather than repaired, both recorded:

- The workspace switch never read its reply. `.then(() => loadLinearContext())` runs
  on a refusal exactly as on a success, so only a transport rejection reaches the
  error copy. Interpreting the operation here would surface a refused switch for the
  first time; that is a product change with its own re-record.
- `app/terminal-settings.tsx` still reads `ms` off the reply envelope instead of off
  its result, so the value is always undefined. It did not migrate, and the inventory
  now carries the defect as its own note.

Four mutants are added, one per new family that admits a state-only one:
the Home snapshot, the push test result and the tasks repo list each decoded one
level above the envelope, and the workspace switch with its context reload dropped.
`aiVault.history-screen` gets none and says why in the suite: everything
`worktree.ps` publishes also moves the `scopePaths` the next scripted completion
asserts, so a mutant aborts the sequence instead of diverging from it. Its evidence
is the reply matrix at that request.

The tasks source-parity ratchet moves with the family it guards: hook, statement,
declaration, render and style counts are unchanged, and the semantic source is a pure
deletion of four lines — two `rpc:` call signatures and the two method literals they
carried.

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

* test(mobile): matrix the six new screen families' replies

One golden per scripted reply, eleven partitions each, recorded at the pinned
baseline alongside the pilots. Seven sites: `accounts.list`, `notifications.testPush`,
`repo.list`, `linear.selectWorkspace`, and all three of the history screen's —
`worktree.ps` and the two `status.get` reads its scan chains off the worktree list.

The history matrix is also that family's defect evidence in place of a mutant: every
partition at `worktree.ps` changes the `scopePaths` the downstream `aiVault.listSessions`
carries, and the sender args are recorded with it.

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

* docs(mobile): correct three operation and mutant comments

Comment-only, no product behaviour and no golden movement.

- `worktreeCatalogRead` says two readers; there are three. Names the third
  (the agent-history panel's `scopePaths` seed) and drops the stale count from
  the module header, which described call sites rather than the two operations.
- `newTabRepoListRead`'s census counted the two operations over `repo.list`, not
  its own two callers, and claimed both read a workspace's connection id. The
  tasks route keeps the whole list for its repo pickers. The split from
  `nativeChatRepoListRead` stays where it belongs: acceptance.
- The `aiVault.history-screen` mutant note pointed at the reply matrix as the
  accepted-vs-refused oracle. Decoding
  `matrix-aivault.history-screen-worktree.ps-1.json` through the value pool
  shows `normal`'s projected state is identical to all seven non-crashing
  partitions (spinner, two labels, zero rows). The real oracles are the next
  request's `scopePaths` (`["/repo/feature"]` vs `[]`) and the crash channel the
  three `inner-*` partitions land in.

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

* docs(mobile): give the second files.list reader its real reason

Comment-only, no product behaviour and no golden movement.

`legacyFileListRead` claimed "the member reader rejects this consumer's
input". Nothing rejects: `rpcUncheckedMemberReader` returns the member,
and reusing it here would simply drop `truncated`. The reason the explorer
declares its own operation is the other direction. Widening
`files.list-or-skip` to a payload reader would split the `workspace-files`
variant it shares with `nativeChatFileSearchRead` over
`files.searchPaths`, whose only caller feeds both through one
`extractPaths` in `use-mobile-native-chat-file-search.ts`, so the member
read would move into that hook rather than disappear.

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

* style(mobile): indent the six scenario entries spliced during the merge

The conflict on `pilot-scenarios.json` was resolved by id rather than by
hunk, splicing this branch's six entries into main's text at the array's
close. The splice started at the entry's `{` instead of at its line, so
those six lines lost their indentation. oxfmt's only change is those six
lines; the parsed document is identical, and the recording suite still
matches all 667 goldens, so no scenario digest depends on the raw text.

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

* test(mobile): re-record the merged goldens once at the pin

One record for the whole merged tree, at the unchanged baseline
e7206f62a8, through a detached worktree reset
to that pin with this branch's rpc-recording tree, scenarios and recorder
script overlaid. Product source in that worktree was proven identical to the
baseline before the run, so the recordings describe the pre-refactor product.

13 goldens move, all of them the ones #20915 added. They arrived carrying the
recorder digest from before this branch edited `screen-native-substitutes.ts`,
and `recorderSha256` is the only key that moves on any of them; every
recording body is identical after decoding through the value pool. The other
654 were re-recorded byte-for-byte and are not in this commit.

All 667 goldens now carry one `recorderSha256`, one `baseline` and one
`lockfileSha256`.

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

* docs(mobile): state the real gates on two screen holdouts

Comment-only, no product behaviour and no golden movement.

The accounts route said "the screen now mounts". It does not, at this
commit: it reads `expo-router.useFocusEffect` and `react-native.ScrollView`,
neither is a substituted member, and the trap refuses before any effect
runs. The note now names that as the first gate and the `accounts.subscribe`
effect as the second, and says why the two members are not added here.

The host-screen overlay note blamed a "reanimated timing driver" for
deciding when the drawer's children exist. Nothing gates them:
`resolveBottomDrawerMounted` returns `visible || mounted`, `BottomDrawer`
renders `MountedBottomDrawer` on that, and that component renders its
children unconditionally inside its `Modal`. The blocker is the module's
own imports of reanimated and gesture-handler, neither substituted.

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

* test(mobile): drop the tasks route adapter's unreachable reload action

No scenario names `reload-repos`, and no schedule driver can generate it:
the drivers emit only disconnect, cutover, reset, unmount, blur and remount.
Every other action on this adapter is reached by a scenario. Deleting the
branch leaves the remount and unmount branches, which are driven.

Re-recorded once at the pin e7206f62a8 with
the product source in that worktree proven identical to the baseline first.
Two goldens move, both in the `tasks.route-repo-list` family, with
`adapterSha256` the only moved key and both recording bodies identical after
decoding through the value pool. The other 665 re-recorded byte-for-byte.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 22:10:22 -04:00
Jinwoo Hong d130347993 refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations (#20702)
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's

`recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR
that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its
merge with main conflicted on that one line in 153 files; every future domain PR would collide
with every other in flight the same way.

Split the directory at a real seam instead of a filename convention: `adapters/` holds one module
per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers
the engine only. A new `adapterSha256` covers the source of the module that mounts each operation
a golden's scenarios drive, read off the same `mounts` calls that build the table the recording
runs against, so the pin cannot name a file the runner did not use.

Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the
goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file
inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file,
and an adapter importing a sibling each fail.

The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which
leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new
header field; the goldens re-record in the next commit.

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

* test(mobile): re-record the RPC goldens under the split recorder/adapter digest

Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers
`adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and
recording ran against the same pinned product tree.

    git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l
    0

The seven `adapterSha256` values partition the 153 goldens by the module each was recorded
through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory,
9 tasks, 8 workspace settings.

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

* refactor(mobile): stop pinning goldens to recorder inputs no recording can read

The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the
per-family mutant registry beside it, and the probe-hole witness. None can change a recording --
the loader consults a mutant only when a mutant test asks for one, and no suite but the two
recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have
and charged every domain a full re-record for it.

`mutants/` now holds the table, the registry, the reference states, the mutant suites and the
probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can
reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by
name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts`
checks exactly that, and fails if an engine file names the directory or anything outside imports
from it.

`recorderSha256` also pins only the suites in `recording-drivers.ts`, which
`scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or
writes one to a scratch directory, is no longer provenance for a recorded file.

`OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold
the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own
exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden.

Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to
`root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed
anything. Each call now spells the root differently.

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

* test(mobile): re-record the RPC goldens under the mutant and driver exclusions

Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153
because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that
module now carries its own exposure declaration.

    git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256)":' | wc -l
    0

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

* fix(mobile): restore the preferences actions the merge resolution dropped

#20568 added `resume` and `trust` actions to the `settings.task-preferences`
adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already
moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the
`pilot-mount-adapters.ts` conflict in favour of the registry merge silently
discarded them and `tw-task-preferences-resume-write` failed to record at all
("Missing or completed request: ui.set#1").

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

* test(mobile): re-record the RPC goldens at main's tip after the merge

All 208 goldens, header-only. `baseline` moves from 50e752fc66 to main's tip
c6a7216984, `goldenFormatVersion` from 4 to 5, `recorderSha256` to the value of
the engine with `adapters/` and `mutants/` carved out, and `adapterSha256` is new
on every file. Nine distinct adapter digests over 208 goldens: each golden now
pins only the module that mounts it.

No observation moved. The whole-diff census against origin/main reports exactly
four changed keys and nothing else:

  208 "adapterSha256":   416 "baseline":
  416 "goldenFormatVersion":   416 "recorderSha256":

Recorded in place rather than through the README's detached-baseline dance: this
branch changes no product file, so its tree at the merge is byte-identical to
c6a7216984 under mobile/src, src/shared and the lockfile, and the parity claim
stays non-circular. README says so now.

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

* test(mobile): hold the recording drivers to the engine's mutant-seam rule

The name scan exempted every `.test.ts` on the ground that a test cannot change a
recording. Two of them can: the recording drivers are the recording path. A driver
that read the mutant table by path rather than importing it passed both seam checks
— the import scan sees no import, and the name scan waved it through as a test:

  const table = resolve(import.meta.dirname, 'mutants/operation-mutations.ts')
  console.log(readFileSync(table, 'utf8').length)

at the top of `pilot-recordings.test.ts` gave 2 passed before, and after this change
fails with ["pilot-recordings.test.ts"].

Only non-driver tests are exempt now. This file lives in `mutants/`, which
`recorderSha256` skips, so no golden moves: the recorder suite is green on the
existing 208 with zero dirty.

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

* refactor(mobile): drop the registry parameter no caller varies

`pilotMountAdapters` took `registered` so a caller could mount a different module
set; all six callers take the default. The header-digest tests vary the registry
through `goldenRecording`, which keeps its own parameter and is where the stub
roots need it. Engine source, so `recorderSha256` moves and the goldens follow in
the next commit.

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

* test(mobile): re-record the RPC goldens after the registry parameter came out

All 208, `recorderSha256` only. The re-record against the previous commit moves
416 lines, every one of them that field:

  416 "recorderSha256":

Against origin/main the picture is unchanged from the merge: 208 goldens, 0 added
or deleted, 0 non-header lines, and exactly four keys differing —

  208 "adapterSha256"   416 "baseline"   416 "goldenFormatVersion"   416 "recorderSha256"

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

* docs(mobile): wrap the recording README at the width the rest of it uses

Seven lines this branch added ran past 100 columns, worst 124. No wording changed.
Markdown is outside `recorderSha256`, so no golden moves.

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

* docs(mobile): name the worktree overlay, not the archive that cannot work

`git archive` was offered alongside a detached checkout as a way to lay this
branch's recorder over the pinned baseline. It cannot work: the fence in
scripts/rpc-recording.mts runs `git diff --quiet <baseline>` and an untracked-file
check, both of which need a real `.git`. In an archive tree git exits non-zero for
lack of a repository and the script reports "Product sources or lockfile differ
from the pinned main baseline", which reads as a product mismatch that is not
there. The transport agent lost time to exactly that.

Names `git worktree add --detach` only, and says what the misleading failure looks
like if someone tries an archive anyway. Markdown is outside `recorderSha256`, so
no golden moves.

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

* test(mobile): close two ways an adapter module escapes its own digest

Two holes, one class: the seam was checked by how an import was spelled and by
what the register's values evaluated to, never by where they resolve or where they
were written.

Inward imports: the scan dropped every specifier starting with `..`, so
`'../adapters/settings-mount-adapters'` climbed out of the directory and back into
it unseen. A reviewer had `new-tab-agent-mount-adapters.ts` project a value read
from the settings module, edited that module, and watched the mounted state change
while the new-tab adapter digest held. Specifiers now resolve against the
directory and anything landing back inside it fails:

  ["new-tab-agent-mount-adapters.ts imports ../adapters/settings-mount-adapters"]

The register: `adapters/mounted-operation-modules.ts` is pinned by nothing —
`recorderSha256` skips the directory and `adapterSha256` reads each entry's
`source`. An `exposes` written inline there drives the mounted product module with
no digest covering it. The same reviewer replaced the new-tab entry's `exposes`
with a literal overriding `loadMobileNewTabAgentOptions`; twelve fence tests
passed. Both `mounts` and `exposes` must now be identifiers the register imports
from that entry's own module:

  ["new-tab-agent-mount-adapters.ts writes exposes inline instead of importing it"]

Checked on the register's syntax, not its values, because an inline literal and an
imported binding are indistinguishable once evaluated.

Pinning the register in the engine digest would also close it, and is the wrong
trade: every domain adding a register line would re-digest all 208 goldens, which
is the conflict this PR exists to remove. Keeping the register an index costs
nothing and keeps a domain's line local.

Both fixes live in a `.test.ts` outside the drivers, so no golden moves.

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

* test(mobile): prove the mutant seam from the drivers out, not by spelling

The seam rested on a grep for the literal `mutants`, which the exported
`MUTANT_DIRECTORY` spells without containing. A reviewer had
`pilot-mount-adapters.ts` read the mutant table through that constant and both
checks passed. The README's claim — that nothing on the recording path names the
directory — was false as written.

Three changes, in order of strength:

Reachability is now proved forward. The suite walks the static import graph from
the two recording drivers and fails if any module under `mutants/` is in it. That
answers the real question, what a golden's bytes can depend on, instead of the old
inward scan's question, who mentions this directory. Non-emptiness is asserted on
both sides so a graph that resolved nothing cannot pass by reaching nothing.

The name scan covers both spellings, for paths a module can be read by rather than
imported. The reviewer's probe now fails as ["pilot-mount-adapters.ts"].

`MUTANT_DIRECTORY` is no longer exported. Its two consumers were both tests of the
digest, and they now spell the path instead, which is strictly better for them: a
test that imports the constant follows a rename silently, while one that spells it
fails on a rename — and that specific directory name is the whole soundness
argument. This edits `recorder-digest.ts`, so the goldens re-record in the next
commit.

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

* test(mobile): re-record the RPC goldens after MUTANT_DIRECTORY stopped being exported

All 208, `recorderSha256` only. Against the previous commit the diff is 416 lines
and every one of them is that field:

  416 "recorderSha256":

Against origin/main, unchanged: 208 goldens, 0 added or deleted, 0 non-header
lines, four keys differing —

  208 "adapterSha256"   416 "baseline"   416 "goldenFormatVersion"   416 "recorderSha256"

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

* docs(mobile): state the mutant seam's actual argument, and its edge

The README claimed nothing on the recording path names `mutants/`. That was the
old inward scan's claim and a reviewer falsified it with the exported constant. It
now describes what the check does: a forward walk of the import graph from the two
recording drivers, plus a name scan in both spellings for read-by-path, plus the
constant no longer being exported. It also names the case neither closes — a path
assembled from fragments at runtime.

Markdown is outside `recorderSha256`, so no golden moves.

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

* test(mobile): prove the engine/adapter seam in both directions

The inward scan only held adapters to the seam. An engine file importing an
adapter executes code its own digest skips and that every golden recorded
through another domain leaves out of `adapterSha256`, so the register is now
the only crossing allowed from the engine side.

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

* test(mobile): name what the driver walk missed instead of counting it

Seeding `seen` with the drivers made the driver-presence check true by
construction, and the size bound compared a graph inflated by `typeof import`
product modules against a recorder-sized number. Both go; the walk now reports
the recording files it failed to reach, which is empty today and names an
orphan engine file the moment one appears.

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

* docs(mobile): reflow four paragraphs left ragged by the rewrap

Orphan fragments only, no wording change: the golden-schema field list, the
mutant-evidence paragraph, the probe-witness sentence and the re-anchor note.

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

* test(mobile): record the dictation, terminal, notification and browser domains against main

Adds scenarios and mount adapters for six small feature areas before any product file
moves, so the migration that follows has a frozen main oracle to be measured against:
dictation setup and session, agent history, terminal input and viewport refit, push
registration and the hosted browser's pointer, keyboard and dialog commands.

Five new adapter modules, one per domain, each pinned by its own `adapterSha256`. Two
declare exposures: `sendRegister`/`sendUnregister` are module-private in push-registration
and their exported callers read the keychain host catalog first, and the history hook
reaches its client through the shared per-host context rather than a parameter, so the
provider is the mounting boundary.

`native-mounting-substitutes.ts` is copied verbatim from the transport migration (#20667)
so the two branches merge, extended with the members these hooks read: `AppState` and
`useWindowDimensions` on react-native, the two-way audio module, `expo-keep-awake`, and
`buffer`. Every device event source is inert — no listener is fired — because each send is
driven through the operation's own API instead.

Wiring the substitute table into the loader moves `recorderSha256`, so every golden's
header re-digests. The product tree is unchanged, so `baseline` is unchanged and the 208
existing goldens move exactly one line each; no observation moves.

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

* refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations

Replaces 42 raw `sendRequest` reaches across six feature areas with declared operations:
dictation setup and session, agent history, terminal input and viewport refit, push
registration and the hosted browser's page commands. No wire change — every golden
recorded in the previous commit still compares byte-identical, which is the evidence.

Acceptance is preserved site by site rather than unified. Every dictation and browser
refusal already raised the host's message with a screen fallback, so those share one
policy and keep their own copy at the call site. The terminal's two input sites read one
boolean off an object result, which `object-result-or-null` gives them without a throw.
The worker-takeover report and the three enrichment reads in the resume sheet are skips.

Two latent behaviours are preserved deliberately rather than tidied. `repo.list` reads
`.repos` at the return statement, so a null result throws a raw TypeError there and not a
wrapped refusal message; the three enrichment reads beside it are optional-chained and
tolerate the same null. `speech.dictation.finish` checks its refusal before the staleness
guard and reads `.text` after it, so the member read stays at the call site.

`RpcSendArguments` now admits an explicit `null` where the catalog declares no params.
Four shipped senders put `params: null` on the wire for such a method, and a frame carrying
a null is not the frame that omits the key; without this the migration would have rewritten
those bytes. `src/settings/native-voice-settings-operations.ts` widens its client type for
the same reason — it holds the port only to hand it to dictation.

Three references are left behind, each listed in the inventory with what blocks it.

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

* test(mobile): matrix the dialog-dismiss site and name the three reaches left behind

The reply matrix drives every reply a family's base scenario scripts, and the browser dialog
family scripted only the accept leg, so repointing `browser.dialogDismiss` at another method
survived the whole suite. A scenario for the dismiss leg closes it; the mutation is killed now.

The inventory loses five section headers that no longer list anything, and the three entries
that resisted migration each carry what blocks them: a `worktree.ps` inside the history screen
component's own effect, an unsubscribe closed over inside a `subscribe` callback, and a
`notifications.getMissedSince` gated behind the OS notification tray and the keychain host
catalog. Faking either of the last two would record device state, not a wire.

Eighteen goldens move on `adapterSha256` alone: removing two inner casts from the browser and
history adapters re-digests the goldens mounted through them and nothing else. All 263 were
re-recorded from the pinned baseline and compare byte-identical to the previous recording
apart from those headers and the new file.

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

* refactor(mobile): fold five refusal-to-message spellings into one helper

The same three lines appeared five times in this PR: interpret a reply, and turn a refusal into a
plain Error carrying the host's message or the screen's copy. `interpretOrThrowRefusalMessage`
lives beside `refusedRpcMessageOrFallback` and takes the interpretation as a thunk, so the caller
still awaits the request outside the catch and a transport rejection reaches it as the object the
transport threw, delivery-unknown mark intact.

That removes the browser hook's own `assertBrowserCommandAccepted`, whose first parameter named an
operation the helper then used only to call `interpret`. Every browser page command is built by one
factory with one acceptance, so substituting one operation's `interpret` for another's was
unobservable and the parameter read as load-bearing when it was not. The call sites now name the
operation where they request and where they interpret, the shape the dictation sites already used.

Main's eight source-control spellings are deliberately untouched; folding those in is its own PR.

No wire change and no golden moves: `git diff --name-status origin/recorder-adapter-digest...HEAD
-- mobile/rpc-foundation` is byte-identical before and after at 55 added and 209 modified.

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

* test(mobile): record the history screen holding for a late worktree list

The hook holds loading when a scoped tab has no active worktree and the worktree list has not
arrived, rather than firing an unscoped scan that would briefly show unrelated host history. The
adapter hardcoded `worktreesLoaded` to true and its `worktrees-loaded` action was dead, so no
golden reached that branch and the hold was unrecorded.

The list and the flag now move together, which is how the screen learns them, and `mount` takes
`worktreesLoaded: false` to start unloaded. The new scenario records the hold, then the late load.

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

* test(mobile): re-record the goldens at the pinned baseline

Recorded from a detached worktree at c6a7216984
with this branch's rpc-recording/ and pilot-scenarios.json overlaid, per the
README's migration-branch procedure. Only header fields moved; no body line in
any golden changed.

recorderSha256 moved on all 208 pre-existing goldens: this branch adds
native-mounting-substitutes.ts at the top of rpc-recording/ and routes
operation-module-loader.ts through it, and both are recorder-engine inputs.
That resolves itself when #20667 lands the same substitution on main.

adapterSha256 moved on exactly four goldens, all from the late-worktree-list
scenario's edit to adapters/agent-history-mount-adapters.ts:
aivault-history-scan-fulfilled, aivault-history-scan-unsupported,
matrix-aivault.history-aivault.listsessions-1,
matrix-aivault.history-status.get-1.

aivault-history-scan-worktrees-late.json is new.

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

* refactor(mobile): ask these call sites for a request port, not a whole client

The migration widened seven files from `Pick<RpcClient, 'sendRequest'>` to the
full `RpcClient` for no reason: a bound operation's `request` takes
`UnvalidatedRpcRequestPort`, which is structurally that same single member.
Name the port instead, so the signature says what each function actually needs
and a caller holding only a port still satisfies it.

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

* test(mobile): make these adapters reject an action no scenario dispatches

Each of the five new mount adapters ended its `action` with an unnamed default,
so a misspelled or engine-introduced name silently ran the last branch: probing
`remount` on the history adapter ran `onRefresh()` and put `status.get` on the
wire. Twelve pre-existing adapters throw `Unknown X action` instead; these now
do too.

Deleted with it: every branch no scenario can reach. `unmount` is dispatched
only by the lifecycle derivation in derived-goldens.ts, which is restricted to
LIFECYCLE_BASES, and none of these scenarios is in it; teardown goes through
`dispose`, which already unmounts. Same for the history adapter's `retry`,
`select-scope` and refresh, the dictation start flow's `disable`, and the
viewport adapter's `reconnect`. None of them handled `remount`, which the
schedule driver always pushes after `unmount`, so the pairing was never whole.

The three single-action entries keep `_name`, matching six pre-existing
entries that do the same.

Goldens move on adapterSha256 only and are re-recorded separately.

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

* refactor(mobile): drop four fallback strings no catch can read

Both pointer paths wrap their whole sequence in `catch {}` with an empty body,
so the message the refusal helper builds is discarded. `interpret` already
throws on a refusal under `require-result-or-throw-message`, and the helper only
rewrote the text, so removing it keeps the same control flow: the sequence still
stops at the refused leg, the later commands still go unsent, and `setError`
still does not run. The mousemove matrix golden records `error: null` under
every refusal and transport shape either way.

The helper stays where the throw reaches a caller: dictation and agent history.

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

* refactor(mobile): read the viewport outcome where the reader is

`readTerminalUpdateViewportOutcome` had one caller, the reader that wraps it, so
the name bought a second file to open and nothing else. Inline the two
comparisons and keep the outcome type where it was.

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

* test(mobile): re-record this branch's goldens after the adapter cleanup

Recorded from a detached worktree at c6a7216984,
this branch's rpc-recording/ and pilot-scenarios.json overlaid, per the README's
migration-branch procedure. 266 recordings, all reproduced.

adapterSha256 moved on all 56 goldens this branch owns, because the named throws
and the deleted unreachable branches changed all five adapter modules. No other
header field moved, and no body line in any golden changed: the wire, the
effects and the state snapshots are identical, which is the claim the five
review deletions rest on.

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

* refactor(mobile): reach the narrow port type without naming the port module

The previous commit named `UnvalidatedRpcRequestPort` by importing the port
module, and that fails the boundary ratchet: it counts an import of
`unvalidated-rpc-request-port` as reach, so all six unlisted files became
offenders and the history panel went from 1 reference to 2.

Main's `Pick<RpcClient, 'sendRequest'>` fails it for the same reason, by a
different rule: a bare `'sendRequest'` string literal is counted too. That is
why the migration widened these signatures in the first place, so the review
finding's premise that it was done for no cause is wrong. Only the full
`RpcClient` scored zero.

Re-export the port type from `rpc-client` instead. An export declaration with no
module specifier is not counted, the seven signatures still say they need one
sender rather than a whole client, and the inventory does not move. Holding a
client already carries the same reach, so nothing new is opened.

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

* Revert "refactor(mobile): reach the narrow port type without naming the port module"

This reverts 92e679f587 and 16c9c232fc as one commit, returning the seven
client signatures to the full RpcClient the migration gave them.

unvalidated-rpc-request-port-boundary.test.ts:192 pins an import of the port
type as one reach by design: naming the type is exactly what the ratchet
retires, so re-exporting it from rpc-client opened an uncounted path for every
future file. Widening to the client is the design's intended end state.

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

* test(mobile): restore this branch's native substitutes and re-record at the pin

Taking #20667's landed `native-mounting-substitutes.ts` verbatim dropped the substitutes this
branch's domains need, and five scenarios stopped recording: `speech-audio-chunk-acknowledged`,
`speech-dictation-session-transcript`, `speech-dictation-session-cancelled`,
`terminal-viewport-refit-applied` and `terminal-viewport-refit-legacy-desktop` all failed with
`Missing or completed request`, because the hook throws on the native member before it sends.

Both branches created that file independently; neither is a version of the other. Main's structure
is kept whole — `partialNativeModule`, the `__esModule` rule, the async-storage trap — and this
branch's boundaries are added inside it: `buffer`, `AppState` and `useWindowDimensions` on
`react-native`, `@orca/expo-two-way-audio`, `expo-keep-awake`, and `expo-secure-store` as a second
unusable store. Each is inert; no listener is ever fired and no audio is produced.

That is an engine edit, so every golden re-digests. Recorded at main's pin
c6a7216984 from a detached worktree with this recorder overlaid, and
the product tree there was byte-identical to the pin. All 397 goldens moved on `recorderSha256`
alone and nothing else:

    git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
      | grep -vE '^(\+\+\+|---)' \
      | grep -vE '^[+-]  "(recorderSha256|adapterSha256|goldenFormatVersion|baseline|scenarioSha256|lockfileSha256)":' | wc -l
    0

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

* test(mobile): re-record at the pin after the #20685 merge

The merged recorder is a third engine: main's post-tasks-2 files plus this
branch's native-mounting-substitutes.ts, so recorderSha256 moves once and every
golden re-digests. Recorded from the pinned baseline c6a7216984 in a detached
worktree with this tree's recorder laid over it, so the product source is still
main's pre-migration tree. All 509 goldens move on recorderSha256 alone; no
recorded wire byte changes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-15 15:20:26 -04:00
Jinwoo Hong 341b13cf67 Restore mobile push and fix cold-start dismissals (#20068)
* Restore mobile push for delivery validation

* fix(mobile): register push task before headless startup

* Add authenticated mobile push test and fix iOS release entitlements

* Mock push-test transport in notification consent tests

* Fix slept workspace test for structured remount result

* Fix mobile notification review findings

* Pad Android notification icon to prevent square cropping

* fix(mobile): present visible Android data pushes in foreground

* test: use deterministic clock for teardown deadline

* fix(mobile): present foreground pushes through Expo public APIs

* fix(mobile): check push eligibility before foreground scheduling

* fix(mobile): register push from shared host connection lifecycle
2026-09-12 01:03:57 -04:00
Jinwoo Hong e187c82678 Revert mobile push rollout pending delivery investigation (#20040) 2026-09-11 02:17:58 -04:00
Jinwoo Hong d33354cfd2 feat(mobile): receive native push notifications from paired desktops (#19951)
* feat(mobile): deliver native push notifications from paired desktops

* fix(mobile): retry push capability probes

* fix(mobile): cancel retired push capability probes

* fix(mobile): ignore stale push reconciliations

* fix(mobile): type capability probe at its boundary

* fix(notifications): route mobile push taps to the originating pane

* Require explicit mobile push-service consent on upgrade
2026-09-11 01:00:16 -04:00
Jinwoo Hong 4e1681338c refactor(mobile): extract settings, diagnostics and editor-document screens from their routes (#19675) 2026-09-10 16:10:36 -07:00