Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
98bb5d9
docs(remote): plan the phone's move onto shared rendering seams
Juliusolsson05 Sep 25, 2026
fdb6215
refactor(sessions): feed desktop and phone from one session feed tap
Juliusolsson05 Sep 25, 2026
a7451fa
refactor(session-feed): load transcript history through the SessionFe…
Juliusolsson05 Sep 25, 2026
10eddea
refactor(session-runtime): share committed-record admission across ev…
Juliusolsson05 Sep 25, 2026
9abd025
fix(remote): run the phone store on the desktop's ingest rules
Juliusolsson05 Sep 25, 2026
a8d1d20
refactor(renderer): split the toast contract from its desktop present…
Juliusolsson05 Sep 25, 2026
93feba3
refactor(renderer): inject host capabilities into rendered rows
Juliusolsson05 Sep 25, 2026
10aee63
refactor(remote): mount the real feed rows on the phone with a typed …
Juliusolsson05 Sep 25, 2026
e811b3c
refactor(composer): send clear-composer keystrokes through the Sessio…
Juliusolsson05 Sep 25, 2026
7edda6b
refactor(feed): map a runtime onto the agent feed in one shared place
Juliusolsson05 Sep 25, 2026
b433cbe
fix(remote): paint the phone's feed and prompts through the desktop's…
Juliusolsson05 Sep 25, 2026
6db222b
refactor(rendering): declare ledger policy per provider through the r…
Juliusolsson05 Sep 25, 2026
d28d52f
refactor(feed): stop painting unknown providers as Claude
Juliusolsson05 Sep 25, 2026
8fbe93d
fix(rendering): make Grok and Pi visible to the render-shape evidence…
Juliusolsson05 Sep 25, 2026
e3f5159
refactor(providers): drop the per-provider TileLeaf slot from the ren…
Juliusolsson05 Sep 25, 2026
4fdf0d9
refactor(providers): keep provider code out of the desktop workspace
Juliusolsson05 Sep 25, 2026
6300649
docs(remote): describe the phone on the shared rendering seams
Juliusolsson05 Sep 25, 2026
1e2d9f1
refactor(renderer): register the renderer host in the feature reference
Juliusolsson05 Sep 25, 2026
1f2a65a
Merge origin/main into refactor/phone-shared-rendering
Juliusolsson05 Sep 25, 2026
a65a0dc
test(sessions): pin the deliver-prompt row barrier through the feed tap
Juliusolsson05 Sep 25, 2026
6032b47
docs(remote): correct the plan's verification caveat
Juliusolsson05 Sep 25, 2026
87a58c2
fix(sessions): keep a failing feed sink from exiting the app
Juliusolsson05 Sep 25, 2026
c82cef1
fix(rendering): recompute the ledger when its policy input changes
Juliusolsson05 Sep 25, 2026
68d34bf
fix(remote): clear a recovered live channel's fault on the phone
Juliusolsson05 Sep 25, 2026
fcaf879
test(remote): compare phone and desktop ledger rows from recordings
Juliusolsson05 Sep 25, 2026
c30ba0f
chore(build): drop the deleted renderer registry from the tsconfigs
Juliusolsson05 Sep 25, 2026
234ee44
Merge remote-tracking branch 'origin/main' into refactor/phone-shared…
Juliusolsson05 Sep 25, 2026
71745af
Merge remote-tracking branch 'origin/main' into refactor/phone-shared…
Juliusolsson05 Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

26 changes: 18 additions & 8 deletions docs/design/conditions-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ primitives that file now re-exports).
│ the FORWARD-LOOKING headless ConditionModule contract │
│ view.ts ConditionView / ConditionViewProps / AttentionLevel │
│ ConditionOutlet.tsx the ONE generic outlet (routes snapshot by kind) │
│ dispatch.ts makeDispatch / makeDispatchFromOnSend (pty arm wired, │
│ dispatch.ts makeDispatch / makeOutletDispatch (pty arm wired, │
│ custom arm delegates to a resolver when provided) │
├─ Layer 2: provider modules ─────────────────────────────────────────────────┤
│ src/providers/claude/renderer/conditions/views.tsx → CLAUDE_VIEWS │
Expand Down Expand Up @@ -114,7 +114,12 @@ never picks a surface itself — it is shared by the app and the phone.

The two builders differ in ONE respect, and it is load-bearing:

- `makeDispatchFromOnSend` (views) **reports and returns**. Views call
- `makeOutletDispatch` (outlets) **reports and returns**. Its pty arm hands the
surface the whole action (`{ id, label, data }`), not just its bytes: the
desktop writes `data`, while the phone sends the action over the wire so the
desktop can verify it against the live menu. That is what lets the phone
mount the same `ProviderConditionOutlet` as the desktop (#1177); it used to
keep a second dispatcher and mount the core outlet directly. Views call
`void dispatch(action)`, so a rejection would be an unhandled promise
rejection on top of a failure the user can already see in a toast. A missing
resolver here is the ordinary refusal `no-resolver`, with its own message
Expand Down Expand Up @@ -211,12 +216,17 @@ headless emitter
→ IPC 'session:conditions'
→ useIpcSubscriptions.ts: applyConditionSnapshot(current, snapshot) (~:195, applied ~:1065)
→ runtime.conditions
→ TileLeaf.tsx (~:510) <ProviderConditionOutlet conditions={runtime.conditions} onSend={send}/>
→ useAgentFeedModel: provider normalizeConditions (shared with the phone)
→ TileLeaf.tsx <ProviderConditionOutlet conditions={feedModel.normalizedConditions}
onPtyAction={action => sendConditionKey(action.data)}/>
```

`send` (TileLeaf's prop) is already bound to the active session and ultimately
reaches `window.api.sendInput(sessionId, data)` → `session:input`. PR-1 reuses
that `onSend` verbatim via `makeDispatchFromOnSend`.
`sendConditionKey` is bound to the active session and writes through the
pane's SessionFeed (`sendInput(sessionId, data)` → `session:input`). The phone
mounts the same outlet with its own `onPtyAction`, which sends the whole
action to the desktop for verification. Since #1177 the event reaches both
clients through one main-side session feed tap (the desktop's window sink is
forwarder.ts).

## Why `custom` actions exist

Expand All @@ -235,8 +245,8 @@ AskUserQuestionRow
```

A surface that does not pass a resolver never silently drops the action. On the
control plane (`makeDispatch`) that is a throw; in a view
(`makeDispatchFromOnSend`) it is the reported refusal `no-resolver`, which the
control plane (`makeDispatch`) that is a throw; in an outlet
(`makeOutletDispatch`) it is the reported refusal `no-resolver`, which the
user sees as "This agent cannot receive that kind of answer. Answer it in the
terminal instead." Either way the click accounts for itself — a custom action
without its named resolver would otherwise look clickable while doing nothing.
Expand Down
344 changes: 344 additions & 0 deletions docs/plans/2026-09-24-phone-shared-rendering.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion docs/rendering/rendering-rewrite-plan-2026-07.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Rendering Rewrite — The Plan (2026-07-06)

Status: **the canonical plan.** Supersedes `rendering-rewrite-practical-plan.md`
Status: **SHIPPED** (PR #555, merged 2026-07-18). Kept as the design record,
not a live work list. The progress sections below are historical: the
`AGENT_CODE_RENDER_PIPELINE` / `AGENT_CODE_RENDER_SHADOW` flags, runtime shadow
mode and the soak gate were deleted at cutover (`legacy-deletion-manifest.md`),
the `view/` bridge lives at `src/renderer/src/features/feed/ledger/`, and the
per-provider policy table moved into each provider's `ledgerPolicy.ts` (#1177).
For the current system read ARCHITECTURE.md §8.3 and `rendering-system.md`.

Original status: **the canonical plan.** Supersedes `rendering-rewrite-practical-plan.md`
(2026-05-22), `docs/superpowers/plans/2026-05-22-rendering-tdd-rewrite.md` (the
8-phase original), and the planning threads in #172. The knowledge dump
(`rendering-knowledge-dump.md`) remains the evidence bible; the
Expand Down
10 changes: 5 additions & 5 deletions docs/rendering/rendering-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ A provider (Claude, Codex, opencode) is a program emitting a messy, out-of-order
- **DECIDE** (`src/renderer/src/rendering/…`) is the **ownership ledger**: a pure function from the **`RuntimeRenderInput`** slice of `SessionRuntime` (see below) to an ordered, fully-explained list of rows. It decides *what is visible*, *who owns it*, and *in what order* — and records *why* for every candidate, chosen or rejected.
- **RENDER** (`src/renderer/src/features/feed/…`) is the **feed painter**. It consumes the ledger's ordered `FeedRenderItem[]` and emits JSX. It makes **no** visibility/ownership/order decisions.

The seam between DECIDE and RENDER is the hook `useLedgerFeedItems` (`src/renderer/src/features/feed/ledger/useLedgerFeedItems.ts`). Both consumers of the pipeline — the **desktop** (`TileLeaf`) and the **remote phone client** (`src/remote-client/src/ui/SessionView.tsx`) — mount the *same* `<Feed>` and hand it the *same* ledger output via `renderItemsOverride`. There is one rendering pipeline; the phone is not a second implementation.
The seam between DECIDE and RENDER is the hook `useLedgerFeedItems` (`src/renderer/src/features/feed/ledger/useLedgerFeedItems.ts`), wrapped since #1177 by `useAgentFeedModel` and `AgentFeed` (`src/renderer/src/features/feed/agent/`), the one place a runtime slice becomes ledger rows, merged entries, normalized conditions and Feed props. Both consumers of the pipeline — the **desktop** (`TileLeaf`) and the **remote phone client** (`src/remote-client/src/ui/SessionView.tsx`) — mount the *same* `AgentFeed` and condition outlet. There is one rendering pipeline; the phone is not a second implementation, and what differs per app (Monaco, link/file opening, the debug switch, the recorder) arrives through the `RendererHost` context rather than build aliases.

### The folder layout is the layering (post-#493)

Expand Down Expand Up @@ -70,17 +70,17 @@ That last property is the one that makes this system diagnosable. "Why did this

`SessionFeed` (`src/shared/sessionFeed/SessionFeed.ts`) is the seam between "the UI wants live session I/O" and "where the bytes physically come from." It lives in `@shared` (not `@preload`) so non-Electron surfaces can implement it — the desktop uses `IpcSessionFeed` (over `window.api.*`), the phone uses `WebSocketSessionFeed` (over a WebSocket). Nine **listeners** (subscribed once, globally, dispatched by `sessionId` inside the callback to avoid N×N listener storms):

`onSessionStarted · onSessionScreen · onSessionJsonlEntries · onSessionJsonlError · onSessionSemanticEvent · onSessionConditions · onSessionProcessState · onSessionSubAgents · onSessionExit`
`onSessionStarted · onSessionScreen · onSessionJsonlEntries · onSessionJsonlError · onSessionSemanticEvent · onSessionConditions · onSessionProcessState · onSessionSubAgents · onSessionExit` (plus the input-readiness, transcript-diagnostic, history-boundary and provider-session-changed channels added since)

…and three **commands** — `sendInput`, `deliverPrompt`, `resolveCondition`. The command surface is deliberately narrow: lifecycle/spawn/kill/raw-terminal are *absent* so a remote transport cannot express them.
…three **commands** — `sendInput`, `deliverPrompt`, `resolveCondition` — and one **read**, `loadHistory` (#1177; the initial page and older pages through one request shape, where the desktop used two raw `window.api` calls and the phone an ad-hoc extra). The command surface is deliberately narrow: lifecycle/spawn/kill/raw-terminal are *absent* so a remote transport cannot express them. Both implementations are fed in main by one `SessionFeedTap` (`src/main/sessions/sessionFeedTap.ts`), so the order a listener observes is the same on either client.

All nine listeners are wired in one place — `workspace/hook/ipc/useIpcSubscriptions.ts`, the central ingest orchestrator. Each handler is a `setRuntimes(prev => …)` reducer.

### The reducers (fold)

Ingest is a set of **reference-stable reducers** — each returns `prev` unchanged on a no-op so React memoization holds, and each runtime *slice* only changes reference when its own reducer really changed it. This is the foundation of the identity-stability chain (see §6).

- **JSONL / committed plane** — the bulk-burst handler in `useIpcSubscriptions.ts`, with the shared entry utilities in `session-runtime/entries.ts` (`indexEntryIntoMaps`, `entryTextContent`). Raw provider lines route through the provider mapper (see below), dedupe by UUID, and fold into `runtime.entries`. Tool blocks are folded into in-place lookup maps `toolUseIndex`/`toolResultIndex` (keyed by tool_use_id); `indexEntryIntoMaps` returns *whether it changed* so callers bump the monotonic `toolIndexVersion` only when a cross-entry pairing actually moved (the naive `useMemo([entries])` rebuild was O(N²) at bootstrap). `lastJsonlEntryAt` tracks the newest observed entry *timestamp* (producer clock, `null` — never `0` — as the "never seen" sentinel).
- **JSONL / committed plane** — the bulk-burst handler in `useIpcSubscriptions.ts`, with the shared entry utilities in `session-runtime/entries.ts` (`indexEntryIntoMaps`, `entryTextContent`) and the admission rules every ingest site calls in `session-runtime/ingest/` (mode-aware dedupe, marker stamping, the #910 history placement, the history tool reindex — shared with the phone store and replay since #1177). Raw provider lines route through the provider mapper (see below), dedupe by UUID, and fold into `runtime.entries`. Tool blocks are folded into in-place lookup maps `toolUseIndex`/`toolResultIndex` (keyed by tool_use_id); `indexEntryIntoMaps` returns *whether it changed* so callers bump the monotonic `toolIndexVersion` only when a cross-entry pairing actually moved (the naive `useMemo([entries])` rebuild was O(N²) at bootstrap). `lastJsonlEntryAt` tracks the newest observed entry *timestamp* (producer clock, `null` — never `0` — as the "never seen" sentinel).
- **Semantic / live plane** — `session-runtime/semantic/foldEvent.ts`, `foldSemanticEvent(state, ev, sessionKind)`. The **one-session-one-reducer** contract: every semantic event flows through here before any UI reads it; surfaces select from `runtime.semantic`, they never open their own subscription. The model is **block-level**: a `SemanticLiveTurn` holds `blocks: Record<number, SemanticLiveBlock>` keyed by index plus a `blockOrder: number[]`, because streaming deltas arrive out of order. Blocks accumulate copy-on-write as `text_delta`/`thinking_delta`/`tool_input_delta`/… events arrive; `tool_result` attaches onto the originating block by correlation id. Turn *replacement* is policy-gated (`canReplaceMismatchedTurn`) — an ended turn is always replaceable, a live turn yields only to trusted sources.
- **Stream-phase plane** — `session-runtime/semantic/streamPhaseMachine.ts`, `reduceStreamPhase`, deliberately *outside* `foldSemanticEvent` because the phase lives on `SessionRuntime`, not `SemanticRuntimeState` (folding it in would be a layering violation). It drives the single in-feed `WorkIndicator`.

Expand Down Expand Up @@ -153,7 +153,7 @@ Everything else on `SessionRuntime` is *not* a decide input. The tool indices (`
4. **tool-use** — id owned by committed → `committed-tool-use-owned`; else the **collapsed-running** rule folds an unresolved churn tool (Read/Glob/Grep/Bash) with no committed trace into a running receipt — but only behind a **tail gate** (`committedTailMs > candidate.timestampMs`, fix #465): committed truth has provably moved *past* the tool's turn yet never recorded it. This honors "not caught up means REORDER, never suppress."
5. **tool-result** — yields only to a committed tool *result*, never the tool-use commit alone (hiding live output at tool-use time made output vanish before the durable copy existed).

Per-provider asymmetry is **policy, not forks** (plan D10) — a `SUPPRESSION_POLICY` table sets `wholeTurnByMessageId` (Claude true; Codex/opencode commit one item at a time) and `hideUnresolvedHistoryTools` (Claude only; Codex's function_call_output lands in a *later* turn, so the rule over-fired).
Per-provider asymmetry is **policy, not forks** (plan D10) — each provider declares a `LedgerProviderPolicy` in `providers/<kind>/renderer/ledgerPolicy.ts` (reached through the capability registry; #1177 moved it out of a `SUPPRESSION_POLICY` table in the shared model). It sets `wholeTurnByMessageId` (Claude true; Codex/opencode commit one item at a time), `hideUnresolvedHistoryTools` with its churn tool names (Claude only; Codex's function_call_output lands in a *later* turn, so the rule over-fired), whether the ghost plane may render (not OpenCode) and whether `<`-prefixed user rows are scaffolding (Claude). The adapter resolves the suppression half into `LedgerInput.policy`, so the pure model never names a provider.

### The ordering law (`model/order.ts`)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Remote Mobile Companion

> **Status (2026-09-24, #1177):** partly superseded. The isolation boundary's
> *capability* wall still holds (the phone's command surface cannot express
> spawn, kill, raw input or provider switching, and raw PTY never reaches a
> remote sink). Its *code* rule — remote re-implements what it needs instead of
> sharing it — is reversed: both clients are fed by one main-side
> `SessionFeedTap`, run one ingest core and mount one agent feed, because the
> duplicated copies drifted and the phone rendered worse. See
> `docs/plans/2026-09-24-phone-shared-rendering.md`.

**Date:** 2026-07-06
**Status:** Approved design, pending implementation plan

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Remote Semantic Rendering — the desktop feed on the phone

> **Status (2026-09-24, #1177):** the build-level alias/stub table described
> here is gone. The phone mounts the real row modules, and the capabilities that
> differ per app arrive through the typed `RendererHost` context
> (`src/renderer/src/features/rendererHost/`); the phone's feed mapping is the
> desktop's own `useAgentFeedModel` / `AgentFeed`. See
> `docs/plans/2026-09-24-phone-shared-rendering.md`.

**Date:** 2026-07-06
**Status:** Approved direction (user delegated approach); implementation in progress
**Parent:** 2026-07-06-remote-mobile-companion-design.md · issue #420
Expand Down
20 changes: 19 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
import { abandonPendingBequest, recordPendingBequest } from '@main/ipc/window.js'
import { wireSessionForwarder } from '@main/sessions/forwarder.js'
import type { SessionForwarderControl } from '@main/sessions/forwarder.js'
import { SessionFeedTap } from '@main/sessions/sessionFeedTap.js'
import { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js'
import { setOutboundObserver } from '@main/window/windowRegistry.js'
import { captureWindowGeometry, restorableBounds } from '@main/window/windowGeometry.js'
Expand Down Expand Up @@ -335,6 +336,9 @@ let unregisterExtensionInput: (() => void) | null = null
let extensionQuitReady = false
let extensionQuitPending: Promise<void> | null = null
let sessionForwarder: SessionForwarderControl | null = null
// The one main-side session feed tap (#1177): ordering, coalescing and the
// sub-agent watcher, shared by the desktop forwarder and the remote sink.
let sessionFeedTap: SessionFeedTap | null = null

// A packaged release needs one executable-level smoke test that stops before
// touching the user's real workspace, process lock, provider CLIs, or network.
Expand Down Expand Up @@ -1129,6 +1133,13 @@ async function startApp(): Promise<void> {
// first thing real-world testing tripped on).
remoteController = new RemoteController({
manager,
// Resolved at enable time; the tap is built later in startup, at the
// forwarder's wiring (see there). Enabling needs a user action on a
// window, which cannot happen before that point.
getFeedTap: () => {
if (!sessionFeedTap) throw new Error('session feed tap is not wired yet')
return sessionFeedTap
},
journal: appRunJournal,
// v2 identity projection: one read model over the persisted workspace
// (titles, spoken names, tabs, pins) for the remote server's session
Expand Down Expand Up @@ -1343,7 +1354,13 @@ async function startApp(): Promise<void> {
})
performanceService.mark('app.main.sessionManager.created')

sessionForwarder = wireSessionForwarder(manager, lspManager)
// Built HERE, at the forwarder's old spot, not beside `new SessionManager`:
// the tap's manager listeners take the forwarder's former position in each
// event's listener list, so every other main subscriber still runs before
// or after it exactly as it did.
const feedTap = new SessionFeedTap(manager)
sessionFeedTap = feedTap
sessionForwarder = wireSessionForwarder(manager, lspManager, feedTap)
registerSessionRoutingIpc(manager, sessionForwarder)
// CLI auto-updater — constructed AFTER SessionManager because it uses
// the manager to decide whether an active session of the target kind
Expand Down Expand Up @@ -1520,6 +1537,7 @@ async function startApp(): Promise<void> {
const conversationService = createConversationService({ ledger: conversationLedger, listWorktrees: listWorktreesForCwd })
registerAllIpc({
manager,
sessionFeedTap: feedTap,
userMcpService,
updates: { updateService, updateChecks, app: { version: app.getVersion(), isPackaged: app.isPackaged } },
remoteController,
Expand Down
5 changes: 4 additions & 1 deletion src/main/ipc/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SessionFeedTap } from '@main/sessions/sessionFeedTap.js'
import type { SessionManager } from '@main/sessionManager.js'
import { EditorFsRootRegistry } from './editorFsRootRegistry.js'
import type { LspManager } from '@main/lspManager.js'
Expand Down Expand Up @@ -84,6 +85,8 @@ export type IpcDeps = {
ghostJournals: GhostJournalRegistry
dictationDebugJournals: DictationDebugJournalRegistry
pasteDebugJournals: PasteDebugJournalRegistry
/** The one main-side session feed tap (#1177); see registerSessionIpc. */
sessionFeedTap: SessionFeedTap
// Null in a normal build — only constructed when session recording is gated
// on (main/index.ts). The dev-debug IPC needs it for the Attach-Recording-
// Note handlers (plan §7b).
Expand Down Expand Up @@ -111,7 +114,7 @@ export function registerAllIpc(deps: IpcDeps): void {
installPerformanceIpcInstrumentation()
registerEditorFsIpc(editorFsRoots)
registerEditorFsWatchIpc(editorFsRoots)
registerSessionIpc(deps.manager, deps.pasteDebugJournals, deps.appRunJournal)
registerSessionIpc(deps.manager, deps.pasteDebugJournals, deps.sessionFeedTap, deps.appRunJournal)
registerProviderIpc(deps.manager)
registerLspIpc(deps.lspManager, editorFsRoots, deps.aiWorkspaceRegistry)
registerFsIpc()
Expand Down
Loading
Loading