Every SidecarEvent that reaches the frontend lands in an XState v5 machine called streamingMachine. The machine is the source of truth for the activity stream rendered in the chat, and it drives canvas updates via a downstream interceptor hook. This page covers the lifecycle, the spec-stream parser, and the React 19 throttling pattern.

States and transitions

The machine lives at src/machines/streaming/machine.ts. Initial state idle. Six states total: idle, sending, streaming, complete, cancelled, error. Transitions and actions:
  • SUBMIT → enter sending. Actions: resetInlineWidgetStream, initSpecStreamParser.
  • STREAM_START → enter streaming. (Phase 4 spec-stream parser already initialized.)
  • CHUNK → action processSpecStream(text) — push text through the mixed-stream parser to extract JSONL patch lines, emit remaining text to the activity stream.
  • THINKING → push to activityStream as a thinking-block entry.
  • TOOL_START / TOOL_COMPLETEpushToolToStream / updateToolInStream (in actions/activity-stream.ts).
  • USAGE_UPDATE → bumps token counters; tracked per-API-turn.
  • streaming state exitflushSpecStream, finalizeInlineWidgetStream (runs on ALL exit paths — complete, cancel, error).
  • COMPLETEpersistInlineWidget before finalizeMessage. ✓ VERIFIED at machine.ts:498-509.

The activity stream

activityStream is an array on the machine’s context. Each entry is one of:
TypeContentsCreated by
textStreaming text accumulated from CHUNK eventsprocessSpecStream (text-only portion)
thinkingThinking-block content (extended reasoning)THINKING handler
toolTool invocation: {name, input, status, output, isError, parentToolUseId}pushToolToStream + updateToolInStream
subagentSubagent run reference (with parentToolUseId)subagentStart/subagentComplete
The interceptor hook (useCanvasToolInterceptor) consumes this stream. By the time the interceptor sees an item, it’s already been normalized and pushed by XState actions — the interceptor does NOT receive raw IPC. ✓ VERIFIED at useCanvasToolInterceptor.ts:108.

Spec streaming — the mixedStreamParser

When an agent emits a mcp__canvas__canvas(action: 'render_widget') tool call with a streaming spec, the spec doesn’t arrive as one blob — it streams in alongside the assistant text. Two mechanisms cooperate:

initSpecStreamParser

Action defined in actions/spec-stream.ts:31-50. Creates a createMixedStreamParser() closure with an onPatch callback. On every patch:
onPatch: (patch) => {
  const streamingSpec = applySpecPatch(currentSpec, patch);
  useCanvasStore.getState().updateStreamingSpec(streamingSpec);  // live canvas
  useInlineWidgetStreamStore.getState().pushSpec({ ...streamingSpec }); // inline chat
}

processSpecStream

Action invoked on every CHUNK event. Pushes the chunk text through the parser. The parser:
  1. Detects JSONL patch lines mixed in (each on its own line, prefixed with {").
  2. Strips them from the chat text before rendering.
  3. Applies them to the accumulating spec.
✓ VERIFIED at spec-stream.ts:88-130.

flushSpecStream + finalizeInlineWidgetStream

Run on exit of streaming state (every path). Ensures any in-flight spec gets finalized before the message is persisted.

persistInlineWidget

Only runs on COMPLETE. Stores the final inline widget so it survives session reload.

React 19 throttling pattern

React 19’s automatic batching can collapse multiple state updates within a single render cycle. For a streaming machine emitting tens of updates per second, this would lose intermediate spec frames. useStreamingSession (src/hooks/useStreamingSession.ts) subscribes to the XState actor’s state changes and uses requestAnimationFrame to throttle:
let pendingSnapshot: Snapshot | null = null;
let rafId: number | null = null;

actor.subscribe((newState) => {
  pendingSnapshot = newState;
  if (rafId === null) {
    rafId = requestAnimationFrame(() => {
      setSnapshot(pendingSnapshot);
      rafId = null;
    });
  }
});
✓ VERIFIED at useStreamingSession.ts:303-321. Result: at most one React render per animation frame, intermediate spec frames preserved as they arrive.

SidecarEvent → XState mapping

The listener in StreamingSession (the JS wrapper around the actor) maps Tauri IPC events to XState events:
SidecarEventXState eventAction
textCHUNKprocessSpecStream
thinkingTHINKINGpushThinkingToStream
toolStartTOOL_STARTpushToolToStream (status=‘running’)
toolStartingTOOL_STARTING(permission about-to-run precursor)
toolCompleteTOOL_COMPLETEupdateToolInStream (status=‘complete’)
subagentStartSUBAGENT_STARTpushSubagentToStream
subagentCompleteSUBAGENT_COMPLETEupdateSubagentInStream
usageUpdateUSAGE_UPDATEbumpTokens
permissionRequest(handled outside XState — modal IPC)
completeCOMPLETEfinalizeMessage
errorERRORrecordError
cancelledCANCELrecordCancellation

Inline widget streaming

A SEPARATE store at src/stores/inlineWidgetStreamStore.ts holds inline-widget state per message. Spec streaming pushes patches to both canvasStore.updateStreamingSpec (for the canvas) AND inlineWidgetStreamStore.pushSpec (for the in-chat inline widget). They’re independent — a single agent message may render both an inline preview in the chat AND auto-open the full widget in the canvas, depending on the element count threshold. finalizeInlineWidgetStream (on streaming exit) commits the final spec to the inline widget for that message. persistInlineWidget (on COMPLETE) writes to backend storage so the widget survives session reload — but the exact persistence path was flagged as an open question in the audit (see Trust → GAP-C / open questions).

Session lifecycle integration

The XState machine doesn’t own session lifecycle. useStreamingSession does:
  • Singleton SessionManager instance (getSessionManager() at useStreamingSession.ts:50).
  • getOrCreateStreamingSession(activeSessionId, conversationId) creates/retrieves a StreamingSession (which owns the XState actor).
  • TIGER-5 fix: persists sdkSessionId to the DB when it first appears in the machine context (lines 323-345).
  • Liveness watchdog: tracks lastEventAtRef on every IPC event; sets showInactivityWarning after 5 min silence.

Key files

The XState v5 machine definition. State graph, transitions, actions wired in. Spec-stream init at L379. Persist hooks at L498-509.
initSpecStreamParser (L31-50), processSpecStream (L88-130), flushSpecStream. Mixed-stream parsing logic.
pushToolToStream, updateToolInStream, pushSubagentToStream, pushThinkingToStream.
The StreamingEvent union type — XState’s input alphabet.
Singleton session manager + actor subscription with rAF throttling. ~760 LOC. Throttle at L303-321.
Composed hook returning the public API (activityStream, send, cancel, retry).
Separate Zustand store for inline-widget streaming per message.

Open questions

  • Spec stream backpressure — if patches arrive faster than React can render, do they queue or drop? rAF throttle suggests they coalesce but exact behavior under heavy load was not verified.
  • Inline widget persistence targetpersistInlineWidget writes somewhere on COMPLETE. Is it message-scoped DB? Vault file? Need to read persist* action wiring.

Next

TipTap Editor

How the document-mode canvas surface handles AI-mediated content updates.