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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user