-
Notifications
You must be signed in to change notification settings - Fork 0
Chat Node UI
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:
-
Two copies of the transcript exist. Node data (
data.messages, persisted with the project file) is the durable copy; a localuseStatecopy is the one that actually streams. -
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 withwindow.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.
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
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-datastreaming: true(L240), which the node chrome (badge/spinner) can read. -
Streaming → Streaming transitions are pure functional
setMessagesupdates. 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
doneis the commit point: the accumulated transcript is written withrecord = true(one undo entry per turn) and then persisted (L124–L137). This is the boundary that makes a completed turn survive a reload. -
context-addedis the one event that bypasses the streaming message entirely. It is injected by node links (Phase 18 context injection), deduped byev.messageId, committed without an undo record, and then persisted beforelinks.markDirty(id)so auto-linking can re-derive links from the changed conversation.
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.
| 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.
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.
-
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) — suppliesuseCanvas(), 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) — definesChatNodeDataand re-exportsnodeTitle;ChatNodeData['messages'][number]is aliased to the localChatMsgtype, so message shape changes flow through this one alias. -
src/core/chat/types.ts(imported L9) —ChatEventandChatToolCall, the shared contract with the main-process chat runtime. -
src/core/chat/cost.ts(imported L10) —conversationCostandModelPrice, used by/costand driven bysettings.chat.priceOverrides. -
src/renderer/src/nodes/LinkHandles.tsxandsrc/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.
-
Enter-while-busy is currently unreachable past the guard.
send()documents thatqueueandsendshould "fall through and send now", butsendText()begins withif (!text || busy) return(L186). While a turn is streaming,busyis true, so both branches drop the message (thesendbranch still callschat.stop(id), leaving the text in the textarea). Onlypromptbehaves as documented. Fix this deliberately — e.g. aforceparameter — rather than by removing the guard, because the guard also protects against double-sends from rapid keystrokes. -
Node-data
streamingis never cleared on success in this range. It is set at L240 and reset tofalseonly in thechat.sendfailure path (L255). If nothing outside this range clears it, the node can render as permanently streaming after a successful turn. -
Local
messagescan drift fromdata.messages. BecauseuseStateseeds 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. -
notemessages are part of the outgoing payload.sendTextforwards the entire local transcript tochat.send; the/costcomment 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. -
toolCalldedupe depends on stablecall.idacross re-broadcasts. Changing how approvals re-emit calls without preserving the id will produce duplicate cards. -
Failure handling is asymmetric. A failed
chat.sendsurfacesres.errorand clearsbusy;persistNodeData(...).then(markDirty).catch(() => {})swallows errors entirely. -
The subscription returns its own disposer (
return window.termsprawl.chat.onEvent(id, …)), sochat.onEventmust return an unsubscribe function keyed toid; otherwise nodes leak listeners on unmount. -
Settings are snapshotted once per mount. Changing
enterBehaviororpriceOverridesin settings will not affect nodes that are already open. -
Auto-scroll is conditional.
followRefis 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 fromprevand 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.
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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance