Skip to content

Chat Node UI

dazeb edited this page Sep 17, 2026 · 2 revisions

Chat Node UI

What this component is

ChatNode is the React Flow node that renders one agent conversation directly on the canvas. It is explicitly not a PTY terminal (see the header comment at L14–L19): the node owns the presentation of a conversation, while every provider request, streaming socket and tool execution happens in the main process behind window.termsprawl.chat. Two invariants follow from that split and explain most of the code in the file:

  1. Two copies of the transcript exist. Node data (data.messages, persisted with the project file) is the durable copy; a local useState copy is the one that actually streams.
  2. Replies arrive as events, not as the send() result. chat.send() only returns an ok/error envelope (L244–L257). Tokens, usage, tool calls and turn completion are delivered on a per-node push channel subscribed with window.termsprawl.chat.onEvent(id, handler) (L59–L144).

A developer editing this file is almost always editing one of: the event reducer (L60–L140), the send path (L164–L258), or the streaming/persistence boundary between the two.

Turn lifecycle

stateDiagram-v2
  [*] --> Idle
  Idle --> Streaming: sendText - append user msg, streaming=true, busy=true, chat.send()
  Streaming --> Streaming: delta / thinking / usage / toolCall
  Streaming --> Streaming: toolResult resolves a matching call id
  Streaming --> Streaming: context-added - persisted at once, links marked dirty
  Streaming --> Idle: done - commit with undo record, persistNodeData, links.markDirty
  Streaming --> Idle: done reason=stopped - message flagged stopped
  Idle --> Idle: local slash commands
Loading

Key nodes:

  • Idle → Streaming is the only place a user message is appended and the only place chat.send() is called. It also flips node-data streaming: true (L240), which the node chrome (badge/spinner) can read.
  • Streaming → Streaming transitions are pure functional setMessages updates. They never touch node data, so a turn can emit hundreds of deltas without writing to the workspace file or the undo stack.
  • Streaming → Idle on done is the commit point: the accumulated transcript is written with record = true (one undo entry per turn) and then persisted (L124–L137). This is the boundary that makes a completed turn survive a reload.
  • context-added is the one event that bypasses the streaming message entirely. It is injected by node links (Phase 18 context injection), deduped by ev.messageId, committed without an undo record, and then persisted before links.markDirty(id) so auto-linking can re-derive links from the changed conversation.

Event contract

ev.kind Effect inside the onEvent handler Persisted when
delta Lazily creates the assistant message via ensureAssistant(), appends ev.text to content on done
thinking Same message, appends to thinking on done
usage Attaches { inputTokens, outputTokens } plus optional model to the streaming message on done
toolCall Pushes { ...ev.call, status: 'running' } into toolCalls, deduped by call.id because the approval hook re-emits the same call (L95–L98) on done
toolResult Replaces result / isError / status on the matching call; unmatched ids are silently ignored on done
context-added Appends a linked-context message if messageId is unseen; commits and persists immediately immediately
done Marks the streaming message stopped when reason === 'stopped', clears streamingMsgId, setBusy(false), commits with an undo record immediately

The assistant message identity is a ref (streamingMsgId), not state, precisely because several events in the same tick must agree on which message they are appending to. ensureAssistant() is idempotent: if a message is already streaming, later events reuse it; otherwise it mints a crypto.randomUUID() and appends a fresh empty assistant message.

Local state vs. node data

State Kind Lifetime / notes
messages useState, seeded from data.messages ?? [] Authoritative while streaming. Seeded once at mount — it is never re-synced from props.
busy useState Set on send, cleared only by done or a failed chat.send envelope.
input, error useState Textarea contents and the visible error line.
streamingMsgId, systemRef, lastSendRef, followRef useRef Stream identity, last accepted system prompt, retry payload, scroll-follow armed state.
prices, enterBehavior useState, loaded once from settings.get() on mount (L38–L50) Per-model cost overrides and what Enter does mid-turn.
data.model, data.provider, data.system node data Send-time configuration, forwarded verbatim to chat.send (L244–L250).

commitMessages(msgs, record) (L53–L55) is the single funnel from local state into node data; record controls whether the mutation becomes an undo entry. Turn completion and /clear use record = true; /cost notes and link-injected context deliberately do not.

Input handling

send() and sendText() are split on purpose: send() reads the textarea and applies the Enter-while-busy policy, sendText(text) takes the text explicitly so the retry button can re-send the last payload (lastSendRef) without waiting for React state to round-trip (L183–L188).

Slash commands are resolved locally and never reach a provider:

Command Effect
/clear Resets local messages and writes messages: [], cost: undefined, streaming: false with an undo record
/model <arg> updateNodeData(id, { model: arg }, true) — the model becomes part of the node's serialized data
/system <arg> Updates systemRef.current and node data system
/cost Appends a note-role message with total tokens and conversationCost(...) USD; the costing copy drops toolCalls first
anything else Falls through and is sent as a normal user message

Note the argument parser: text.slice(1).split(/\s+/) with rest.join(' ') collapses runs of whitespace, and /model / /system without an argument silently fall through to a provider send.

How the files collaborate

  • src/renderer/src/nodes/ChatNode.tsx — the component described above; owns streaming state, the event reducer, slash commands and the send path.
  • src/renderer/src/canvas/Canvas.tsx (imported L5, used L21) — supplies useCanvas(), which is the node's only writer to the workspace store: updateNodeData (in-memory + undo), persistNodeData (durable write), closeNode.
  • src/renderer/src/state/workspace.ts (imported L4, L7) — defines ChatNodeData and re-exports nodeTitle; ChatNodeData['messages'][number] is aliased to the local ChatMsg type, so message shape changes flow through this one alias.
  • src/core/chat/types.ts (imported L9) — ChatEvent and ChatToolCall, the shared contract with the main-process chat runtime.
  • src/core/chat/cost.ts (imported L10) — conversationCost and ModelPrice, used by /cost and driven by settings.chat.priceOverrides.
  • src/renderer/src/nodes/LinkHandles.tsx and src/renderer/src/components/HelpBadge.tsx (imported L6, L8) — link affordances and the transient help affordance rendered inside the node chrome.

The important coupling is that the node is the only place that writes chat transcripts into node data. The main-process runtime streams events; the node decides what becomes durable and when. Anything that wants to inject conversation content (links, tools, imports) must go through the context-added event or write node data directly.

Boundaries, hazards and extension points

  • Enter-while-busy is currently unreachable past the guard. send() documents that queue and send should "fall through and send now", but sendText() begins with if (!text || busy) return (L186). While a turn is streaming, busy is true, so both branches drop the message (the send branch still calls chat.stop(id), leaving the text in the textarea). Only prompt behaves as documented. Fix this deliberately — e.g. a force parameter — rather than by removing the guard, because the guard also protects against double-sends from rapid keystrokes.
  • Node-data streaming is never cleared on success in this range. It is set at L240 and reset to false only in the chat.send failure path (L255). If nothing outside this range clears it, the node can render as permanently streaming after a successful turn.
  • Local messages can drift from data.messages. Because useState seeds from props once, an undo/redo of a turn, a project reload, or any external writer will not be reflected in an already-mounted node.
  • note messages are part of the outgoing payload. sendText forwards the entire local transcript to chat.send; the /cost comment asserts notes are never sent to providers, so that filtering must happen in the main-process runtime. If you add another local-only role, verify that path.
  • toolCall dedupe depends on stable call.id across re-broadcasts. Changing how approvals re-emit calls without preserving the id will produce duplicate cards.
  • Failure handling is asymmetric. A failed chat.send surfaces res.error and clears busy; persistNodeData(...).then(markDirty).catch(() => {}) swallows errors entirely.
  • The subscription returns its own disposer (return window.termsprawl.chat.onEvent(id, …)), so chat.onEvent must return an unsubscribe function keyed to id; otherwise nodes leak listeners on unmount.
  • Settings are snapshotted once per mount. Changing enterBehavior or priceOverrides in settings will not affect nodes that are already open.
  • Auto-scroll is conditional. followRef is armed only while the viewport is within 24px of the bottom, so reading history is not interrupted; the pinning effect runs on every render with no dependency array.
  • The event subscription intentionally runs once per node ([id] with an eslint-disable at L143), relying on functional updates and refs instead of closing over props. Any new event branch you add must respect that rule — read nothing from props, only from prev and refs.

Extension points: new event kinds go in the if / else if chain (L70–L137) and should decide explicitly whether they commit immediately or wait for done; new slash commands go above the fall-through at L234; new per-node configuration should be read from node data so it serializes with the project, not from component-local settings.

Limits of this snapshot

Only src/renderer/src/nodes/ChatNode.tsx lines 1–260 were provided. Everything after const stop = ... (L260) — including the JSX that renders messages, thinking blocks, tool/permission cards, token chips, the model and account selectors, and the keyboard handler that calls send() — is not visible, so this page describes the state and event contract those views consume rather than their markup. The collaborating modules listed above are evidenced only by their imports; their internal line ranges were not available for citation.

Sources: src/renderer/src/nodes/ChatNode.tsx#L1-L60, src/renderer/src/nodes/ChatNode.tsx#L59-L144, src/renderer/src/nodes/ChatNode.tsx#L146-L182, src/renderer/src/nodes/ChatNode.tsx#L183-L260

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally