Skip to content

refactor(remote): put the phone on the desktop's rendering seams - #1186

Merged
Juliusolsson05 merged 28 commits into
mainfrom
refactor/phone-shared-rendering
Sep 25, 2026
Merged

Juliusolsson05 merged 28 commits into
mainfrom
refactor/phone-shared-rendering

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Problem

The phone companion mounted the desktop's Feed, but it got there through copies of desktop code instead of sharing it, and the copies had drifted. Fixes landed in one copy at a time, which is why the phone kept looking broken. The drift showed up as real rendering bugs on the phone:

  • History out of order. Initial history was prepended blindly instead of placed by the OpenCode Terminal: deferred medium/low findings from the round-2 verification #910 rule. A live burst that landed before the backfill put newer turns above older ones, permanently.
  • Collapsed-running rule never fired. The ledger got lastJsonlEntryAt: 0.
  • No ordering barriers in main. Main's remote tap had no semantic coalescer and none of its barriers, so a cumulative preview could land after the committed row that replaced it.
  • Missing condition state. The phone drew the raw condition snapshot, so during a Claude compaction with no screen snapshot it showed nothing where the desktop showed the compaction state.
  • Older history unreachable. A view whose rows arrived live first had no pagination cursor.
  • Two channels missing. transcript-diagnostic and provider-session-changed never reached the phone. Both are relayed now. The store consumes the diagnostic; the phone follows a provider-session switch through transcript-roll detection and the history-boundary reset, as before.
  • Duplicate sub-agent watcher. A second watcher polled the same directories as the first.

Beyond the phone, the rendering code had modularity debt:

  • Per-provider policy was hard-coded in the shared decide layer.
  • Provider code imported desktop workspace internals.
  • 'claude' was a silent fallback for unknown providers.
  • Grok and Pi were invisible to the render-shape evidence loop.

This is also groundwork for the planned screenshot viewer, which needs exactly these seams.

What changed

Each concern now has one implementation, with the transport or host injected.

  1. One main-side session feed tap (src/main/sessions/sessionFeedTap.ts). It owns every ordering, coalescing and sub-agent decision once, then fans out to sinks:
    • The desktop forwarder is the window sink.
    • SessionFeedSource is the remote sink.
    • Raw PTY channels are opt-in per sink.
  2. History on the SessionFeed contract. loadHistory covers both the initial page and older pages, on both transports.
  3. One ingest core (src/renderer/src/session-runtime/ingest/). It holds the committed-record admission rules, the OpenCode Terminal: deferred medium/low findings from the round-2 verification #910 placement, the history tool reindex and the live semantic step. The desktop's live burst and history actions, the phone store and the replay harness all call it.
  4. A typed RendererHost in place of the phone's six Vite aliases and stubs. It covers the Monaco loader, link and file opening, the debug switch and the recording bridge. Toasts get one shared context.
  5. One useAgentFeedModel + AgentFeed, mounted by both TileLeaf and the phone, together with the same ProviderConditionOutlet.
  6. Provider-declared ledgerPolicy reached through the registry. The pure model receives LedgerInput.policy and never names a provider.
  7. Provider code never imports @renderer/workspace/**.
    • The dead per-provider TileLeaf registry is deleted.
    • A reverse rule is added to the existing import-boundary test.
  8. Docs. ARCHITECTURE.md, the conditions and rendering docs, spec status notes, and the plan with implementation notes.

Design decisions and tradeoffs

  • Remote isolation doctrine, partly reversed. The capability wall stays: the phone's command surface still cannot spawn, kill, write raw input or switch provider, and raw PTY never reaches the remote sink. What's dropped is the rule that remote re-implements code instead of sharing it, because the duplication is where the bugs were.
  • An ingest core, not one store. The shared core is a set of per-record steps. The desktop's queue, optimistic, worktree and ghost planes stay layered on top in its hub, so the phone never carries state it can't fill.
  • One deliberate desktop behavior change. History batches now rebuild the tool-pairing index in window order after merging. The result is identical when tool ids are unique; when they repeat, the newest block wins.
  • No features/feed/public barrel, a change from the plan. Provider rows importing MarkerRow, the feed contexts and nested rows is legitimate composition. A barrel would only change module evaluation order inside the existing registry ↔ rows cycle. The real violations were the workspace imports, and those are gone.
  • The inert default host is intentional. No bundle that mounts a row pulls in desktop code unless the app root mounts that desktop code on purpose.
  • Merge with main. feat(composer): lock the composer and show the prompt as Sending in the feed #1183's deliver-prompt flush moved from the deleted module-global coalescer onto SessionFeedTap.flushCommitted, and it now has a test.

Review round

One Claude and one Codex reviewer reviewed the branch read-only. I verified every finding against the code before acting on it, and all of them held.

  • Risk: a failing tap sink exited the app. The tap re-raised a sink's error on a microtask, and main's crash hooks treat that as fatal. It now re-raises synchronously after delivering to every sink, so the error surfaces where a throwing listener's always did. (Claude)
  • Bug: the phone never consumed transcript-diagnostic. A recovered Pi bridge or late OpenCode Terminal server kept its failure on the phone forever. The desktop's recovery rule is now shared in session-runtime/liveChannelRecovery.ts, and both clients apply it. (Codex)
  • Risk: the ledger cache ignored the new policy input. It now compares it. (Codex)
  • Weak test: the parity test only compared committed rows through shared helpers. It now also replays the recordings (semantic and committed events interleaved) through both stores and compares the ledger rows they produce. It fails if the phone's semantic subscription is dropped. (both)
  • Nit: an exited session could seed a phone with a stale sub-agent fleet. The tap now drops the seed on exit. (Claude)
  • Nit: the tsconfigs still listed the deleted registry.renderer.ts. Removed. (Codex)
  • Nit: docs claimed the phone consumes provider-session-changed. The claim is corrected: the channel is relayed but not consumed. (both)

Main was merged in three times, bringing in #1176, #1105, #1179, #1183, #1184 and #1185. #1183's deliver-prompt flush now goes through the tap, with a new test.

Issues

Fixes #1177

Verification

  • npm run check passes on Node 24 after merging origin/main: test contract, fixture checks, keybindings, tsc -b, the live-resume probe, the full vitest suite (830 files, 6524 tests) and the package build.
  • After the review fixes and a second merge of main (feat(goal): let agents complete their goal and bulk-close completed agents #1184), tsc -b is clean and the full vitest suite passes 6558 of 6559. The one failure is the extension Electron system test, which is intermittent and outside this branch's code; details under Known limitations.
  • npm run client:build passes. The phone bundle has no Monaco, app store, Global Editor opener, browser-pocket code or desktop toast provider; I checked the module graph of the real build.
  • New tests, each confirmed to fail against the old behavior:
  • Not verified live. Per standing instruction the app was not launched, so this is verified from source and tests only. A live phone check (Claude, Codex, OpenCode sessions over LAN) is the remaining manual step.

Known limitations and follow-ups

  • Intermittent extension system-test failure, filed as bug(extensions): runtime-api request intermittently rejected for an unrecognized extensionId key #1187. In one full run, electron.system.test.ts failed on a strict-schema ZodError ("Unrecognized key: extensionId") in the extensions:runtime-api handler. The same file passed on its own right afterwards, and in the earlier full run. This branch doesn't touch that code path.

  • Phone chrome and CSS are unchanged. This fixes what the phone renders; the layout pass is next, ideally with the planned screenshot viewer.

  • SessionPreviewPane still reads history via window.api. It previews a picked conversation with no pane session behind it.

  • Pre-existing, intermittent local timeouts, not from this branch. store.test.ts and ProviderEnablementRow time out on some local runs, and they do the same on an untouched main checkout. They passed in the final full run.

🤖 Generated with Claude Code

Juliusolsson05 and others added 21 commits September 24, 2026 17:46
Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop forwarder and the remote SessionFeedSource each subscribed to
SessionManager and re-implemented the same ordering and batching. The
copies drifted: the phone had no semantic coalescer and so none of its
ordering barriers, never received transcript-diagnostic or
provider-session-changed, had lost the JSONL interning and Codex
observation sidecar, and ran a second sub-agent watcher over the same
directories.

SessionFeedTap now owns all of it once, transport-neutral, emitting
(channel, payload) to sinks. The forwarder is the window sink (IPC names,
routing, the #746 screen alias, broadcast-only events); SessionFeedSource
is the remote sink over the SAME tap instance (session list, the #866
terminal gate at one choke point, `removed` forwarding). Raw PTY channels
are opt-in per sink and the remote sink never opts in. The module-global
jsonlCoalescer becomes per-tap state. The phone wire gains the two missing
channels, and RemoteServer primes sub-agent fleets from the tap because
the shared watcher only emits on change.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed contract

History was the one session read outside the contract: the desktop called
two preload methods from its history actions, and the phone had an ad-hoc
getHistory on its transport. A shared ingest core cannot page history
without knowing the transport while that holds.

SessionFeed gains loadHistory(SessionHistoryRequest) with one request for
the initial page and older pages. Its fields are the union of what the two
hosts need: the phone's server resolves by sessionId, the desktop's main
reads the durable transcript identity the renderer holds. Failure is a
rejection on both transports, which kept the desktop's error paths and
IpcSessionFeed's zero-logic delegation unchanged; the phone's transport
turns its { ok:false } reply into that rejection at its own edge.

The initial-history loader reads through the feed (its readHistory
injection point becomes a feed override, defaulting to the one desktop
feed), the older-history action takes the feed from the workspace hook,
and the phone store and its tests move off getHistory.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery ingest site

Four loops turned mapped transcript records into feed entries: the desktop's
live burst, its initial loader, its older pager, and the phone store (plus the
replay harness, a fifth). Each re-typed the same rules and the copies had
drifted. The rules now live once in session-runtime/ingest/: mode-aware
admission (the #375 live/tail/older dedupe asymmetry), the pagination-anchor
rule, the producer-time lastJsonlEntryAt cursor, the #910 history placement
(moved verbatim from initialHistory.ts), and the fold-then-phase semantic step.
This commit moves the desktop and the replay harness onto it.

One deliberate behaviour change: history batches (initial chunk and older
pages) now rebuild the tool-pairing indexes in window order after merging,
instead of indexing the batch after the live rows. Identical when tool ids
are unique; when an id repeats, the newest block now wins where an older one
could overwrite it. A batch with no tool block leaves maps and version alone.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone mirrored the desktop's ingest by hand and rendered worse where the
mirror had drifted. It now calls the shared ingest core, which fixes:

- initial history was prepended blindly, so a live burst that landed before
  the backfill (or a durable read trailing the live stream) put newer turns
  above older ones permanently; it now uses the #910 placement rule and its
  cursor test
- the ownership ledger got a constant lastJsonlEntryAt of 0, so the
  collapsed-running rule could never fire on the phone
- a view whose first rows arrived live had no pagination cursor, so a
  backfill placed after them left older history unreachable

A parity test replays real Claude and Codex transcripts through the phone
store and the desktop's replay fold and requires identical rows and cursor.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation

Rows the phone also mounts raise toasts. The desktop provider reads the app
store and extension IPC, so the phone aliased the whole GlobalToast module
away and re-declared a look-alike context in its own file. Now there is one
context (ui/GlobalToastContext.ts) that both the desktop GlobalToastProvider
and the phone's ToastHostProvider provide, and every row imports the hook
from there. GlobalToast.tsx keeps re-exporting the hook for desktop chrome.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The feed's rows called the desktop directly: window.api for links and
render-shape evidence, the Global Editor opener, the app store's debug
switch, and a Monaco/LSP path. The phone mounts the same rows, so it swapped
those modules out with Vite aliases whose stubs mirrored exports by hand.

A RendererHost context now carries the few capabilities a row needs from
the app it is mounted in: a Monaco runtime loader (null = static engine,
the same hljs layer Monaco paints first), external-URL and workspace-file
opening, the debug overlay switch, and the session-recording bridge that
arms render-shape capture. The observer takes its sightings sink at arm
time instead of reading window.api when it sends. The default host is
inert on purpose so no bundle that mounts a row drags in desktop code; the
desktop mounts DesktopRendererHostProvider at its root.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…host

The phone substituted six desktop modules through Vite aliases: CodeBlock,
the app store (a stub the type checker could not see), the perf client
(already unreachable), SafeMarkdownLink, SafeInlineCode and GlobalToast.
Every desktop change to one of them silently risked the phone.

The phone now bundles the very same row modules and mounts a RendererHost
at its root: no Monaco (the static engine the desktop paints first), links
open in a new tab with noopener, no editor so file links render as text, no
debug overlay, no recorder. The stubs and their aliases are deleted; the
bundle builds without them and contains no Monaco, app store, editor opener
or browser-pocket code.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nFeed

clearAgentComposer defaulted to window.api.sendInput, the one composer write
that bypassed the SessionFeed every other session write goes through. It now
takes the feed: the composer passes its context feed, and the palette
command (outside React) passes the desktop's one ipcSessionFeed.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TileLeaf turned a SessionRuntime into ~25 Feed props, merged entries and a
normalized condition snapshot; the phone re-did it by hand as a documented
mirror that had drifted. useAgentFeedModel now owns that mapping over a
runtime SLICE (AgentFeedRuntime), and AgentFeed is the one place a runtime
becomes Feed props; surfaces pass only their own chrome (tail mode, pickers,
scroll telemetry, usage-limit actions, debug logging). selectMergedEntries
takes the slice it actually reads.

The outlet dispatcher (makeDispatchFromOnSend -> makeOutletDispatch) now
hands a pty choice to the surface as the whole action instead of its bytes.
The desktop still writes action.data; the phone needs the id so the desktop
can verify the choice against the live menu, which is why it could not use
ProviderConditionOutlet before.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… mapping

The phone mounted Feed and the core ConditionOutlet with a hand-kept copy of
TileLeaf's mapping. It now mounts useAgentFeedModel, AgentFeed and the
desktop's ProviderConditionOutlet, which fixes what the copy had lost:

- the provider-normalized condition snapshot: during a Claude compaction with
  no screen snapshot the desktop showed the compaction state from
  structured evidence and the phone showed nothing
- the merged-entries fallback and the desktop's askUserQuestion sentinel
  (undefined = no snapshot yet, null = snapshot without the question)
- render-shape observation of conditions, now the same code path

The phone's dispatch becomes handlers for the shared dispatcher: a pty choice
still goes out as the whole action for the desktop to verify, and every
refusal still lands on the screen's error line.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…egistry

The shared decide layer held provider asymmetries as literals keyed by
provider name: a SUPPRESSION_POLICY Record in ownership.ts, Claude's churn
tool names beside it, `provider !== 'claude'` for scaffolding user rows in
the committed collector and `provider === 'opencode'` for the ghost gate.
Adding a provider meant editing shared code, contrary to plan D10.

Each provider now declares a LedgerProviderPolicy in its own
renderer/ledgerPolicy.ts (with the WHYs that used to sit in shared code),
exposed as the required `ledgerPolicy` capability. Collectors read it from
the registry they already use; the adapter resolves the suppression policy
into a required LedgerInput.policy, so the pure model knows only what a
policy bit means and never names a provider. Values are unchanged.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three places silently coerced a missing or unknown provider to 'claude':
Feed's provider prop default, the provider context default and the replay
harness. Feed's prop is now required (AgentFeed, its one mount, always
knows it), a recording naming an unknown provider is refused instead of
replayed through Claude's mappers, and the remaining defaults (context
outside a Feed, the phone's one-frame window before its session list) use
the named DEFAULT_PROVIDER with the reason beside it.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… loop

Grok and Pi had no shape catalogs and were missing from the registry's
plain catalog array, so their painted shapes could never be resolved and
the Unknown Shape Inbox could not attribute them. Both now register an
honest empty catalog (no entry invented from an unobserved tool list), and
the registry keys catalogs by provider kind as a full Record, so a new
provider cannot compile without naming one.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…derer registry

registry.renderer.ts gave every provider a TileLeaf slot, and all five held
the same component. The only thing TileTree used it for was that one pane
plus rejecting unknown kinds, and it made the provider registry import the
workspace pane that mounts provider rows. TileTree now validates the kind
and mounts TileLeaf directly; the registry file and the RendererProviderConfig
and TileLeafProps types it alone used are deleted. Tests that stubbed the
leaf through the registry now mock the TileLeaf module they meant.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two provider modules reached into the desktop pane: the Claude slash picker
took its state type from workspaceStore, and Codex's composer submit borrowed
the single-write fast path of Claude's paste helper from the TileLeaf
directory. The picker type now comes from the SessionFeed contract (where an
identical declaration already lived; session-runtime re-exports it instead
of declaring it a second time), and Codex writes its own one-line
bracketed-paste protocol with the same paste-debug event.

The existing import-boundary test gains the reverse of its feed rule:
src/providers/** never imports @renderer/workspace/**, since provider rows
are mounted by both the desktop pane and the phone.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ARCHITECTURE.md now describes the session feed tap and its sinks (with the
two channels its table lacked), the shared ingest core, the agent feed
model, the RendererHost in place of build aliases, and provider-declared
ledger policy; the "phone passes lastJsonlEntryAt 0" limit is gone because
it is fixed. The living conditions and rendering docs follow the renamed
outlet dispatcher and policy home. The shipped rewrite plan gets an
accurate status header, the two dated remote specs point at what
superseded their isolation and alias sections, and the plan records where
the build differed from it, including the deliberate choice not to add a
feed barrel module.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every features/ directory must name the reference page that documents it.
rendererHost is shared infrastructure behind the conversation feed, like
sessionFeed, so it belongs to the conversation page.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolutions and adaptations main's new code needed on this branch:

- workspace/hook/index.ts: keep main's Agent Activity close handler and the
  branch's older-history read through the SessionFeed.
- #1183 flushed the deleted module-global JSONL coalescer before answering a
  prompt delivery. The same barrier now goes through the shared session feed
  tap (SessionFeedTap.flushCommitted), which registerSessionIpc receives
  explicitly; its test call sites pass a stub.
- #1183's pendingEntryUuid Feed prop is desktop chrome on AgentFeed.
- A new ownership test takes Claude's suppression policy from the registry.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1181's ordering (the prompt's committed row reaches the renderer before
the delivery reply, or the pending Sending row blinks out) had no test,
and this branch moved its barrier from a module-global coalescer onto the
shared tap. The test drives the real tap and fails without the flush.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 and others added 6 commits September 24, 2026 19:49
The tap re-raised a sink's throw on a microtask so the other sinks still got
the event. Main's crash hooks treat every uncaughtException as fatal, so any
sink bug on a direct event (started, conditions, exit) now ended the process
where it used to reach SessionManager.emit's caller. The tap still delivers
to every sink first, then re-raises synchronously, which lands the failure
exactly where a throwing listener's always did.

The tap also drops a session's sub-agent fleet seed on exit, as the remote
server's own cache does, so a phone connecting after an agent exited is not
primed with its last fleet.

Found in the PR #1186 review. Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LedgerInput.policy became an input in #1177 but the session ledger's cache
did not compare it, so a caller swapping the policy under the same provider
got the previous decisions back. Latent in production, where each provider's
policy object is stable.

Found in the PR #1186 review. Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone now received transcript-diagnostic but never consumed it, so a Pi
bridge or OpenCode Terminal server that came up late kept its failure and
reload advice on the phone forever, while the desktop cleared it. The
desktop's rule (a connected live-state clears exactly the fault its channel
raised) moves into session-runtime/liveChannelRecovery.ts and both clients
apply it. The provider-session-changed JSDoc now says the phone relays but
does not consume it, and how it follows a switch instead.

Found in the PR #1186 review. Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parity test compared only committed rows through the shared helpers, so
a missing phone subscription or a semantic step applied differently would
pass. It now also plays the checked-in recordings (semantic and committed
events interleaved) into the replay fold and the phone store and compares
the ledger rows both produce; dropping the phone's semantic subscription
fails it. The header states what it cannot catch: a wrong rule inside the
shared core agrees with itself and is pinned by the core's own tests.

Found in the PR #1186 review. Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both projects still named src/providers/registry.renderer.ts, deleted
earlier in this PR, with comments describing its TileTree role. The plan
records the review round.

Refs #1177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-rendering

# Conflicts:
#	src/renderer/src/features/command-palette/ui/CommandPalette.tsx
@Juliusolsson05
Juliusolsson05 merged commit c333d0e into main Sep 25, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the refactor/phone-shared-rendering branch September 25, 2026 03:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(remote): put the phone on the desktop's rendering seams

1 participant