This document explains how the desktop app's React frontend holds and updates state during workflow editing and live runs. The central tension is performance: a ReactFlow canvas with dozens of nodes, each receiving a stream of tokens at high frequency, will jank badly under naïve React state. Relavium's answer is three deliberate choices — Zustand stores instead of React Context, direct per-node subscriptions into those stores, and a token double-buffer that caps re-renders at the display refresh rate. The Zustand store shapes are canonical in ../reference/shared-core/store-shapes.md; this document explains why the model is built the way it is.
flowchart TB
Engine["packages/core RunEventBus<br/>(WebView-side, in-process)"] --> SM["SseManager<br/>(singleton, non-React)"]
SM -->|agent:token, batched| RB["rawBuffer"]
RB -->|16ms RAF flush| TB["tokenBuffer<br/>(runStore)"]
SM -->|node:started / node:completed / cost:updated| RunStore["runStore<br/>(per-run, per-node status)"]
subgraph Stores["Zustand stores"]
ProviderStore["providerStore"]
AgentStore["agentStore"]
WorkflowStore["workflowStore"]
UiStore["uiStore"]
RunStore
end
RunStore -->|useRunNodeStatus(id)<br/>memoized selector| Node["AgentNode #id<br/>(React.memo)"]
TB --> Node
WorkflowStore --> Canvas["ReactFlow canvas"]
Node --- Canvas
Status: this document is grounded in the still-true frontend findings of the deep analysis (Zustand + ReactFlow direct subscriptions, the token double-buffer, canvasStore/runStore separation). The exact store
Shape:{}definitions are the canonical property of ../reference/shared-core/store-shapes.md.
The decision to put ReactFlow node state in direct Zustand subscriptions
rather than React Context is recorded in
ADR-0010. The
adversarial review of the original design found that holding canvas node state in a
CanvasContext caused O(n) re-renders: a single streaming token would change
the context value and re-render every node, not just the one receiving the token.
At 9 node types, dozens of nodes, and a token every few milliseconds, that is
unworkable. Zustand with per-node selectors fixes it because a component only
re-renders when the specific slice it subscribes to changes.
State is split into focused Zustand stores (Zustand v5 + immer). The complete
Shape:{} for each is in
../reference/shared-core/store-shapes.md;
in summary:
| Store | Holds | Notes |
|---|---|---|
providerStore |
configured providers, model catalog | keys themselves never live here — only references; see local-first-and-security.md |
agentStore |
agent definitions, selected agent | mirrors *.agent.yaml files |
workflowStore |
the open workflow, undo/redo history | serialized to YAML on save |
uiStore |
panel open/closed, theme, selection, active tab, activeSessionId |
pure view state; Chat and Canvas are co-equal tabs |
runStore |
live run status, per-node status, token buffers, cost | the hot path during a run |
Chat sessions are DB-first, not a store: a session is auto-persisted and
resumable in history.db and queried via IPC, so the only session state in a
global store is the transient uiStore.activeSessionId. There is no sixth
store — the five-store split above is the whole set. See
agent-sessions.md for the session model and
../reference/contracts/agent-session-spec.md
for the runtime contract.
The single most important decision is keeping canvas structure (which nodes and edges exist, their positions) separate from run state (per-node live status and streaming tokens):
- Canvas structure uses ReactFlow's
useNodesState/useEdgesState, isolated in a canvas-scoped store and serialized to YAML on save. It changes when the author edits the graph. - Run state lives in
runStore. It changes constantly during a run.
If these were one store, every token would invalidate canvas structure and force a
full graph re-layout. Keeping them apart means a streaming token only touches
runStore, and only the one node subscribed to that node's slice re-renders.
Each custom node component subscribes to only its own slice of runStore via a
memoized selector — for example a useRunNodeStatus(id) hook keyed by nodeId,
compared with a shallow-equality function so a node re-renders only when its own
status or token text actually changes. Combined with React.memo on the node
components and a stable nodeTypes reference (a common ReactFlow footgun — an
inline nodeTypes object remounts every node on each render), this keeps the cost
of a streaming run proportional to the active nodes, not the whole graph.
LLM token events arrive faster than the screen can usefully repaint. Rendering
each token immediately would re-render a node hundreds of times per second for no
visual benefit and would starve the rest of the UI. Relavium uses a
double-buffer driven by requestAnimationFrame:
sequenceDiagram
participant Bus as RunEventBus (WebView-side)
participant Raw as rawBuffer
participant RAF as requestAnimationFrame (~16ms)
participant TB as tokenBuffer (runStore)
participant Node as node face
Bus->>Raw: agent:token (high frequency)
Bus->>Raw: agent:token
Note over Raw: accumulate without re-rendering
RAF->>Raw: flush on next frame
Raw->>TB: commit batched text
TB->>Node: single re-render (CSS opacity pulse)
Incoming agent:token events accumulate in a rawBuffer without touching
React state. On each animation frame (~16ms) the buffer is flushed into the
tokenBuffer in runStore, which triggers exactly one re-render of the affected
node. This caps token re-renders at roughly 60fps regardless of how fast tokens
arrive. A subtle but important correctness note from the review: the buffer must be
keyed per node, not a single shared buffer — a single global token buffer
corrupts output when multiple agent nodes stream in parallel.
A non-React SseManager singleton owns the subscription to the engine's event
stream and routes events into the stores. On the desktop the engine and its
RunEventBus run WebView-side in the same JS runtime, so this subscription is
in-process and run events do not cross IPC
(ADR-0018); the only
Rust→WebView channel on the LLM hot path is the delegated egress's
Channel<StreamChunk>, which the adapter folds into agent:token events on that
bus. Keeping the manager outside React means reconnection logic and gap detection
do not depend on component lifecycles:
- Events carry a
sequenceNumber; the manager detects gaps and re-syncs (in the local desktop case this is a state refetch; over HTTP in Phase 2 it is SSELast-Event-IDresumption). agent:tokenevents go through the double-buffer; status and cost events (node:started,node:completed,node:failed,cost:updated,run:completed,run:failed,human_gate:paused,human_gate:resumed) updaterunStoredirectly.- A
human_gate:pausedevent raises the root-levelHumanGateOverlay; the matchinghuman_gate:resumedclears it, and resolving the gate is idempotent on reconnect (see execution-model.md).
The event shapes are the
SSE event schema; on the desktop they
are produced and consumed WebView-side over the engine's in-process RunEventBus
(they do not cross the IPC contract,
which carries only the Channel<StreamChunk> egress on the LLM hot path).
Workflow editing keeps a bounded undo/redo history (a snapshot stack of the
workflow graph) in workflowStore. Snapshots are debounced so a drag does not
push dozens of entries, and history capture is disabled while a run is active to
avoid mutating the graph mid-execution.
- execution-model.md — what produces the events this layer renders.
- desktop-architecture.md — the Tauri shell that delivers events to the WebView.
- ADR-0010 — why Zustand direct subscriptions over Context.
- ../reference/shared-core/store-shapes.md — the canonical store shapes.