Skip to content

Node Links, Edges & Link Inspector

dazeb edited this page Sep 17, 2026 · 2 revisions

Node Links, Edges & Link Inspector

Node links are the named, node-to-node relationships on the canvas. They are domain objects persisted as NodeLink, then projected into React Flow edges for rendering. The key separation is:

  • NodeLink is the durable shape: ids, kind, config, auto flag, label, transient run result.
  • React Flow Edge is the view shape produced from a NodeLink.
  • The inspector edits the NodeLink; it should not treat edge data as the source of truth for config.

Runtime mechanism

A link is created and maintained through this path:

  1. Persisted links are normalized by parseNodeLink / parseNodeLinks.
  2. Renderer state holds normalized NodeLink[].
  3. Renderer helpers map those links into React Flow edges with type: 'nodelink'.
  4. The canvas renders each edge through NodeLinkEdge.
  5. Nodes expose connect affordances through LinkHandles.
  6. Selecting an edge opens LinkInspector against the original NodeLink.
  7. Inspector changes flow back as patches through patchLink, then state rebuilds edges.
  8. Saving serializes links through serializeLinks, which drops transient run state.
flowchart LR
  A["Project file links"] -->|parseNodeLink / parseNodeLinks| B["NodeLink[] normalized"]
  B -->|deserializeLinks| C["workspace link state"]
  C -->|linksFromSerialized / reconcileLinkEdges| D["React Flow Edge[] type='nodelink'"]
  D --> E["NodeLinkEdge"]
  C --> F["LinkInspector"]
  F -->|onChange LinkPatch| G["patchLink"]
  G --> C
  C -->|serializeLinks| A
  H["LinkHandles on node chrome"] -->|connect drag, handler outside excerpt| C
  E -->|selected edge| F
Loading

The important nodes in this flow are the normalization boundary, the edge projection, and the inspector edit loop. Normalization is deliberately tolerant of junk entries, but it is also strict about required fields and per-kind config. The edge projection is intentionally narrow: linksFromSerialized copies kind, auto, lastRun, and label into edge.data, but it does not copy config. That means the inspector must receive the full NodeLink from workspace state, not reconstruct it from the edge.

Shared link contract: src/shared/node-links.ts

This file owns the shared parse/normalize rules. It is Electron-free, DOM-free, and never throws. The renderer can import it directly, while core/workspace code can reuse the same rules without pulling browser-only or node:fs dependencies into the wrong bundle.

Supported kinds are:

Kind Required config shape
file-output kind, path: string, mode: 'overwrite' | 'append', header: boolean
context-inject kind, wrapper: boolean, pastePointer: boolean
a2a-peer kind, message: 'last-output' | 'full-capture', deliverReply: boolean

parseNodeLink requires:

  • id, source, target, and kind to be strings.
  • kind to be one of the known link kinds.
  • source !== target.
  • auto to be a boolean.
  • config to be a record whose own kind matches the link kind and whose remaining fields satisfy that kind’s shape.

Optional behavior:

  • createdAt accepts any number; if absent or invalid it falls back to Date.now().
  • label survives only if it is a string that trims to a non-empty value, then it is capped at 60 characters.
  • lastRun survives only if it is a record with at: number, ok: boolean, and summary: string.
  • parseNodeLinks maps over an array and drops every null result.

This parser is the validation gate. If a link disappears after reload, the most likely cause is that its persisted form failed one of these checks. The parser intentionally drops junk silently, so production of invalid links can look like missing links rather than a thrown error.

Renderer projection and persistence: src/renderer/src/state/workspace-links.ts

This module contains renderer-side pure helpers for already-normalized links. It stays React Flow-shaped where useful.

  • deserializeLinks(raw) delegates to parseNodeLinks, so renderer load uses the same rules as shared/core.
  • serializeLinks(links) produces JSON-safe copies, drops lastRun, and normalizes label by trimming and capping it at 60 characters. lastRun is intentionally transient; a stale result from a previous run should not be written back to the project file.
  • linksFromSerialized(links) builds React Flow edges:
    • id, source, target
    • type: 'nodelink'
    • data: { kind, auto, lastRun, label }
  • reconcileLinkEdges(links, previous) rebuilds edges from links but preserves edge.selected by id. This matters when state changes while an edge is selected; without it, an inspector driven by edge selection can close unexpectedly.
  • patchLink(link, patch) applies partial updates. Missing label means unchanged; explicitly passing label: undefined removes it. Other fields are only overwritten when not undefined. This helper does not validate or trim; callers such as the inspector are expected to pass valid values.
  • removeLinksForNode(links, nodeId) drops links in both directions when a node is deleted.

The boundary to remember: these helpers assume links are already normalized. They do not replace the shared parser as the persistence validation layer.

Edge rendering: src/renderer/src/canvas/NodeLinkEdge.tsx

NodeLinkEdge is the custom React Flow edge for type: 'nodelink'. It renders:

  • A bezier path via getBezierPath and BaseEdge.
  • Selected styling: lime #c6f135 stroke and width 2; otherwise #4a4a48 and width 1.5.
  • A midpoint kind chip in SVG. The chip maps:
    • file-output → file
    • context-inject → ctx
    • a2a-peer → a2a
    • unknown kind → the raw kind string
  • An optional user-facing name through EdgeLabelRenderer. The label is a DOM overlay at the midpoint, not SVG text. This is deliberate: it remains readable at any zoom. The label element carries nodrag nopan so interacting with it does not drag or pan the canvas.

NodeLinkEdge reads only data.kind and data.label from the edge. The rest of the link data, especially config, is not available on the edge. If you extend the edge to show more metadata, coordinate with linksFromSerialized; do not assume edge.data contains the full NodeLink.

Handle affordances: src/renderer/src/nodes/LinkHandles.tsx

LinkHandles is the shared anchor pair for linkable nodes:

  • A target handle on the left. isConnectableStart={false} prevents starting a connection from the target side.
  • A source handle on the right.

The handles have no ids. That matches the current link model, which stores only node ids in source and target; there are no sourceHandle / targetHandle fields on NodeLink. The comment states the handles are invisible until the node or an edge drag is active, with visibility controlled by the link-handle CSS class.

For extension work: if a node ever needs multiple link anchors, the data model must grow handle ids and the edge projection must set React Flow’s handle fields. The current model assumes one source anchor and one target anchor per linkable node.

Link inspector: src/renderer/src/components/LinkInspector.tsx

LinkInspector is the popover shown when a node-link edge is selected. It receives:

  • The full NodeLink.
  • sourceKind and targetKind, used to gate valid behaviors.
  • Callbacks for onChange, onRun, onDelete, and onClose.
  • A running flag so manual runs can disable the run button.

The inspector does not mutate the link directly. It emits partial patches. The parent is responsible for applying them, typically through patchLink, then reconciling edges.

Valid-kind gating

validKinds(sourceKind, targetKind) mirrors the canvas connect rules as expressed through the registry imported as ../../../core/links/registry:

  • If LINK_SOURCES['file-output'] includes the source kind, the inspector offers file-output.
  • If that same source-kind gate passes and the target is chat or terminal, it also offers context-inject.
  • If the target is a2a-peer and LINK_SOURCES['a2a-peer'] includes the source kind, it offers a2a-peer.

Note the coupling: as written, context-inject availability is gated by the file-output source list, not a separate context-inject source list. If the registry is meant to distinguish those source sets, this function and the canvas connect rules need to be updated together.

Inspector fields

  • Label: optional text input, max 60. An empty string is emitted as undefined, which patchLink interprets as removing the label.
  • Behavior: kind select. Changing kind resets config with linkDefaultConfig(kind) from the registry.
  • file-output: path relative to project, mode overwrite / append, timestamp header checkbox.
  • context-inject: for chat targets, a wrapper checkbox; for terminal targets, a paste-pointer checkbox.
  • a2a-peer: for chat sources, a payload select for latest assistant reply versus full conversation; terminal sources show explanatory text instead. A checkbox controls whether the reply is delivered back.
  • Auto: checkbox controlling auto.
  • Run now: calls onRun, disabled while running.
  • Delete: two-step in-popover confirmation, then onDelete.
  • Last run: if lastRun exists, shows an OK/error summary.

The inspector also re-exports linkDefaultConfig and LinkConfig for tests.

Collaboration and call chain

The cleanest way to reason about the system is:

  • Persistence enters through parseNodeLinks or deserializeLinks.
  • State owns the normalized NodeLink[].
  • Rendering derives edges through linksFromSerialized or reconcileLinkEdges.
  • Edge rendering uses NodeLinkEdge, which reads only kind and label.
  • Affordances come from LinkHandles, one source and one target per linkable node.
  • Selection should survive state updates through reconcileLinkEdges.
  • Editing happens in LinkInspector, which emits patches.
  • Saving goes through serializeLinks, dropping lastRun.

When you modify link behavior, keep these boundaries intact:

  • Do not persist lastRun through normal serialization.
  • Do not rely on edge.data.config; resolve the selected edge id back to a NodeLink in workspace state.
  • Do not bypass parseNodeLink on load unless you deliberately own a different normalization layer. Invalid configs will be dropped.
  • When changing kind, reset config to a shape that matches the new kind, or reload-time parsing will reject the link.
  • When deleting a node, remove incident links with removeLinksForNode.

Extension points

Adding a new link kind requires coordinated changes across several layers:

  • The shared allowlist and per-kind config validator in src/shared/node-links.ts.
  • The NodeLink / LinkConfig type union in shared types.
  • The registry module used for source-kind gating and linkDefaultConfig.
  • The edge kind chip mapping in NodeLinkEdge.
  • The inspector’s KIND_OPTIONS, validKinds, and per-kind field controls.
  • The actual runtime executor or scheduler that performs the link action.

Adding multiple anchors per node requires more than UI work. The current NodeLink fields are only node ids, and LinkHandles are not keyed by handle id. Supporting multiple links of different roles would require handle ids on nodes, handle ids on links, and corresponding React Flow edge wiring.

Evidence limits

The provided excerpts cover the shared parser, renderer projection helpers, edge renderer, handle component, and inspector. They do not show the canvas parent component that opens the inspector, the React Flow onConnect handler that creates links, the runtime executor/scheduler, the registry implementation, the persistence call sites in core/workspace-files.ts, the CSS for link classes, or any IPC surface. Those areas must be checked before changing creation, execution, or persistence semantics.

Sources: src/shared/node-links.ts, src/renderer/src/state/workspace-links.ts, src/renderer/src/canvas/NodeLinkEdge.tsx, src/renderer/src/nodes/LinkHandles.tsx, src/renderer/src/components/LinkInspector.tsx

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