Grow a node with its ports, stop it flickering, draw what it reaches out to

- A node's height follows the ports on its busiest side. It is a function of
  the document, so `layoutGraph` reserves exactly what is drawn and nothing
  measured is fed back into the layout.
- The three status controls now sit in slots that are there whether the
  control is or not. A node running many times a second mounted and unmounted
  the stop button on every execution, resizing the card each time.
- A port bound to another flow's message is drawn as a label, naming the node
  at the far end and its type. Only the opposite direction was answered
  before. The scan behind both is now cached on the store's commit counter
  rather than reading every flow per request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
This commit is contained in:
2026-08-23 06:35:28 +02:00
co-authored by Claude Opus 5
parent 680c6053b9
commit 148d50f2bd
8 changed files with 454 additions and 131 deletions
+20 -2
View File
@@ -61,7 +61,7 @@ import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { LiveEdge } from "./LiveEdge"
import { type Direction, layoutGraph } from "./layout"
import { type Direction, layoutGraph, nodeHeight } from "./layout"
import { NodePanel } from "./NodePanel"
import { RunDialog } from "./RunDialog"
import "./flow.css"
@@ -512,9 +512,27 @@ function FlowEditorInner({
...external.nodes.map((node) => node.id),
]
const shapeKey = `${direction}|${key}|${ids.join(",")}`
// How tall each node's ports make it, which `FlowNode` draws to the same
// number. A function of the document — the bindings key above already covers
// every port, so this changes exactly when the layout has to run again, and
// nothing measured is ever fed back into it.
const heights =
direction === "LR"
? new Map(
definitions.map((node) => [
node.id,
nodeHeight(
Math.max(
(node.requires ?? []).length,
(node.provides ?? []).length,
),
),
]),
)
: new Map<string, number>()
// biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render.
const positions = useMemo(
() => layoutGraph(ids, edges, direction),
() => layoutGraph(ids, edges, direction, heights),
[shapeKey, edges],
)
+104 -81
View File
@@ -32,6 +32,7 @@ import { useIsMobile } from "@/hooks/useMobile"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { portOf } from "./deriveEdges"
import { nodeHeight, PORT_SPAN } from "./layout"
import {
liveStore,
useNodeEmits,
@@ -79,7 +80,9 @@ export type FlowNodeData = {
/** Spread handles along the node's edge so several ports stay reachable. */
function handleOffset(index: number, total: number): string {
if (total <= 1) return "50%"
const span = 60
// The same fraction `nodeHeight` sizes the node for, so a node is always
// tall enough for the ports this spreads down it.
const span = PORT_SPAN * 100
return `${50 - span / 2 + (span / (total - 1)) * index}%`
}
@@ -128,6 +131,14 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
// The graph runs top to bottom on a phone, so the ports have to face that
// way too — see DESIGN-GUIDELINES.md → Responsive.
const vertical = useIsMobile()
// Ports run down the sides only while the graph runs across, and that is the
// only direction in which their number decides the height: running downwards
// they spread along the node's width, which is fixed. Read from the document
// rather than measured, so `layoutGraph` can reserve exactly this much.
const ports = Math.max(
(definition.requires ?? []).length,
(definition.provides ?? []).length,
)
// Whether a pulse is playing right now; the ring is mounted only while it is.
const [firing, setFiring] = useState(false)
// The count outlives this component: the store is module-level, and a
@@ -186,8 +197,9 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
/>
) : null}
<div
style={vertical ? undefined : { minHeight: nodeHeight(ports) }}
className={cn(
"flow-node-card relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
"flow-node-card relative flex min-w-[168px] max-w-[220px] flex-col justify-center rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
// Whatever is wrong right now is on the card as well as on the dot:
// a click through from the brain has to land on something visible.
// Selection wins the border when it is both — the dot is the status
@@ -214,87 +226,98 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
{typeLabel}
</span>
</span>
{running ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Stop this node"
data-testid="node-cancel"
onClick={(event) => {
event.stopPropagation()
// Best effort by nature: it may well have finished between
// the render and the click, which is the outcome asked for.
FlowsService.cancelNode({
name: flow,
nodeId: definition.id,
}).catch(() => {})
}}
>
<Square />
</Button>
</TooltipTrigger>
<TooltipContent>Stop this node</TooltipContent>
</Tooltip>
) : null}
{/* Each control sits in a slot that is there whether the control
is or not. They come and go with what the node is doing — and a
node running many times a second comes and goes that often — so
in the flex row itself they would resize the card on every
execution, which reads as a flickering shape. */}
<span className="flex size-6 shrink-0 items-center justify-center">
{running ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="nodrag nopan size-6 shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Stop this node"
data-testid="node-cancel"
onClick={(event) => {
event.stopPropagation()
// Best effort by nature: it may well have finished between
// the render and the click, which is the outcome asked for.
FlowsService.cancelNode({
name: flow,
nodeId: definition.id,
}).catch(() => {})
}}
>
<Square />
</Button>
</TooltipTrigger>
<TooltipContent>Stop this node</TooltipContent>
</Tooltip>
) : null}
</span>
{(status === "error" || failedEarlier) && onShowLogs ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
// `nodrag` keeps xyflow from reading the press as a drag; the
// click itself is stopped so the node panel stays closed.
className={cn(
"nodrag nopan -my-1 size-6 shrink-0 hover:text-destructive",
// Red while it is the only thing left saying so, and named
// in words beside it: colour never carries a status alone.
failedEarlier
? "text-destructive"
: "text-muted-foreground",
)}
aria-label={
failedEarlier
? `Failed at ${failedAt} — show the traceback`
: "Show what this node printed"
}
data-testid="node-traceback"
onClick={(event) => {
event.stopPropagation()
onShowLogs(definition.id)
// Reading it is what dismisses it: nothing else does, and a
// marker that never goes away stops meaning anything.
liveStore.acknowledgeFailure(nodeId)
}}
>
<Bug />
</Button>
</TooltipTrigger>
<TooltipContent>
{failedEarlier
? `Failed at ${failedAt} — show the traceback`
: "Show the traceback"}
</TooltipContent>
</Tooltip>
) : null}
<span className="flex size-6 shrink-0 items-center justify-center">
{(status === "error" || failedEarlier) && onShowLogs ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
// `nodrag` keeps xyflow from reading the press as a drag; the
// click itself is stopped so the node panel stays closed.
className={cn(
"nodrag nopan size-6 shrink-0 hover:text-destructive",
// Red while it is the only thing left saying so, and named
// in words beside it: colour never carries a status alone.
failedEarlier
? "text-destructive"
: "text-muted-foreground",
)}
aria-label={
failedEarlier
? `Failed at ${failedAt} — show the traceback`
: "Show what this node printed"
}
data-testid="node-traceback"
onClick={(event) => {
event.stopPropagation()
onShowLogs(definition.id)
// Reading it is what dismisses it: nothing else does, and a
// marker that never goes away stops meaning anything.
liveStore.acknowledgeFailure(nodeId)
}}
>
<Bug />
</Button>
</TooltipTrigger>
<TooltipContent>
{failedEarlier
? `Failed at ${failedAt} — show the traceback`
: "Show the traceback"}
</TooltipContent>
</Tooltip>
) : null}
</span>
{style ? (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className={cn("size-2 shrink-0 rounded-full", style.dot)}
aria-label={style.label}
/>
</TooltipTrigger>
<TooltipContent className="max-h-60 max-w-xs overflow-y-auto whitespace-pre-line break-words">
{problem || style.label}
</TooltipContent>
</Tooltip>
) : null}
<span className="flex size-2 shrink-0 items-center justify-center">
{style ? (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className={cn("size-2 shrink-0 rounded-full", style.dot)}
aria-label={style.label}
/>
</TooltipTrigger>
<TooltipContent className="max-h-60 max-w-xs overflow-y-auto whitespace-pre-line break-words">
{problem || style.label}
</TooltipContent>
</Tooltip>
) : null}
</span>
</div>
<PortHandles
@@ -0,0 +1,50 @@
/**
* A node's height against the ports it has to fit.
*
* Run: `bun src/components/Flow/layout.check.ts` (there is no unit runner; the
* suite in `tests/` drives a running stack). Typechecked with the rest of
* `src` and imported by nothing, so it is not in the bundle.
*/
import assert from "node:assert/strict"
import { layoutGraph, nodeHeight, PORT_SPAN } from "./layout"
/** Handles are 12px across — see `.react-flow__handle` in `flow.css`. */
const HANDLE = 12
// Two ports fit the plain box, so most nodes are unchanged.
assert.equal(nodeHeight(0), 56)
assert.equal(nodeHeight(1), 56)
assert.equal(nodeHeight(2), 56)
assert.ok(nodeHeight(3) > 56)
for (let ports = 2; ports <= 12; ports += 1) {
const height = nodeHeight(ports)
// `handleOffset` spreads the ports over `PORT_SPAN` of the edge, so that is
// the room they actually get. Two handles must not touch.
const between = (PORT_SPAN * height) / (ports - 1)
assert.ok(
between > HANDLE,
`${ports} ports on a ${height}px node leave ${between}px between handles`,
)
// Growing, never shrinking: one more port can only need more room.
assert.ok(height >= nodeHeight(ports - 1))
}
// The layout places top-left corners, so a taller node has to be lifted by its
// own half-height rather than by the default box's.
const tall = nodeHeight(8)
const placed = layoutGraph(
["a", "b"],
[{ source: "a", target: "b" }],
"LR",
new Map([["b", tall]]),
)
const a = placed.get("a")
const b = placed.get("b")
assert.ok(a && b)
// Dagre centres the two on one rank line, so the taller one starts higher up
// by exactly the difference in half-heights.
assert.equal(Math.round(a.y - b.y), Math.round((tall - 56) / 2))
console.log("layout: ok")
+39 -8
View File
@@ -15,8 +15,33 @@ export type Direction = "LR" | "TB"
/** `FlowNode` is `min-w-[168px] max-w-[220px]`; an endpoint is narrower. */
const NODE_W = 220
/** Icon row plus two text lines, as measured. */
/** Icon row plus two text lines, as measured. The floor, not the height. */
const NODE_H = 56
/**
* How much of a node's edge the ports are spread over, as a fraction.
*
* Shared with `FlowNode`'s `handleOffset`, which does the spreading: the
* height below is chosen so that span has room for the ports, so the two
* cannot be allowed to drift apart.
*/
export const PORT_SPAN = 0.6
/**
* Centre to centre between two handles. They are 12px across (`flow.css`), so
* this leaves 6px of rim between them.
*/
const PORT_PITCH = 18
/**
* How tall a node with this many ports on one side has to be.
*
* A pure function of the document — the ports are declared, so the height is
* known before anything is drawn. That is what keeps it out of the measuring
* problem below: nothing is fed back, so there is nothing to oscillate.
*/
export function nodeHeight(ports: number): number {
return Math.max(NODE_H, Math.ceil(((ports - 1) * PORT_PITCH) / PORT_SPAN))
}
/**
* Room for the live value an edge carries (`LiveEdge`'s chip is
* `max-w-[140px]`). Reserved on the edge itself, so dagre routes nodes around
@@ -58,6 +83,7 @@ function build(
edges: { source: string; target: string }[],
direction: Direction,
wrap: Wrap[],
heights: Map<string, number>,
) {
const graph = new dagre.graphlib.Graph()
graph.setDefaultEdgeLabel(() => ({}))
@@ -74,7 +100,7 @@ function build(
// Insertion order is what makes the result deterministic, so it follows the
// document rather than whatever order the edges happen to mention nodes in.
for (const id of ids) {
graph.setNode(id, { width: NODE_W, height: NODE_H })
graph.setNode(id, { width: NODE_W, height: heights.get(id) ?? NODE_H })
}
for (const edge of edges) {
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue
@@ -120,16 +146,21 @@ function wrapWideRanks(graph: ReturnType<typeof build>, ids: string[]): Wrap[] {
/**
* Lay the graph out and return each node's top-left corner.
*
* ponytail: every node is treated as 220×56 rather than measured. Measuring
* would feed the result back into the layout and oscillate; if nodes ever grow
* past that box, take the sizes from `node.measured` once they have settled.
* `heights` is what a node's ports make it, from {@link nodeHeight}; anything
* left out is the plain box. It is read from the document rather than from the
* canvas on purpose — see that function.
*
* ponytail: every node is still treated as 220 wide rather than measured.
* Measuring would feed the result back into the layout and oscillate; if nodes
* ever grow past that, take the width from `node.measured` once it has settled.
*/
export function layoutGraph(
ids: string[],
edges: { source: string; target: string }[],
direction: Direction,
heights: Map<string, number> = new Map(),
): Map<string, { x: number; y: number }> {
let graph = build(ids, edges, direction, [])
let graph = build(ids, edges, direction, [], heights)
// Running downwards, a rank wider than the screen is the one thing the
// layout can still do something about. Wrapping one rank pushes whatever was
@@ -142,7 +173,7 @@ export function layoutGraph(
const more = wrapWideRanks(graph, ids)
if (!more.length) break
wrap.push(...more)
graph = build(ids, edges, direction, wrap)
graph = build(ids, edges, direction, wrap, heights)
}
}
@@ -153,7 +184,7 @@ export function layoutGraph(
return [
id,
node
? { x: node.x - NODE_W / 2, y: node.y - NODE_H / 2 }
? { x: node.x - NODE_W / 2, y: node.y - node.height / 2 }
: { x: 0, y: 0 },
]
}),