'use client'; /** * Canvas / mind-map view of a flow. Editable, in parity with the * list view for everything except the trigger / header / fallback * panels (those are list-only — they don't fit visually inside a * node graph and the user can switch to List for them). * * What this view does: * - Renders every flow_node as a draggable tile, pan + zoom + * minimap. Drag positions persist via the editor context * (writing on dragStop, not every frame). * - Renders edges between nodes, labeled per slot (button title, * "true" / "false", list row title) so a branching flow reads * as a real decision tree. * - Click a node → side-sheet opens with the same per-node form * the list view uses, plus "Set as entry" / "Delete". * - Drag from a source handle on one node to a target handle on * another → wires that slot's `next_node_key`. Per-slot handles * for multi-outgoing types (condition, send_buttons, send_list) * so the user picks which branch they're wiring. * - Backspace / Delete on a selected node → removes it AND clears * every inbound `next_node_key` reference (no dangling arrows). * - Delete on a selected edge → clears just that slot. * - "+ Add node" floating button drops a new node at the visible * viewport center. * - Runs dagre auto-layout once on mount for flows whose * `position_x` / `position_y` are all zero (pre-canvas flows * and brand-new flows) — otherwise everything would pile at * the origin. * * The toggle in `flow-editor-shell.tsx` swaps this in for * `` on the same page. Both views share the same * `BuilderState` via `useFlowEditor()` — toggling never resets * unsaved edits, and a drag here updates the same nodes array the * list view reads. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { applyNodeChanges, Background, BackgroundVariant, Controls, Handle, MiniMap, Panel, Position, ReactFlow, ReactFlowProvider, useReactFlow, type Connection, type Node as RfNode, type Edge as RfEdge, type NodeChange, type NodeProps, type OnNodeDrag, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { Plus, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from '@/components/ui/sheet'; import { applyEdgeConnection, deriveCanvasEdges, outgoingSlots, } from '@/lib/flows/edges'; import { autoLayout, shouldAutoLayout } from '@/lib/flows/layout'; import { NODE_META, NodeIconChip, groupNodeTypesByCategory, nodeColors, summarizeNode, type BuilderNode, type NodeType, } from './shared'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useFlowEditor } from './flow-editor-state'; import { NodeConfigForm } from './forms/node-config-form'; // React-Flow node `data` payload — the bits our custom renderer needs. interface NodeData extends Record { node: BuilderNode; isEntry: boolean; /** Validator's "look here" pulse — flashes the card border for * ~1.6s. Drives a CSS animation, doesn't change layout. */ isFlashed: boolean; } const NODE_WIDTH = 240; // Best-effort default; actual height varies by summary length but // dagre needs SOMETHING to compute rank spacing. Underestimating is // safer than over (tighter layout that still doesn't overlap). const NODE_HEIGHT = 90; // ============================================================ // Custom node — one card per flow node, styled to match the list // view's collapsed card so the two views feel like the same product. // ============================================================ // Echo the design's green/red branch ports for the condition node's // true / false slots; every other slot inherits the node's own hue. // The true/false hues are derived from the start (emerald) and handoff // (rose) node colors so all branch/port colors stay single-sourced in // NODE_HUE — a palette tweak there can't leave these stale. function slotColor(nodeType: NodeType, slotId: string, fallback: string) { if (nodeType === 'condition' && slotId === 'true') { return nodeColors('start').solid; } if (nodeType === 'condition' && slotId === 'false') { return nodeColors('handoff').solid; } return fallback; } function FlowNodeCard({ data, selected }: NodeProps) { const { node, isEntry, isFlashed } = data as NodeData; const meta = NODE_META[node.node_type]; const c = nodeColors(node.node_type); const summary = summarizeNode(node); const slots = outgoingSlots(node); // Start nodes are entry-only; nothing ever targets them, so they // don't need an incoming Handle. Every other node type accepts // incoming edges (including terminal handoff / end — they're the // common targets). const hasTarget = node.node_type !== 'start'; // Single-slot nodes get a single source handle floated on the right // edge of the card. Multi-slot nodes (condition, send_buttons, // send_list) render slot rows inline so each handle visually sits // next to the slot it represents. const isMultiSlot = slots.length > 1; return (
{hasTarget && ( )}
{meta.label} {isEntry && ( Entry )}
{node.node_key}
{summary && (
{summary}
)} {isMultiSlot && (
{slots.map((slot) => (
{slot.label}
))}
)} {!isMultiSlot && slots.length === 1 && ( )}
); } const NODE_TYPES = { flow: FlowNodeCard }; // ============================================================ // Root canvas // ============================================================ /** * Outer wrapper provides the React-Flow context to the inner body, * so `useReactFlow()` works from anywhere in `FlowCanvasInner` * (notably, the pan-to-flash effect). The split is required because * useReactFlow() must be called inside a ReactFlowProvider. */ export function FlowCanvas() { return ( ); } function FlowCanvasInner() { const { state, setState, updateNodeConfig, updateNodePosition, updateNodePositions, removeNode, flashKey, } = useFlowEditor(); const reactFlow = useReactFlow(); const builderNodes = state.nodes; const entryNodeId = state.entry_node_id; // Side-panel state — which node's form is open. Canvas-only UI; the // list view's analogue is the per-card expanded set in // flow-builder.tsx. const [selectedNodeKey, setSelectedNodeKey] = useState(null); const selectedNode = useMemo( () => selectedNodeKey ? (builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null) : null, [selectedNodeKey, builderNodes] ); const autoLayoutPositions = useMemo(() => { const canvasEdges = deriveCanvasEdges(builderNodes); return shouldAutoLayout(builderNodes) ? autoLayout( builderNodes.map((n) => ({ id: n.node_key, width: NODE_WIDTH, height: NODE_HEIGHT, })), canvasEdges.map((e) => ({ source: e.source, target: e.target })), { direction: 'TB' } ) : null; }, [builderNodes]); // If dagre had to place an all-zero flow, persist the generated // positions into editor state once. Otherwise the next drag would // save only the dragged node and every other node would fall back // to (0,0), which feels like nodes teleporting around the canvas. const persistedAutoLayoutRef = useRef(false); useEffect(() => { if (!autoLayoutPositions || persistedAutoLayoutRef.current) return; persistedAutoLayoutRef.current = true; updateNodePositions( Object.fromEntries( [...autoLayoutPositions].map(([key, pos]) => [key, pos]) ) ); }, [autoLayoutPositions, updateNodePositions]); const derivedRfNodes = useMemo(() => { const nodes: RfNode[] = builderNodes.map((n) => { const fallback = autoLayoutPositions?.get(n.node_key); return { id: n.node_key, type: 'flow', position: { x: fallback?.x ?? n.position_x ?? 0, y: fallback?.y ?? n.position_y ?? 0, }, data: { node: n, isEntry: n.node_key === entryNodeId, isFlashed: n.node_key === flashKey, }, }; }); return nodes; }, [builderNodes, entryNodeId, flashKey, autoLayoutPositions]); const [rfNodes, setRfNodes] = useState[]>(derivedRfNodes); useEffect(() => { setRfNodes(derivedRfNodes); }, [derivedRfNodes]); const rfEdges = useMemo(() => { const canvasEdges = deriveCanvasEdges(builderNodes); // sourceHandle is now wired up — the FlowNodeCard renders a Handle // per slot whose id matches the scheme in edges.ts, so React-Flow // can hang the arrow off the right place on each card. const rfEdges: RfEdge[] = canvasEdges.map((e) => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, label: e.label, // Mode-aware via CSS tokens so edge chrome flips with light/dark. labelStyle: { fill: 'var(--muted-foreground)', fontSize: 11 }, labelBgStyle: { fill: 'var(--card)' }, labelBgPadding: [4, 2] as [number, number], labelBgBorderRadius: 4, style: { stroke: 'var(--border)', strokeWidth: 1.5 }, })); return rfEdges; }, [builderNodes]); const handleNodesChange = useCallback( (changes: NodeChange>[]) => { setRfNodes((nodes) => applyNodeChanges(changes, nodes)); }, [] ); // Drag-to-position: React-Flow tracks the visual drag internally and // fires this once on release. We write the final coordinate back to // the editor context (which flips `dirty`); save then ships the new // positions in the existing PUT /api/flows/[id] body (the route // already destructures position_x / position_y per migration 010). // Writing only on dragStop (not on every position-change tick during // the drag) keeps state updates cheap on long drags. const handleNodeDragStop = useCallback>>( (_event, node) => { updateNodePosition(node.id, node.position.x, node.position.y); }, [updateNodePosition] ); // Pan to the flashed node when the validator panel requests one. // Animate over 400ms; landing zoom is whatever the user already has // (don't force a zoom reset — that would be jarring mid-edit). useEffect(() => { if (!flashKey) return; const node = builderNodes.find((n) => n.node_key === flashKey); if (!node) return; const x = (node.position_x ?? 0) + NODE_WIDTH / 2; const y = (node.position_y ?? 0) + NODE_HEIGHT / 2; reactFlow.setCenter(x, y, { zoom: reactFlow.getZoom(), duration: 400, }); }, [flashKey, builderNodes, reactFlow]); const handleNodeClick = useCallback( (_event: React.MouseEvent, node: RfNode) => { setSelectedNodeKey(node.id); }, [] ); // Drag-to-connect: React-Flow fires onConnect when the user drops a // handle drag onto a target handle. We look up the source node, // compute the right config patch via applyEdgeConnection (matches // the same slot scheme as deriveCanvasEdges), and dispatch via // updateNodeConfig. The resulting state change re-derives edges on // the next render — no need to maintain a separate edge list. const handleConnect = useCallback( (connection: Connection) => { if ( !connection.source || !connection.target || !connection.sourceHandle ) { return; } const sourceNode = builderNodes.find( (n) => n.node_key === connection.source ); if (!sourceNode) return; // Self-loops are a footgun (a button whose target is its own // node = infinite reprompt). Reject silently — the user can // still wire one via the per-node dropdown if they really want. if (connection.source === connection.target) return; const patch = applyEdgeConnection( sourceNode, connection.sourceHandle, connection.target ); if (patch) updateNodeConfig(connection.source, patch); }, [builderNodes, updateNodeConfig] ); // Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow // fires this with the set of deleted-node objects; we route each // through the editor context's removeNode (which now also unlinks // inbound references so no dangling arrows survive). Closing the // side panel on delete keeps the UI honest if the user deleted the // node currently being edited. const handleNodesDelete = useCallback( (deleted: RfNode[]) => { for (const n of deleted) { removeNode(n.id); if (selectedNodeKey === n.id) setSelectedNodeKey(null); } }, [removeNode, selectedNodeKey] ); // Edge delete: clear the source node's slot rather than removing // anything. Edges are derived from configs, so the only way to // "delete" one is to null out its underlying next_node_key. const handleEdgesDelete = useCallback( (deleted: RfEdge[]) => { for (const e of deleted) { if (!e.sourceHandle) continue; const sourceNode = builderNodes.find((n) => n.node_key === e.source); if (!sourceNode) continue; const patch = applyEdgeConnection(sourceNode, e.sourceHandle, ''); if (patch) updateNodeConfig(e.source, patch); } }, [builderNodes, updateNodeConfig] ); // Wrapped mutators that target the currently-selected node — pass to // the form so each keystroke goes through the editor context (which // flips `dirty` and feeds the validator). const onSelectedUpdateConfig = useCallback( (patch: Record) => { if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch); }, [selectedNodeKey, updateNodeConfig] ); const handleDeleteSelected = useCallback(() => { if (!selectedNodeKey) return; removeNode(selectedNodeKey); setSelectedNodeKey(null); }, [selectedNodeKey, removeNode]); const handleSetEntry = useCallback(() => { if (!selectedNodeKey) return; setState((s) => ({ ...s, entry_node_id: selectedNodeKey })); }, [selectedNodeKey, setState]); if (rfNodes.length === 0) { return (

No nodes yet.

); } return ( <>
{/* Dot grid, matching the design's faint canvas backdrop. */} nodeColors((n.data as NodeData).node.node_type).solid } nodeStrokeWidth={0} nodeBorderRadius={3} maskColor="color-mix(in oklch, var(--background) 70%, transparent)" className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]" />
setSelectedNodeKey(null)} onUpdateConfig={onSelectedUpdateConfig} onDelete={handleDeleteSelected} onSetEntry={handleSetEntry} /> ); } // ============================================================ // Side panel — opens when a canvas node is clicked. Mounts the // shared NodeConfigForm dispatcher so edits made here behave // identically to the list view's per-card editor. // ============================================================ function NodeEditSheet({ node, isEntry, allNodes, onClose, onUpdateConfig, onDelete, onSetEntry, }: { node: BuilderNode | null; isEntry: boolean; allNodes: BuilderNode[]; onClose: () => void; onUpdateConfig: (patch: Record) => void; onDelete: () => void; onSetEntry: () => void; }) { // Sheet is controlled — opens when a node is selected, closes via // Esc / overlay / close button (all delegated to onClose). const open = node !== null; if (!node) { return ( !v && onClose()}> ); } const meta = NODE_META[node.node_type]; const c = nodeColors(node.node_type); return ( !v && onClose()}>
{meta.label} {isEntry && ( Entry )} {meta.blurb}
{node.node_key}
{!isEntry ? ( ) : ( )}
); } // ============================================================ // Floating add-node button — bottom-right of the canvas. Mirrors // the list view's AddNodeButton (same dropdown menu, same NodeType // list, same icons via NODE_META) but drops the new node into the // center of the visible viewport rather than appending to a list. // ============================================================ const ADD_NODE_TYPES: NodeType[] = [ 'start', 'send_buttons', 'send_list', 'send_message', 'send_media', 'collect_input', 'condition', 'set_tag', 'handoff', 'end', ]; function CanvasAddNodeButton() { const reactFlow = useReactFlow(); const { addNode, updateNodePosition } = useFlowEditor(); const handleAdd = (type: NodeType) => { const key = addNode(type); // Place the new node at the visible canvas center. The Panel's // own DOM lives inside ReactFlow so we can climb up to find the // .react-flow root and read its bounding rect. If we can't find // it (test envs, etc.), addNode's default (0, 0) is the fallback // and the user can drag the node into view. const root = document.querySelector('.react-flow') as HTMLElement | null; if (!root) return; const rect = root.getBoundingClientRect(); const center = reactFlow.screenToFlowPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }); // NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset // so the card sits visually centered rather than top-left at the // viewport center. updateNodePosition( key, center.x - NODE_WIDTH / 2, center.y - NODE_HEIGHT / 2 ); }; return ( Add node {groupNodeTypesByCategory(ADD_NODE_TYPES).map((group, i) => (
{i > 0 && } {group.label} {group.types.map((t) => { const meta = NODE_META[t]; return ( handleAdd(t)} className="gap-3 py-2" > {meta.label} {meta.blurb} ); })}
))}
); }