# Execution Overlay The renderer-side counterpart of Main's `pipeStreamLoop`. Both sides use the **same pure assembler** — [AI SDK's `readUIMessageStream`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/read-ui-message-stream) — to turn the chunk stream into a `CherryUIMessage`. Main writes the result to disk; the renderer paints it onto the chat surface as an overlay above the SWR-backed history. ## Why the same merge function on both sides `UIMessageChunk` assembly is non-trivial: text deltas merge by `id`, reasoning blocks have their own start/delta/end, tool calls go through `tool-input-start` / `tool-input-delta` / `tool-input-available` / `tool-output-available`, dynamic data parts merge by key, multi-step turns carry step boundaries. Re-implementing any of this on the renderer would mean a second source of truth that *had* to track AI SDK upstream, with two ways to disagree about partial state. Running the same `readUIMessageStream` on the same `UIMessageChunk` stream — once on Main (writing to `exec.finalMessage`), once on the renderer (driving the overlay) — guarantees structural agreement. What persists is exactly what the user saw streaming. ``` Main: pipeStreamLoop(stream) tee() ├─ branch A → broadcast to listeners → WebContentsListener → IPC chunks └─ branch B → readUIMessageStream → exec.finalMessage (writes to DB) ▲ │ (DB write) │ Renderer: TopicStreamSubscription ┌──── readUIMessageStream → snapshot │ │ │ ▲ │ ▼ │ │ │ routes chunks by │ fed by branch stream │ executionId + anchor │ │ into stream branches ───────┘ ▼ branch ReadableStream → useExecutionOverlay (per execution) ``` ## TopicStreamSubscription `src/renderer/services/aiTransport/TopicStreamSubscription.ts`. A renderer class that owns: - **One IPC attach per topic.** `attach` is ref-counted — every execution that calls `register(executionId, anchorMessageId)` increments the count; the last `unregister` triggers `detach` (deferred one microtask so a transient `activeExecutions` flicker doesn't detach-then-reattach). - **Execution + anchor demux.** Each `register(executionId, anchorMessageId)` returns a `ReadableStream` for that model writing to that assistant row. Multi-model parallel responses get separate branches by `executionId`; same-model steer continuations get separate branches by `anchorMessageId`. - **Anchor is part of stream identity.** `executionId` names the model, not the assistant row. During a steer continuation, Main can close A1a and immediately open A2 with the same model id. Chunks for A2 can arrive before React registers A2's reader, so the transport must buffer them under `executionId + anchorMessageId` instead of routing them to the closed A1a branch. - **Synchronous controller creation.** The branch's `ReadableStreamDefaultController` is created during the `new ReadableStream({ start })` call (synchronous), so chunks that arrived between `register` and the reader's first `read()` are already buffered in the stream's internal queue — late readers never miss replayed chunks. - **Terminal demux.** `Ai_StreamDone` / `Ai_StreamError` close the matching branch and fan out an `ExecutionTerminal` (`{ isAbort, isError }`) to listeners; if the payload carries `isTopicDone` or no `executionId`, every branch terminates together. ### Cancellation layering — do not conflate | Layer | Owner | Action | |---|---|---| | Renderer-local subscription | `TopicStreamSubscription.unregister` / `dispose` | Closes the branch reader, drops listener ref count; Main keeps generating | | Generation abort | Main (via `useChatWithHistory.stop` → Chat → `Ai_Stream_Abort`) | Stops the LLM | `TopicStreamSubscription` NEVER aborts the LLM. Closing all branches is the renderer equivalent of `streamDetach` — Main keeps streaming, other windows keep observing. ### Defensive routing A chunk without `executionId` is unexpected — Main always tags chat chunks. As a defensive fallback, if exactly one branch is registered the chunk routes there; otherwise it's dropped with a warning. ## useExecutionOverlay `src/renderer/hooks/useExecutionOverlay.ts`. The per-execution overlay, built on `useTopicStreamSubscription`. ```ts const { overlay, liveAssistants, disposeOverlay, reset } = useExecutionOverlay( topicId, activeExecutions, // ActiveExecution[] from useTopicStreamStatus uiMessages, // current DB snapshot { onFinish } ) ``` ### One reader per turn, zero cross-turn state Each execution gets a **one-shot `readUIMessageStream` reader** per turn, not a stateful AI SDK `Chat`. A `Chat` carries `state.messages` across turns; reusing it made a new turn resume from the previous turn's finished assistant ("previous answer + new stream"). A fresh reader per turn structurally cannot pollute. ### The seed rule (continue-safe) ```ts function pickSeed(uiMessages, anchorMessageId): CherryUIMessage | undefined { if (!anchorMessageId) return undefined const found = uiMessages.find((m) => m.id === anchorMessageId) if (!found) return { id: anchorMessageId, role: 'assistant', parts: [] } // `readUIMessageStream` mutates `message.parts` in place, and `found` is the live // SWR-derived row — clone the parts so the reader only ever writes to a throwaway. return { ...found, parts: structuredClone(found.parts ?? []) } } ``` The reader is seeded with the message whose id is the execution's `anchorMessageId`, taken from the **current DB truth** at reader-start time. Two cases: - **Fresh placeholder** — the SQLite row has empty parts; the seed is effectively empty and the reader builds the message from scratch. - **Tool-approval / continue-conversation** — the row already carries the prior assistant parts (including the unresolved `tool-input` part the approval was on). A streamed `tool-output` chunk then merges cleanly onto its matching `tool-input` because they share the same `toolCallId`. The seed is re-derived from DB on every reader start; it never carries across turns, and its `parts` are cloned so the reader's in-place mutation never touches the SWR row. Combined with the fresh reader, this is the **structural** anti-pollution guarantee — not "force empty parts" or "diff against last frame". ### Lifecycle 1. **Topic switch** — every reader is cancelled, every branch unregistered, `snapshots` cleared. `prevTopicRef` is checked in the render body so the cleanup runs synchronously before the new topic's readers start. 2. **`activeExecutions` change** — diff against the current reader map: cancel + unregister executions no longer in the active list; for newly-active executions, register a branch, clear any retained prior snapshot, kick a new reader. 3. **Terminal** — the branch is closed by `TopicStreamSubscription`; the reader's `for await` exits. The `onFinish(executionId, event)` callback fires with the final snapshot + `{ isAbort, isError }`. 4. **Unmount** — every reader is cancelled, every branch unregistered. ### Overlay teardown is monotonic `disposeOverlay(messageId)` drops exactly one snapshot entry. The chat shell wires this so the overlay is released **only after** the DB refresh promise resolves (see `.finally(() => disposeOverlay(...))` in `V2ChatContent`). That ordering eliminates the visible flash between "streaming overlay" and "persisted parts": the SWR cache holds the authoritative row before the overlay disappears. The renderer never writes streamed parts to SWR — writing them would race the DB-authoritative refresh and cause flicker. ### Why retained snapshots after terminal The hook keeps the final snapshot in `snapshots` until one of: - the same execution restarts (next turn clears it), - the caller calls `disposeOverlay(messageId)` (post-persist handoff), - the caller calls `reset()` (e.g. quick-assistant clear), - the topic switches (effect clears all snapshots). That retention lets consumers read the final frame for the brief window between stream-end and DB-refresh-complete without going through SWR. ## React binding `useTopicStreamSubscription(topicId)` is the React wrapper: - Lazy-init per `topicId` (idiom mirrors `useState(() => ...)`). - Disposed on unmount or topic switch — drops the Main listener and closes every branch. Each mounted topic gets one `TopicStreamSubscription` instance, shared by every consumer in that React tree (today: `useExecutionOverlay`). ## Code map ``` src/renderer/services/aiTransport/TopicStreamSubscription.ts ← class src/renderer/hooks/useTopicStreamSubscription.ts ← React binding src/renderer/hooks/useExecutionOverlay.ts ← per-execution readers + overlay src/renderer/pages/home/V2ChatContent.tsx ← consumer + dispose-after-refresh ``` ## Invariants reviewers should check 1. **Same merge function on both sides.** Any code that re-implements chunk → message assembly on the renderer (instead of feeding `readUIMessageStream`) is wrong — that's where Main and renderer will diverge first. 2. **One reader per branch identity.** No reader should be reused across `activeExecutions` transitions where either `executionId` or `anchorMessageId` changes. Reusing one is what the v1 `Chat` bug was; keying only by model id reintroduces the same problem for same-model steer continuations. 3. **Seed from current DB.** `pickSeed` reads `uiMessagesRef.current` at reader-start time. Stashing the seed on first mount and reusing it across turns would defeat the continue-conversation case. 4. **Overlay disposed after DB refresh.** Any `disposeOverlay(messageId)` call that runs **before** the DB revalidation promise resolves is a flicker bug. 5. **`TopicStreamSubscription` never aborts.** It only detaches. Anything in this layer that calls `Ai_Stream_Abort` is in the wrong place — abort belongs to `useChatWithHistory.stop`. 6. **Ref-counted attach.** A new attach must NOT fire when another execution is already registered for the same topic. A new detach must NOT fire while any execution still has a branch. ## Where to read more - Main-side accumulator: [Stream Manager — `pipeStreamLoop`](./stream-manager.md#execution-loop--runexecutionloop--pipestreamloop) - IPC envelope: [IPC Transport](./ipc-transport.md) - Topic status / approval-anchor surfacing: [Tool Approval](./tool-approval.md) - AI SDK upstream: [`readUIMessageStream` reference](https://ai-sdk.dev/docs/reference/ai-sdk-ui/read-ui-message-stream)