-
Notifications
You must be signed in to change notification settings - Fork 0
Infinite Canvas Surface & Viewport Interaction
The canvas is a React Flow plane that is deliberately unbounded in space: nodes may live at any coordinate, panning is never clamped, and only zoom is bounded (0.05x–3x). Canvas.tsx is the composition root — it mounts <ReactFlow>, owns the node/edge/link/selection state, and exposes a narrow imperative API (via context) to the custom node components rendered inside it. Almost everything a user perceives as "canvas interaction" is either React Flow's built-in behavior or a small, explicit override layered on top of it.
This page describes the mechanics first (viewport, node registry, project loading, links↔edges, menus, organize), then how the files divide labor. The provided excerpts cover Canvas.tsx lines 1–260 only; handlers defined later in that file are called out as not visible rather than guessed at.
<ReactFlow> is rendered with Background/BackgroundVariant, Controls, and MiniMap, plus the vendor stylesheet (reactflow/dist/style.css). The component pulls screenToFlowPosition, getViewport, setViewport, and fitView out of useReactFlow() — these are the only viewport primitives the rest of the code uses, so any new feature that needs to convert between screen and flow coordinates should reuse them rather than computing transforms by hand.
Zoom bounds are module constants:
-
MIN_ZOOM = 0.05— deliberately generous because the plane is spatially unbounded; at 0.05x the whole sprawl is visible at once. -
MAX_ZOOM = 3— nodes contain text; beyond ~3x there is nothing left to read.
Panning has no equivalent clamp. If you add one, be aware that "pull back and see everything" is an explicit design intent recorded in the source comment, not an accident.
By default React Flow owns wheel zoom. Canvas installs a capture-phase wheel listener on its own wrapper element that only acts when invertWheelZoom is true; otherwise it returns early and native behavior is untouched. When it does act it:
- Bail-out guards: the event target must be inside
.react-flow__renderer, and must not be inside a.nowheelsubtree. This is what keeps the context menu, history bar, file tree, controls, minimap, terminals, and editors scrolling themselves. -
preventDefault()+stopPropagation()so React Flow never also sees the event. - Convert
deltaYinto a scale factor (deltaMode === 1→ 0.05 multiplier, other non-pixel modes → 1, pixel mode → 0.002), then negate it:scale = 2 ** -delta. - Anchor at the cursor: the pointer position is converted to flow coordinates with
(px - viewport.x) / viewport.zoom, the next zoom is clamped to[MIN_ZOOM, MAX_ZOOM], andsetViewportis called withx = px - flowX * nextZoom,y = py - flowY * nextZoom.
Two details matter if you refactor the shell:
-
px/pyare measured from the wrapper element's bounding rect, not from the React Flow container. The anchor math therefore assumes the wrapper's top-left is the React Flow origin. If the shell ever offsets the React Flow container inside the wrapper, zoom anchoring will drift. - The listener is bound once with deps
[getViewport, setViewport](stable React Flow identities). The liveinvertWheelZoomvalue is read throughinvertWheelZoomRefso toggling the setting never re-binds the capture listener. The removal call passes{ capture: true }only — keep that symmetric or you will leak a capture listener on every remount.
nodeTypes maps the serialized type strings to components: terminal, sticky, group, diff, editor, browser, chat. diff and editor are wrapped in Suspense around lazy() imports so the ~10 MB Monaco bundle + workers stay out of the boot-critical chunk — the wrappers cast through unknown because React Flow's ComponentType contract wants the exact props type, and the fallback is a plain loading editor… div. Adding a node type means touching three places: this map, a create*/deserializeNodes path in state/workspace.ts, and the node component itself.
edgeTypes currently has a single entry, nodelink: NodeLinkEdge. Edges are not authored directly — they are a projection of NodeLink records (see §5).
Nodes rendered inside the canvas must be able to mutate their own data, persist it, and participate in undo, without those callbacks being serialized into node data. CanvasContext provides exactly that:
| Method | Purpose |
|---|---|
updateNodeData(id, patch, record?) |
Merge a patch into node data; record decides whether an undo snapshot is taken. |
persistNodeData(id, patch) |
Write the patch through to persistence (async). |
commit() |
Flush a debounced undo snapshot. |
closeNode(id) |
Remove a node — groups ungroup their children instead of deleting them. |
useCanvas() throws useCanvas must be used inside Canvas when the context is missing, which is the failure mode you hit if a node component is ever rendered outside the plane (e.g. in a preview or test harness) — render it through Canvas or supply a test provider.
The canvas keeps three related pieces of state:
-
links: NodeLink[]— the persisted reality, loaded from the project file over IPC. -
linksRef— a synchronous mirror so callbacks and event handlers never read stale links. -
edges: Edge[]— derived for React Flow viareconcileLinkEdges(next, previous).
replaceLinks(next) is the single funnel: it updates the ref, the state, and the derived edges together. Any new code path that mutates links should go through it (or through deleteLink), otherwise the ref/state/edge triple can diverge.
deleteLink(linkId) filters by id and then re-persists with persistLinks, which calls window.termsprawl.links.update(projectId, serializeLinks(links)) and swallows failures (fire-and-forget). cascadeLinksForNodes(removedIds) exists specifically for node deletion, so removing a node drops every link touching it rather than leaving dangling edges. Link kinds are constrained by connectableLinkKinds / linkDefaultConfig from core/links/registry.
Link execution state is tracked in two forms, mirroring the links/ref pattern: busyLinksRef (a Set for synchronous checks) and linkRunBusy (a Set in state so the UI re-renders). inspectedLinkId selects which link the LinkInspector shows; it is reset whenever the active project changes.
A single effect keyed on activeProjectId owns the whole swap:
-
generation = ++projectGeneration.current— every load gets a monotonic id. -
loadingRef.current = true, clearinspectedLinkId, resetbusyLinksRef/linkRunBusy. - Seed from
nodeCache[activeProjectId](an in-memory serialized snapshot keyed by project id) throughdeserializeNodes. If there is no active project →[]. If there is a project but no cached nodes →[createTerminalNode(cwd)], i.e. a new project always starts with one terminal. -
setEdges([])andreplaceLinks([])immediately; links then arrive asynchronously. -
window.termsprawl.links.list(activeProjectId)is awaited, and the result is applied only if it is not cancelled, the generation still matches, andactiveProjectIdRef.currentstill equals the project. Older main-process builds without link support simply reject and leave the canvas linkless. - A
setTimeout(…, 0)releasesloadingRefso React Flow has a tick to settle before anything observes "not loading".
The cleanup bumps projectGeneration again and clears the timer, which is why a slow IPC response from a previous project can never paint over the current one. activeProjectIdRef exists so unload handlers and the IPC callbacks read the current project without re-subscribing; latestNodesRef plays the same role for nodes.
useHistory(nodes, setNodes) returns { push, undo, redo, invalidate, canUndo, canRedo }. The canvas feeds it the entire nodes array, so a snapshot is a full layout capture rather than a diff. invalidate is used to kill an id inside every snapshot — this is what prevents undo from resurrecting a terminal that was permanently closed. Organize actions take exactly one snapshot each, so one Ctrl+Z reverts one organize click.
The full history semantics live in the Workspace/Project state module; from the canvas's perspective, updateNodeData(..., record) and commit() are the knobs that decide when a snapshot is taken.
selectedIds: string[] is canvas-level state (not derived from React Flow), and nextSelection from state/canvas-knav is the shared selection-advance helper used by keyboard traversal. The context menu carries an optional nodeId, so a menu opened on a node can render node-scoped actions while a pane menu renders canvas-scoped ones. A related linkMenuOpen flag drives edge-scoped menu state.
CanvasMenu is a positioned role="menu" container that solves four problems the raw coordinates do not:
-
Clamping. A layout effect computes
left/top, clamped to[8, innerWidth - width - 8]/[8, innerHeight - height - 8], so the full action list stays reachable at every edge and window size. AResizeObserverplus awindowresize listener re-runs positioning when the menu's own size or the viewport changes. -
Dismissal. A
pointerdownlistener ondocumentcloses the menu when the click lands outside; the handler is stored incloseRefso the effect does not need to re-run on every render. -
Focus discipline. On open it focuses the first enabled button and remembers the previously focused element; on unmount it restores focus only if focus is still inside the menu.
EscapeandTabclose;ArrowDown/ArrowUp/Home/Enddo roving focus overbutton:not(:disabled). -
Event isolation.
onClickandonKeyDowncallstopPropagation(), so canvas-level shortcuts do not fire while the menu is open, and thenowheelclass keeps the inverted-wheel handler (and React Flow's native zoom) off the menu.
AgentMenuItems is the reusable "Open agent" section used inside menus: it lists agentIds() filtered by agentConfig(id).enabled, renders a brand logo via CSS mask when one exists (claude, codex→openai, grok, gemini→antigravity), and otherwise falls back to a monogram (OC for openclaude, >_ for everything else).
Organize is a single button with no dropdown. Each click advances a three-state cycle and the same button click again shows the next layout:
stateDiagram-v2
[*] --> Cascade
Cascade --> Flat : click
Flat --> Restore : click
Restore --> Cascade : click
- Cascade — stacked diagonal arrangement, stickies along the top.
- Flat — all windows arranged inside the current view, stickies in a row above.
-
Restore — put windows back exactly where they were before the cycle started (
OrganizeSnapshotcaptures the pre-cycle positions).
The layout math is pure and unit-tested in state/workspace.ts (layoutCascade, layoutFlat, layoutRestore, applied via applyLayoutPositions); the Canvas is responsible only for applying positions and recording one undo snapshot. The cycle position lives on the Canvas side, which is why the button itself is stateless.
Because the toolbar renders outside the Canvas provider, the button cannot call the canvas API directly. It publishes a one-shot request instead:
sequenceDiagram
participant B as OrganizeButton
participant Q as useCanvasRequests store
participant C as Canvas
participant W as state/workspace layout fns
participant H as useHistory
B->>Q: spawn({ kind: 'organize' })
Q-->>C: one-shot request consumed by Canvas
C->>W: layoutCascade / layoutFlat / layoutRestore + applyLayoutPositions
W-->>C: next positions (+ OrganizeSnapshot for Restore)
C->>H: push one undo snapshot
The key nodes: the request store is the decoupling seam (the same pattern the settings panel uses for spawning nodes); OrganizeSnapshot is what makes "Restore" possible; and pushing a single history entry per action is what makes each organize exactly one Ctrl+Z. The button disables itself when no project is open, since there is nothing to arrange.
src/renderer/src/canvas/Canvas.tsx — the composition root and the only place that knows about all the moving parts. It mounts <ReactFlow>, registers nodeTypes/edgeTypes, owns nodes/edges/links/selectedIds/menu/inspectedLinkId, performs the project-swap effect, holds the wheel override, and publishes CanvasContext. It pulls creators and serializers from state/workspace.ts, link serialization from state/workspace-links.ts, project state from state/projects.ts, history from state/history.ts, keyboard selection from state/canvas-knav.ts, and the request bus from state/canvas-requests.ts. It is the integration point: each of those modules is individually simple, and the canvas is where their contracts are wired together.
src/renderer/src/canvas/CanvasMenu.tsx — the presentation primitive for menus. It knows nothing about canvas state; it takes coordinates, a close callback, and children, and returns an accessible, clamped, focus-managed menu. AgentMenuItems is a pure section builder over the shared agent config. Because it does not import canvas state, it is reusable by any other menu surface.
src/renderer/src/components/OrganizeButton.tsx — the toolbar entry point. It deliberately contains no layout logic: it renders one button and emits spawn({ kind: 'organize' }). The cycle position, the layout math, and the undo snapshot all live elsewhere, which is why this file stays at ~50 lines.
Collaboration summary: OrganizeButton publishes a request → Canvas consumes it and chooses the next layout → state/workspace.ts computes positions (pure) → Canvas applies them and pushes one history entry. Independently, Canvas renders CanvasMenu with the correct anchor coordinates and, inside it, AgentMenuItems drives node creation through the same agent config used by the rest of the app.
| State | Kind | Role |
|---|---|---|
nodes, edges
|
React state | React Flow's controlled inputs; edges is derived from links. |
links / linksRef
|
State + ref | Persisted link records; the ref exists so handlers never read stale data. |
projectGeneration |
Ref | Monotonic guard against stale async loads. |
busyLinksRef / linkRunBusy
|
Ref + state | Same dual pattern, for link execution: sync checks vs. re-render. |
inspectedLinkId |
State | Which link the inspector edits; reset on project switch. |
menu, linkMenuOpen, selectedIds
|
State | Menu anchors/targets and canvas selection. |
loadingRef, latestNodesRef, activeProjectIdRef, invertWheelZoomRef
|
Refs | Values that event handlers and unload paths must read without re-subscribing. |
cleanupError |
State | Surfaces cleanup failures instead of swallowing them. |
-
Drag and resize are not implemented in the visible excerpt. Node dragging is React Flow's built-in behavior, and
applyNodeChanges/applyEdgeChangesare imported (the standard pattern for controlled change application), but the change handlers themselves are past line 260 and are not described here. Resize affordances belong to the individual node components (GroupNode, etc.), not toCanvas.tsx. -
The excerpt stops mid-file. Everything after
cascadeLinksForNodes— node/pane context-menu open handlers, connection validation usingconnectableLinkKinds, organize request consumption, keyboard shortcuts — is outside the provided range. Treat those as unverified on this page. -
Adding a node type: extend
nodeTypesinCanvas.tsx, provide a creator instate/workspace.ts, and make suredeserializeNodescan rebuild it. Lazy-load it throughSuspenseif it drags in a heavy dependency, following the Monaco precedent. -
Adding a link kind: register it in
core/links/registry.ts; the canvas consumesconnectableLinkKindsandlinkDefaultConfig, andNodeLinkEdgerenders it. -
Adding a menu action: append children to
CanvasMenu; you get clamping, dismissal, focus management, andnowheelisolation for free. Do not attach a competing wheel or pointer handler inside it. -
Anything that needs the canvas from outside the provider (toolbar, sidebar, settings) should go through
useCanvasRequests, not through a ref or a portal into the tree — that is the established decoupling seam. - Viewport limits are intentional. A zoom clamp exists; a pan clamp does not. If you introduce one, document the change, because "the plane is spatially unbounded" is load-bearing for the organize layouts.
Sources: src/renderer/src/canvas/Canvas.tsx, src/renderer/src/canvas/CanvasMenu.tsx, src/renderer/src/components/OrganizeButton.tsx
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