"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, 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, summarizeNode, type BuilderNode, type NodeType, } from "./shared"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, 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. // ============================================================ function FlowNodeCard({ data, selected }: NodeProps) { const { node, isEntry, isFlashed } = data as NodeData; const meta = NODE_META[node.node_type]; const summary = summarizeNode(node); const Icon = meta.icon; 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, labelStyle: { fill: "#cbd5e1", fontSize: 11 }, labelBgStyle: { fill: "#0f172a" }, labelBgPadding: [4, 2] as [number, number], labelBgBorderRadius: 4, style: { stroke: "#475569", 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 ( <>
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 Icon = meta.icon; return ( !v && onClose()}> {meta.label} {isEntry && ( Entry )} {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 {ADD_NODE_TYPES.map((t) => { const meta = NODE_META[t]; const Icon = meta.icon; return ( handleAdd(t)}> {meta.label} ); })} ); }