refactor(remote): put the phone on the desktop's rendering seams - #1186
Merged
Merged
Conversation
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
lastJsonlEntryAt: 0.transcript-diagnosticandprovider-session-changednever 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.Beyond the phone, the rendering code had modularity debt:
'claude'was a silent fallback for unknown providers.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.
src/main/sessions/sessionFeedTap.ts). It owns every ordering, coalescing and sub-agent decision once, then fans out to sinks:SessionFeedSourceis the remote sink.loadHistorycovers both the initial page and older pages, on both transports.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.RendererHostin 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.useAgentFeedModel+AgentFeed, mounted by both TileLeaf and the phone, together with the sameProviderConditionOutlet.ledgerPolicyreached through the registry. The pure model receivesLedgerInput.policyand never names a provider.@renderer/workspace/**.TileLeafregistry is deleted.Design decisions and tradeoffs
features/feed/publicbarrel, a change from the plan. Provider rows importingMarkerRow, 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.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.
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 insession-runtime/liveChannelRecovery.ts, and both clients apply it. (Codex)policyinput. It now compares it. (Codex)exit. (Claude)registry.renderer.ts. Removed. (Codex)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 checkpasses on Node 24 after mergingorigin/main: test contract, fixture checks, keybindings,tsc -b, the live-resume probe, the full vitest suite (830 files, 6524 tests) and the package build.main(feat(goal): let agents complete their goal and bulk-close completed agents #1184),tsc -bis 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:buildpasses. 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.loadHistoryparity across both transports;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.tsfailed on a strict-schemaZodError("Unrecognized key: extensionId") in theextensions:runtime-apihandler. 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.
SessionPreviewPanestill reads history viawindow.api. It previews a picked conversation with no pane session behind it.Pre-existing, intermittent local timeouts, not from this branch.
store.test.tsandProviderEnablementRowtime out on some local runs, and they do the same on an untouchedmaincheckout. They passed in the final full run.🤖 Generated with Claude Code