Move the renderer-side AI-streaming runtime (IpcChatTransport, TopicStreamSubscription, streamDispatchCoordinator) out of the top-level src/renderer/transport/ directory into the shared services/aiTransport/ bucket. By shape these are stateful runtime singletons/classes, and the runtime is cross-surface (consumed by chat, quick-assistant, selection), so per the renderer architecture it routes into services/, not its own top-level directory. - Add a curated index.ts barrel exposing only the externally consumed symbols (ipcChatTransport, TopicStreamSubscription, ExecutionTerminal); the class, dispatch coordinator and helpers stay private. - Update the 6 consumer sites (5 imports + 1 vi.mock) to the barrel. - Sync architecture and AI docs to the new path; drop the now-resolved transport/ deviation from the renderer-architecture pending table.
4.3 KiB
IPC Transport
What it is
IpcChatTransport
(src/renderer/services/aiTransport/IpcChatTransport.ts) implements AI SDK's
ChatTransport<CherryUIMessage> over Electron IPC. The renderer feeds
it into useChat({ id: topicId, transport: ... }). The ChatTransport
interface has only two methods — sendMessages / reconnectToStream;
the transport relays each over window.api.ai.stream* to Main's
AiStreamManager. cancel is not a transport method: it is the
cancel callback of the ReadableStream that sendMessages returns
(AI SDK invokes it on unmount/disposal), and abort is driven by the
request's abortSignal.
useChat({ id: topicId, transport: new IpcChatTransport(defaultBody) })
│ transport methods
├─ sendMessages → window.api.ai.streamOpen (Ai_Stream_Open)
├─ reconnectToStream → window.api.ai.streamAttach (Ai_Stream_Attach)
│ returned-stream / signal callbacks
├─ stream cancel() → window.api.ai.streamDetach (Ai_Stream_Detach)
└─ request abort signal → window.api.ai.streamAbort (Ai_Stream_Abort)
Detach ≠ abort. cancel() (e.g. unmount/disposal) calls streamDetach:
it drops this subscriber while Main keeps generating and persists the
result. Stopping generation is a separate path — the request's abortSignal
firing calls streamAbort. Conflating the two would resurrect the v1
"unmount → cancel → upstream abort → lost reply" bug class.
Per-topic chunks arrive via onStreamChunk listeners filtered by
topicId.
Triggers
sendMessages distinguishes two triggers:
| Trigger | What it does |
|---|---|
submit-message |
Includes userMessageParts (the latest message) so Main persists it |
regenerate-message |
Sends parentAnchorId only; Main re-runs from the existing parent |
Cherry's transport never derives continue-conversation from
message-state introspection. Approval-driven resumption goes through the
explicit Ai_ToolApproval_Respond IPC handled by
useToolApprovalBridge.
Dispatch coordinator
streamDispatchCoordinator (src/renderer/services/aiTransport/streamDispatchCoordinator.ts)
sits between the transport and the IPC call so the Ai_Stream_Open ack
(userMessageId, placeholder ids, executionIds) is observable to callers
that need to join optimistic UI bubbles, rather than being thrown away by
AI SDK's transport interface.
It does not serialize sends — there is no single-in-flight guard in the
coordinator. Concurrency for a topic is arbitrated on the Main side: a chat
resubmit to a live topic is persisted and queued as a steer
(AiStreamManager.enqueuePendingSteer) — the running turn yields and a
continuation answers it — while an agent-session follow-up attaches to the
running stream.
Per-execution demux
The chunk stream from Main is keyed by (topicId, executionId).
TopicStreamSubscription
(src/renderer/services/aiTransport/TopicStreamSubscription.ts) owns the
topic-level streamAttach / streamDetach with ref-counted lifecycle
and demuxes chunks into per-execution branch ReadableStreams, so
multi-model parallel responses render as separate AI SDK messages on
the same topic. useExecutionOverlay consumes each branch through
readUIMessageStream — the same accumulator Main runs in
pipeStreamLoop, so the renderer overlay and the persisted message
are structurally identical.
See Execution Overlay for the merge-function symmetry, seed rule, cancellation layering, and lifecycle.
Topic-level subscription
useTopicStreamStatus(topicId) reads
topic.stream.statuses.<topicId> from the shared cache. The cache is
the cross-window source of truth for:
pending/streaming/awaiting-approval/done/error/aborted- broadcast-completion anchor ids
classifyTurn(status) decodes the status into the TurnStateFlags
predicates the UI consumes (isStreamLive, isTurnActive,
isAwaitingApproval, isTerminal).
Where to read more
- Code:
src/renderer/services/aiTransport/ - Hook glue:
src/renderer/hooks/useChatWithHistory.ts - Per-execution overlay (renderer assembler): Execution Overlay
- Approval bridge: Tool Approval
- Main side: Stream Manager