"use client"; /* Workflows list + the React Flow canvas editor (palette · typed nodes · inspector). */ import "@xyflow/react/dist/style.css"; import { addEdge, applyEdgeChanges, applyNodeChanges, Background, BackgroundVariant, Controls, MiniMap, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, useReactFlow, ViewportPortal, type Connection, type Edge, type EdgeChange, type NodeChange, } from "@xyflow/react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Icon } from "../icons"; import { StatusPill, Tile } from "../primitives"; import { ForgeNode, NodeTypesContext } from "../canvas/ForgeNode"; import { AgentConfig, CollapsibleSection } from "../canvas/AgentConfig"; import { EdgeOverlay } from "../canvas/EdgeOverlay"; import { WorkflowTestPanel } from "../canvas/WorkflowTestPanel"; import { FieldsForm, ModelSelect, MultiSelectChips, type FieldSpec } from "../canvas/ConfigForm"; import { Field, Toggle } from "../primitives"; import { VersionHistory } from "../version-history"; import { ImportExport } from "../import-export"; import type { ComponentT } from "@/lib/api"; import { api, openSSE, Agent, McpClientT, NodeType, Tool, ToolSet, Workflow } from "@/lib/api"; import { NODE_META, NODE_HELP, IO_COLOR, fmtUSD } from "@/lib/data"; import { canvasToExecutable, canvasToFlow, ioCompatible, newNodeId, starterWorkflow, type FlowEdge, type FlowNode } from "@/lib/graph"; function stripRunData(nds: FlowNode[]): FlowNode[] { return nds.map((n) => { const { status, debug, ...data } = n.data; return { ...n, data }; }); } function EditableName({ value, fallback, className, inputClassName = "input", style, inputStyle, onCommit, }: { value?: string | null; fallback: string; className?: string; inputClassName?: string; style?: CSSProperties; inputStyle?: CSSProperties; onCommit: (name: string) => void | Promise; }) { const display = (value || "").trim() || fallback; const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(display); useEffect(() => { if (!editing) setDraft(display); }, [display, editing]); const finish = () => { const next = draft.trim(); setEditing(false); if (!next || next === display) { setDraft(display); return; } void onCommit(next); }; if (editing) { return ( setDraft(e.target.value)} onBlur={finish} onFocus={(e) => e.currentTarget.select()} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); if (e.key === "Escape") { setDraft(display); setEditing(false); } }} style={{ minWidth: 0, ...inputStyle }} /> ); } return ( ); } /* ============ WORKFLOWS LIST ============ */ export function WorkflowsScreen({ project, onOpen }: { project: any; onOpen: (w: Workflow) => void }) { const [wfs, setWfs] = useState([]); const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(false); const reload = useCallback(() => { if (!project?.id) return; api.listWorkflows(project.id).then((w) => { setWfs(w); setLoaded(true); }).catch(() => setLoaded(true)); }, [project?.id]); useEffect(() => { reload(); }, [reload]); async function create() { setBusy(true); try { const s = starterWorkflow(); const executable = canvasToExecutable(s.nodes, s.edges, { id: "wf" }); const wf = await api.createWorkflow(project.id, { name: "Untitled workflow", canvas: s.canvas, executable }); onOpen(wf); } finally { setBusy(false); } } const [deleting, setDeleting] = useState(null); async function del(e: React.MouseEvent, w: Workflow) { e.stopPropagation(); if (!window.confirm(`Delete workflow "${w.name}"?\n\nThis also removes its run history and traces. This cannot be undone.`)) return; setDeleting(w.id); try { setWfs((prev) => prev.filter((x) => x.id !== w.id)); // optimistic await api.deleteWorkflow(project.id, w.id); } catch { reload(); // restore on failure } finally { setDeleting(null); } } async function renameWorkflow(w: Workflow, name: string) { if (!project?.id || name === w.name) return; const previous = wfs; setWfs((prev) => prev.map((x) => (x.id === w.id ? { ...x, name } : x))); try { const updated = await api.updateWorkflow(project.id, w.id, { name }); setWfs((prev) => prev.map((x) => (x.id === updated.id ? updated : x))); } catch { setWfs(previous); } } return (
Workflows
Visual graphs compiled to LangGraph. Build agents, routers, tools, and HITL on the canvas.
({ id: w.id, name: w.name || "Untitled workflow", sub: `${(w.executable?.nodes || []).length} nodes` }))} />
{loaded && wfs.length === 0 ? (
No workflows yet
Create your first workflow and wire nodes on the canvas.
) : (
{wfs.map((w) => (
onOpen(w)}>
renameWorkflow(w, name)} />
{(w.executable?.nodes || []).length} nodes · v{w.active_version}
))}
)}
); } /* ============ CANVAS ============ */ export function guardCanvasBeforeUnload(dirty: boolean, event: BeforeUnloadEvent): boolean { if (!dirty) return false; event.preventDefault(); event.returnValue = ""; return true; } export function WorkflowCanvas(props: { project: any; workflowId?: string; onWorkflowChange?: (workflow: Workflow) => void; onBack: () => void; onRun: () => void; onRegisterFlush?: (fn: (() => Promise) | null) => void }) { return ( ); } function CanvasInner({ project, workflowId, onWorkflowChange, onBack, onRun, onRegisterFlush }: { project: any; workflowId?: string; onWorkflowChange?: (workflow: Workflow) => void; onBack: () => void; onRun: () => void; onRegisterFlush?: (fn: (() => Promise) | null) => void }) { const [wf, setWf] = useState(null); const [registry, setRegistry] = useState>({}); const [tools, setTools] = useState([]); const [toolSets, setToolSets] = useState([]); // Saved agent presets (from the Agents tab) - loadable into an agent node. const [agents, setAgents] = useState([]); const [mcpServers, setMcpServers] = useState([]); const [components, setComponents] = useState([]); // Live KB folders + Q&A kinds drive the dropdowns in the retrieval node config. const [kbFolders, setKbFolders] = useState([]); const [qaKinds, setQaKinds] = useState([]); const [nodes, setNodes] = useNodesState([]); const [edges, setEdges] = useEdgesState([]); const [selId, setSelId] = useState(null); const [problems, setProblems] = useState<{ pointer: string; message: string }[]>([]); const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "invalid">("idle"); const [running, setRunning] = useState(false); const [testOpen, setTestOpen] = useState(false); const [showProblems, setShowProblems] = useState(false); const [reloadKey, setReloadKey] = useState(0); // Unsaved-changes tracking (drives the beforeunload guard + auto-save on navigate-away). const [dirty, setDirty] = useState(false); const dirtyRef = useRef(false); // Undo/redo stacks of {nodes, edges} snapshots. Structural actions push; undo/redo pop. const [past, setPast] = useState<{ nodes: FlowNode[]; edges: FlowEdge[] }[]>([]); const [future, setFuture] = useState<{ nodes: FlowNode[]; edges: FlowEdge[] }[]>([]); // Clipboard for copy/paste/duplicate of a single node. const clipboardRef = useRef<{ nodeType: string; config: Record } | null>(null); // Transient, non-blocking connection-type mismatch hint (edges stay permissive). const [connWarning, setConnWarning] = useState(null); const [paletteQuery, setPaletteQuery] = useState(""); // Hover help for palette items - rendered as a fixed-position card so the palette's // own scroll container can't clip it. Positioned from the hovered item's rect (the // palette's viewport offset varies with the rail/sidebar, so no hardcoded left). const [paletteTip, setPaletteTip] = useState<{ type: string; top: number; left: number } | null>(null); const { fitView } = useReactFlow(); const registryReady = Object.keys(registry).length > 0; useEffect(() => { api.listNodeTypes().then((nt) => setRegistry(Object.fromEntries(nt.map((n) => [n.type, n])))).catch(() => {}); if (project?.id) { api.listTools(project.id).then(setTools).catch(() => {}); api.listToolSets(project.id).then(setToolSets).catch(() => {}); api.listAgents(project.id).then(setAgents).catch(() => {}); api.listMcpClients(project.id).then(setMcpServers).catch(() => {}); api.listComponents(project.id).then(setComponents).catch(() => {}); api.listFolders(project.id).then(setKbFolders).catch(() => {}); api.listQaKinds(project.id).then(setQaKinds).catch(() => {}); } }, [project?.id]); useEffect(() => { if (!project?.id || !workflowId) return; api.getWorkflow(project.id, workflowId).then((w) => { setWf(w); const flow = w.canvas?.nodes?.length ? canvasToFlow(w.canvas) : starterWorkflow(); setNodes(flow.nodes); setEdges(flow.edges as any); setTimeout(() => fitView({ padding: 0.2, duration: 300 }), 90); }).catch(() => {}); }, [project?.id, workflowId, reloadKey, setNodes, setEdges, fitView]); const nodeTypes = useMemo(() => ({ forge: ForgeNode }), []); const selected = nodes.find((n) => n.id === selId) || null; // ---- Canvas UX: dirty tracking, undo/redo, copy/paste/duplicate ---- // Refs mirror the latest graph so the history/clipboard callbacks stay stable (no dep churn) // yet always operate on current state; calling snapshot() inside an action captures the // pre-action graph (refs update after render). const nodesRef = useRef(nodes); const edgesRef = useRef(edges); useEffect(() => { nodesRef.current = nodes; }, [nodes]); useEffect(() => { edgesRef.current = edges; }, [edges]); useEffect(() => { dirtyRef.current = dirty; }, [dirty]); // A node wired as a deep_agent sub-agent (target of a "subagents" edge) is a folded specialist, // not a flow node - flag it so ForgeNode hides its now-meaningless in/out flow handles. const subagentIds = useMemo( () => new Set(edges.filter((e) => e.sourceHandle === "subagents").map((e) => e.target)), [edges], ); const flowNodes = useMemo( () => (subagentIds.size === 0 ? nodes : nodes.map((n) => (subagentIds.has(n.id) ? { ...n, data: { ...n.data, isSubagent: true } } : n))), [nodes, subagentIds], ); // Warn before closing/reloading the tab with unsaved edits. useEffect(() => { const h = (e: BeforeUnloadEvent) => { guardCanvasBeforeUnload(dirtyRef.current, e); }; window.addEventListener("beforeunload", h); return () => window.removeEventListener("beforeunload", h); }, []); const snapshot = useCallback(() => { setPast((p) => [...p.slice(-49), { nodes: nodesRef.current, edges: edgesRef.current }]); setFuture([]); }, []); const undo = useCallback(() => { setPast((p) => { if (!p.length) return p; const prev = p[p.length - 1]; setFuture((f) => [{ nodes: nodesRef.current, edges: edgesRef.current }, ...f].slice(0, 50)); setNodes(prev.nodes); setEdges(prev.edges as any); setSelId(null); setDirty(true); return p.slice(0, -1); }); }, [setNodes, setEdges]); const redo = useCallback(() => { setFuture((f) => { if (!f.length) return f; const next = f[0]; setPast((p) => [...p.slice(-49), { nodes: nodesRef.current, edges: edgesRef.current }]); setNodes(next.nodes); setEdges(next.edges as any); setSelId(null); setDirty(true); return f.slice(1); }); }, [setNodes, setEdges]); const copyNode = useCallback(() => { const n = nodesRef.current.find((x) => x.id === selId); if (n) clipboardRef.current = { nodeType: n.data.nodeType, config: JSON.parse(JSON.stringify(n.data.config || {})) }; }, [selId]); const pasteNode = useCallback(() => { const clip = clipboardRef.current; if (!clip) return; snapshot(); const cur = nodesRef.current; const id = newNodeId(clip.nodeType, cur.map((n) => n.id)); const base = cur.find((n) => n.id === selId); const pos = base ? { x: base.position.x + 44, y: base.position.y + 44 } : { x: 360 + (cur.length % 4) * 36, y: 160 + (cur.length % 4) * 36 }; setNodes((nds) => [...nds, { id, type: "forge", position: pos, data: { nodeType: clip.nodeType, config: JSON.parse(JSON.stringify(clip.config)) } }]); setSelId(id); setDirty(true); }, [selId, setNodes, snapshot]); const duplicateNode = useCallback(() => { const n = nodesRef.current.find((x) => x.id === selId); if (!n) return; clipboardRef.current = { nodeType: n.data.nodeType, config: JSON.parse(JSON.stringify(n.data.config || {})) }; pasteNode(); }, [selId, pasteNode]); // Keyboard: Ctrl/Cmd+Z undo · Ctrl/Cmd+Shift+Z (or Ctrl+Y) redo · Ctrl+C/V copy/paste · // Ctrl+D duplicate. Ignored while typing in a form field so text editing keeps its own undo. useEffect(() => { const isEditable = (el: EventTarget | null) => { const t = el as HTMLElement | null; if (!t) return false; const tag = t.tagName; return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || !!t.isContentEditable; }; const h = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || isEditable(e.target)) return; const k = e.key.toLowerCase(); if (k === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); } else if (k === "y") { e.preventDefault(); redo(); } else if (k === "c") { copyNode(); } else if (k === "v") { pasteNode(); } else if (k === "d") { e.preventDefault(); duplicateNode(); } }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [undo, redo, copyNode, pasteNode, duplicateNode]); // LangGraph edges are control flow (data moves via shared state), so connections // are permissive: any output -> any input, just not a node to itself. Handle colors // remain as a visual hint of the dominant data type. const isValidConnection = useCallback( (c: Connection | Edge) => { if (!c.source || !c.target || c.source === c.target) return false; // A sub-agent edge (deep_agent's bottom handle) may only connect to an agent it can call. if (c.sourceHandle === "subagents") { const tgt = nodes.find((n) => n.id === c.target)?.data.nodeType; return tgt === "agent" || tgt === "deep_agent"; } return true; }, [nodes], ); const stripRouterTargets = useCallback((nds: FlowNode[], removedIds: Set): FlowNode[] => { if (!removedIds.size) return nds; return nds.map((n) => { if (n.data.nodeType !== "router") return n; const cfg = { ...(n.data.config || {}) }; const cases = Object.fromEntries( Object.entries(cfg.cases || {}).map(([key, target]) => [key, removedIds.has(String(target)) ? "" : target]), ); if (removedIds.has(String(cfg.default))) cfg.default = ""; return { ...n, data: { ...n.data, config: { ...cfg, cases } } }; }); }, []); const onNodesChangeSynced = useCallback((changes: NodeChange[]) => { const removedIds = new Set( changes.filter((c) => c.type === "remove").map((c) => c.id), ); // Snapshot before a keyboard (Backspace/Delete) removal so it can be undone. if (removedIds.size) snapshot(); // Ignore pure selection/measurement churn so the canvas doesn't read as dirty on load. if (changes.some((c) => c.type !== "select" && c.type !== "dimensions")) setDirty(true); setNodes((nds) => stripRouterTargets(applyNodeChanges(changes, nds) as FlowNode[], removedIds)); }, [setNodes, stripRouterTargets, snapshot]); const syncRouterCaseEdge = useCallback((nodeId: string, key: string, target: string) => { const handle = `case:${key}`; setEdges((eds) => { const rest = eds.filter((e) => !(e.source === nodeId && e.sourceHandle === handle)); if (!target) return rest; return [ ...rest, { id: `e-${nodeId}-${key}-${target}`, source: nodeId, target, sourceHandle: handle, style: { stroke: IO_COLOR.control || "var(--io-control)", strokeWidth: 2 }, } as FlowEdge, ]; }); }, [setEdges]); const renameRouterCaseEdges = useCallback((nodeId: string, oldKey: string, newKey: string) => { setEdges((eds) => eds.map((e) => ( e.source === nodeId && e.sourceHandle === `case:${oldKey}` ? { ...e, sourceHandle: `case:${newKey}`, id: `e-${nodeId}-${newKey}-${e.target}` } : e ))); }, [setEdges]); const removeRouterCaseEdges = useCallback((nodeId: string, key: string) => { setEdges((eds) => eds.filter((e) => !(e.source === nodeId && e.sourceHandle === `case:${key}`))); }, [setEdges]); const routerKeyForEdge = useCallback((cfg: Record, edge: FlowEdge): string | undefined => { if (edge.sourceHandle?.startsWith("case:")) return edge.sourceHandle.slice(5); const match = Object.entries(cfg.cases || {}).find(([, target]) => target === edge.target); if (match) return match[0]; if (cfg.default === edge.target) return "__default__"; return undefined; }, []); const clearRouterTargetsForRemovedEdges = useCallback((removed: FlowEdge[]) => { if (!removed.length) return; setNodes((nds) => nds.map((n) => { if (n.data.nodeType !== "router") return n; const relevant = removed.filter((e) => e.source === n.id); if (!relevant.length) return n; const cfg = { ...(n.data.config || {}) }; let cases = { ...(cfg.cases || {}) }; let changed = false; for (const edge of relevant) { const key = routerKeyForEdge(cfg, edge); if (!key) continue; if (key === "__default__") { if (cfg.default) { cfg.default = ""; changed = true; } } else if (Object.prototype.hasOwnProperty.call(cases, key) && cases[key]) { cases = { ...cases, [key]: "" }; changed = true; } } return changed ? { ...n, data: { ...n.data, config: { ...cfg, cases } } } : n; })); }, [routerKeyForEdge, setNodes]); const onEdgesChangeSynced = useCallback((changes: EdgeChange[]) => { const removedIds = new Set(changes.filter((c) => c.type === "remove").map((c) => c.id)); if (removedIds.size) { snapshot(); clearRouterTargetsForRemovedEdges(edges.filter((e) => removedIds.has(e.id))); } if (changes.some((c) => c.type !== "select")) setDirty(true); setEdges((eds) => applyEdgeChanges(changes, eds) as FlowEdge[]); }, [clearRouterTargetsForRemovedEdges, edges, setEdges, snapshot]); const removeEdge = useCallback((edge: FlowEdge) => { snapshot(); setDirty(true); clearRouterTargetsForRemovedEdges([edge]); setEdges((eds) => eds.filter((e) => e.id !== edge.id)); }, [clearRouterTargetsForRemovedEdges, setEdges, snapshot]); const onConnect = useCallback((params: Connection) => { if (!isValidConnection(params)) return; snapshot(); setDirty(true); const sn = nodes.find((n) => n.id === params.source); const fromRouterCase = !!params.sourceHandle?.startsWith("case:") && sn?.data.nodeType === "router"; // Dragging from a router's case row wires that case to the target node - the canvas // edge is the picture, config.cases/default is what the compiler routes on. if (params.sourceHandle?.startsWith("case:") && sn?.data.nodeType === "router") { const key = params.sourceHandle.slice(5); setNodes((nds) => nds.map((n) => { if (n.id !== params.source) return n; const cfg = { ...(n.data.config || {}) }; if (key === "__default__") cfg.default = params.target; else cfg.cases = { ...(cfg.cases || {}), [key]: params.target }; return { ...n, data: { ...n.data, config: cfg } }; })); } const sp = registry[sn?.data.nodeType || ""]?.output_ports || []; const io = (sp.find((p) => p.id === params.sourceHandle) || sp[0])?.io_type || "any"; // Non-blocking type hint: LangGraph edges are control flow (data moves via shared state), // so a mismatch is allowed - but surface it so the user knows the port types differ. const tn = nodes.find((n) => n.id === params.target); const tp = registry[tn?.data.nodeType || ""]?.input_ports || []; const tio = (tp.find((p) => p.id === params.targetHandle) || tp[0])?.io_type || "any"; if (!ioCompatible(io, tio)) { setConnWarning(`Connected ${io} → ${tio}: the port types differ. It still works (data flows through shared state), just double-check this is intended.`); window.setTimeout(() => setConnWarning(null), 6000); } setEdges((eds) => addEdge( { ...params, style: { stroke: IO_COLOR[io] || "var(--io-any)", strokeWidth: 2 } }, fromRouterCase ? eds.filter((e) => !(e.source === params.source && e.sourceHandle === params.sourceHandle)) : eds, )); }, [isValidConnection, nodes, registry, setEdges, setNodes, snapshot]); const addNode = useCallback((type: string) => { snapshot(); setDirty(true); const id = newNodeId(type, nodes.map((n) => n.id)); const n = nodes.length; const defConfig: Record = type === "agent" || type === "deep_agent" ? { flavor: type === "deep_agent" ? "deep_agent" : "agent", model: "openai:gpt-4o-mini", middleware: [], tools: [] } : type === "router" ? { expression: "intent", cases: {}, default: "" } : type === "llm" ? { model: "openai:gpt-4o-mini", prompt: "" } : type === "retrieval" ? { top_k: 4, include_docs: true, hybrid: false, rerank: false, include_qa: true, qa_threshold: 0.3, qa_top_k: 3, min_score: 0.18, announce_empty: true } : type === "classifier" ? { labels: ["question", "request", "complaint"], output_key: "intent" } : {}; setNodes((nds) => [...nds, { id, type: "forge", position: { x: 360 + (n % 4) * 36, y: 140 + (n % 4) * 36 }, data: { nodeType: type, config: defConfig } }]); setSelId(id); }, [nodes, setNodes, snapshot]); const updateConfig = useCallback((cfg: Record) => { if (!selId) return; setDirty(true); setNodes((nds) => nds.map((n) => (n.id === selId ? { ...n, data: { ...n.data, config: cfg } } : n))); }, [selId, setNodes]); const deleteNode = useCallback((id: string) => { snapshot(); setDirty(true); setNodes((nds) => stripRouterTargets(nds.filter((n) => n.id !== id), new Set([id]))); setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id)); setSelId((cur) => (cur === id ? null : cur)); }, [setNodes, setEdges, stripRouterTargets, snapshot]); const saveCanvasState = useCallback(async (nextNodes: FlowNode[], nextEdges: FlowEdge[]) => { if (!wf) return; setSaveState("saving"); const persistNodes = stripRunData(nextNodes); const executable = canvasToExecutable(persistNodes, nextEdges, { id: wf.id, version: wf.active_version }); const canvas = { nodes: persistNodes, edges: nextEdges, viewport: { x: 0, y: 0, zoom: 1 } }; try { const res = await api.saveCanvas(project.id, wf.id, canvas, executable); // Errors block publish; warnings are wiring smells (e.g. a router whose expression // nothing writes) - show both in the tray, tagged by level. const warns = ((res as any).warnings || []).map((w: any) => ({ ...w, level: "warning" })); setProblems([...res.errors, ...warns]); setSaveState(res.valid ? "saved" : "invalid"); setDirty(false); // canvas is persisted (invalid only blocks publish, not the save) if (!res.valid || warns.length) setShowProblems(true); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1500); } catch { setSaveState("invalid"); } }, [wf, project?.id]); const save = useCallback(async () => { await saveCanvasState(nodes, edges); }, [saveCanvasState, nodes, edges]); // Register the canvas flush with the parent so the top-bar Publish saves first. useEffect(() => { onRegisterFlush?.(save); return () => onRegisterFlush?.(null); }, [onRegisterFlush, save]); const backGuarded = useCallback(async () => { if (dirtyRef.current) { try { await save(); } catch { /* leave anyway */ } } onBack(); }, [save, onBack]); const renameWorkflowTitle = useCallback(async (name: string) => { if (!wf || !project?.id || name === wf.name) return; const previous = wf; const optimistic = { ...wf, name }; setWf(optimistic); onWorkflowChange?.(optimistic); try { const updated = await api.updateWorkflow(project.id, wf.id, { name }); setWf(updated); onWorkflowChange?.(updated); } catch { setWf(previous); onWorkflowChange?.(previous); } }, [wf, project?.id, onWorkflowChange]); const renameNodeId = useCallback((nodeId: string, rawId: string) => { const nextId = rawId.trim().replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, ""); if (!nextId || nextId === nodeId || nodes.some((n) => n.id === nextId)) return; const nextNodes = nodes.map((n) => { const data = n.data.nodeType === "router" ? { ...n.data, config: { ...(n.data.config || {}), cases: Object.fromEntries( Object.entries(n.data.config?.cases || {}).map(([key, target]) => [ key, String(target) === nodeId ? nextId : target, ]), ), default: String(n.data.config?.default || "") === nodeId ? nextId : n.data.config?.default, }, } : n.data; return n.id === nodeId ? { ...n, id: nextId, data } : { ...n, data }; }); const nextEdges = edges.map((e) => ({ ...e, source: e.source === nodeId ? nextId : e.source, target: e.target === nodeId ? nextId : e.target, })); setNodes(nextNodes); setEdges(nextEdges); setSelId((cur) => (cur === nodeId ? nextId : cur)); void saveCanvasState(nextNodes, nextEdges); }, [nodes, edges, saveCanvasState, setNodes, setEdges]); const clearRunDebug = useCallback(() => { setNodes((nds) => stripRunData(nds)); setRunning(false); }, [setNodes]); const markRunNode = useCallback((nodeId: string, status?: "idle" | "running" | "done" | "error", output?: any) => { setNodes((nds) => nds.map((n) => ( n.id === nodeId ? { ...n, data: { ...n.data, status, debug: { ...(n.data.debug || {}), ...(output !== undefined ? { output } : {}) } } } : n ))); }, [setNodes]); const applyFinalDebug = useCallback((debugNodes: Record = {}) => { setNodes((nds) => nds.map((n) => { const debug = debugNodes[n.id]; return debug ? { ...n, data: { ...n.data, debug: { ...(n.data.debug || {}), ...debug } } } : n; })); }, [setNodes]); const closeTestPanel = useCallback(() => { setTestOpen(false); clearRunDebug(); }, [clearRunDebug]); const palette = useMemo(() => { const groups: Record = {}; Object.values(registry).forEach((nt) => { (groups[nt.category] = groups[nt.category] || []).push(nt); }); return groups; }, [registry]); return (
{/* toolbar */}
{nodes.length} nodes · {edges.length} edges
{problems.length > 0 && ( )} setReloadKey((k) => k + 1)} />
{/* palette */}
setPaletteQuery(e.target.value)} style={{ marginBottom: 10 }} /> {Object.entries(palette).map(([cat, items]) => { const filtered = items.filter((it) => it.label.toLowerCase().includes(paletteQuery.toLowerCase()) || it.type.includes(paletteQuery.toLowerCase())); if (!filtered.length) return null; return (
{cat.replace("_", " & ")}
{filtered.map((it) => { const meta = NODE_META[it.type] || { icon: "n_agent", color: "var(--fg-2)" }; return ( ); })}
); })}
{/* palette hover help card */} {paletteTip && NODE_HELP[paletteTip.type] && (() => { const help = NODE_HELP[paletteTip.type]; const meta = NODE_META[paletteTip.type] || { icon: "n_agent", color: "var(--fg-2)", label: paletteTip.type }; return (
{meta.label} {paletteTip.type}
{help.what}
Example
{help.example}
); })()} {/* canvas */}
{/* Hide React Flow's native edge layer - we draw connections via EdgeOverlay. */} {/* Wait for the node-type registry before mounting React Flow so nodes render WITH their handles on first paint - otherwise React Flow measures handle-less nodes and loaded edges never get drawn (their handle bounds stay empty). */} {registryReady ? ( snapshot()} isValidConnection={isValidConnection as any} onNodeClick={(_, n) => setSelId(n.id)} onPaneClick={() => setSelId(null)} deleteKeyCode={["Backspace", "Delete"]} fitView proOptions={{ hideAttribution: true }} defaultEdgeOptions={{ style: { strokeWidth: 2 } }} > NODE_META[(n.data as any)?.nodeType]?.color || "var(--fg-2)"} /> ) : (
Loading canvas…
)}
{connWarning && (
{connWarning}
)} {showProblems && problems.length > 0 && (
Problems
{problems.map((p: any, i) => (
{p.level === "warning" ? "⚠ " : ""}{p.pointer} {p.message}
))}
)}
{/* inspector */}
{testOpen && wf ? ( ) : selected ? ( ) : (
Workflow
Select a node to configure it, or add nodes from the palette.
State schema
messages · intent
Routing: a Router node routes by its cases (set in its inspector); other edges define flow.
)}
); } function NodeInspector({ node, nodes, tools, toolSets, agents, mcpServers, components, dynamic, onChange, onRename, onDelete, onRouterCaseTarget, onRouterCaseRename, onRouterCaseRemove, }: { node: FlowNode; nodes: FlowNode[]; tools: Tool[]; toolSets: ToolSet[]; agents: Agent[]; mcpServers: McpClientT[]; components: ComponentT[]; dynamic?: Record; onChange: (c: Record) => void; onRename: (nodeId: string, name: string) => void; onDelete: (id: string) => void; onRouterCaseTarget: (nodeId: string, key: string, target: string) => void; onRouterCaseRename: (nodeId: string, oldKey: string, newKey: string) => void; onRouterCaseRemove: (nodeId: string, key: string) => void; }) { const type = node.data.nodeType; const c = node.data.config || {}; const set = (patch: Record) => onChange({ ...c, ...patch }); const meta = NODE_META[type] || { label: type, color: "var(--fg-2)", icon: "n_agent" }; const nodeIds = nodes.map((n) => n.id).filter((id) => id !== node.id); return (
{meta.label}
onRename(node.id, name)} />
{(type === "agent" || type === "deep_agent") && } {type === "retrieval" && } {type === "router" && ( )} {type === "llm" && (
set({ model: v })} />