{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"flow-editor","type":"registry:ui","title":"FlowEditor","description":"Main editor canvas.","package":"fancy-flow","dependencies":["@xyflow\/react"],"registryDependencies":["runtime","canvas","node-palette","node-config-panel","flow-run-controls","flow-run-feed","schema","registry","types","layout"],"files":[{"path":"components\/fancy\/flow-editor\/FlowEditor.tsx","content":"import {\r\n  forwardRef,\r\n  useCallback,\r\n  useEffect,\r\n  useImperativeHandle,\r\n  useMemo,\r\n  useRef,\r\n  useState,\r\n  type CSSProperties,\r\n  type ReactNode,\r\n} from \"react\";\r\nimport {\r\n  ReactFlowProvider,\r\n  useReactFlow,\r\n  addEdge,\r\n  applyEdgeChanges,\r\n  applyNodeChanges,\r\n  type Connection,\r\n  type Edge,\r\n  type NodeMouseHandler,\r\n} from \"@xyflow\/react\";\r\nimport type { UseFlowStateReturn } from \"..\/..\/runtime\/use-flow-state\";\r\nimport { FlowCanvas, type FlowCanvasProps } from \"..\/canvas\/FlowCanvas\";\r\nimport { NodePalette, paletteDropHandlers } from \"..\/NodePalette\";\r\nimport { NodeConfigPanel } from \"..\/NodeConfigPanel\";\r\nimport { FlowRunControls } from \"..\/FlowRunControls\";\r\nimport { FlowRunFeed } from \"..\/FlowRunFeed\";\r\nimport { useFlowState } from \"..\/..\/runtime\/use-flow-state\";\r\nimport { useFlowHistory } from \"..\/..\/runtime\/use-flow-history\";\r\nimport { useFlowRun, applyStatusesToNodes, applyOutputsToNodes } from \"..\/..\/runtime\/use-flow-run\";\r\nimport { exportWorkflow, importWorkflow, workflowToBlob, type WorkflowMetadata, type WorkflowSchema } from \"..\/..\/schema\";\r\nimport { buildNodeTypes, defaultConfigFor, getNodeKind, listNodeKinds, onNodeKindsChanged } from \"..\/..\/registry\";\r\nimport type { ExecutorRegistry, FlowGraph, FlowNode } from \"..\/..\/types\";\r\nimport {\r\n  duplicateNode as cloneNode,\r\n  cloneSubgraph,\r\n  reconnectEdge,\r\n  alignNodes,\r\n  distributeNodes,\r\n  assignToLane as assignToLaneOp,\r\n  removeFromLane as removeFromLaneOp,\r\n  removeEdges,\r\n  removeNodes,\r\n  setEdgeLabel,\r\n  type AlignEdge,\r\n} from \".\/graph-ops\";\r\nimport {\r\n  FlowEditorProvider,\r\n  type FlowEditorAction,\r\n  type FlowEditorApi,\r\n  type FlowEditorBuiltins,\r\n  type FlowEditorSlots,\r\n} from \".\/api\";\r\nimport { HumanPrompt, humanInputFields, type HumanPromptRequest } from \".\/HumanPrompt\";\r\n\r\nexport type FlowEditorProps = {\r\n  initial?: FlowGraph;\r\n  \/** Controlled mode \u2014 host owns the graph state. When set, takes precedence\r\n   *  over `initial`. Pair with `onChange` to receive edits. *\/\r\n  value?: FlowGraph;\r\n  \/** Executor registry passed to runFlow. Each kind name maps to an executor. *\/\r\n  executors?: ExecutorRegistry;\r\n  \/** Saved metadata for export. *\/\r\n  metadata?: WorkflowMetadata;\r\n  \/** Show the palette sidebar. Default true. *\/\r\n  showPalette?: boolean;\r\n  \/** Show the config panel sidebar. Default true. *\/\r\n  showPanel?: boolean;\r\n  \/** Show run feed below the canvas. Default true. *\/\r\n  showFeed?: boolean;\r\n  \/** Total editor height. Default 720. *\/\r\n  height?: number;\r\n  \/** Extra toolbar content, appended after the built-ins. Prefer `actions`\r\n   *  (declarative + agent-emittable); this stays for back-compat. *\/\r\n  extraToolbar?: ReactNode;\r\n  \/** Declarative custom toolbar buttons. *\/\r\n  actions?: FlowEditorAction[];\r\n  \/** Turn built-in toolbar affordances off individually. *\/\r\n  builtins?: FlowEditorBuiltins;\r\n  \/** Replace whole regions of the editor. *\/\r\n  slots?: FlowEditorSlots;\r\n  \/** Forwarded to `<FlowCanvas>` \/ React Flow \u2014 snapToGrid, minimap, context\r\n   *  menus, edge types, and anything else xyflow accepts. *\/\r\n  canvasProps?: Partial<Omit<FlowCanvasProps, \"nodes\" | \"edges\">>;\r\n  \/** Called whenever the graph changes \u2014 host can persist. *\/\r\n  onChange?: (graph: FlowGraph) => void;\r\n  \/** Called when the selected node changes. *\/\r\n  onSelectionChange?: (node: FlowNode | null) => void;\r\n  \/** Called after nodes are deleted, with the deleted ids. *\/\r\n  onDelete?: (ids: string[]) => void;\r\n  \/** Called after connections are broken, with the deleted edge ids. *\/\r\n  onEdgeDelete?: (ids: string[]) => void;\r\n  \/**\r\n   * Stage destructive edits for human confirmation. When set, every delete path\r\n   * \u2014 keyboard, panel, context menu, and `api.deleteNodes`\/`deleteEdges` \u2014\r\n   * calls this first; return false to veto. Default: delete immediately. This\r\n   * realizes the component contract's \"agents propose, humans confirm\" on the\r\n   * canvas.\r\n   *\/\r\n  confirmDelete?: (targets: { nodes: FlowNode[]; edges: Edge[] }) => boolean | Promise<boolean>;\r\n  className?: string;\r\n  style?: CSSProperties;\r\n};\r\n\r\n\/**\r\n * FlowEditor \u2014 batteries-included workflow editor, but not a black box.\r\n *\r\n * Defaults compose NodePalette + FlowCanvas + NodeConfigPanel + run controls +\r\n * feed. Every part is replaceable: pass `actions` for custom toolbar buttons,\r\n * `slots` to swap a whole region, `canvasProps` to reach React Flow, or grab\r\n * the {@link FlowEditorApi} from `ref` \/ `useFlowEditor()` and build your own\r\n * chrome around the primitives.\r\n *\/\r\nexport const FlowEditor = forwardRef<FlowEditorApi, FlowEditorProps>(function FlowEditor(props, ref) {\r\n  return (\r\n    <ReactFlowProvider>\r\n      <div\r\n        className={[\"ff-editor\", props.className ?? \"\"].filter(Boolean).join(\" \")}\r\n        style={{ height: props.height ?? 720, ...props.style }}\r\n      >\r\n        <FlowEditorInner {...props} apiRef={ref} \/>\r\n      <\/div>\r\n    <\/ReactFlowProvider>\r\n  );\r\n});\r\n\r\nfunction FlowEditorInner({\r\n  initial = { nodes: [], edges: [] },\r\n  value,\r\n  executors = {},\r\n  metadata,\r\n  showPalette = true,\r\n  showPanel = true,\r\n  showFeed = true,\r\n  extraToolbar,\r\n  actions = [],\r\n  builtins = {},\r\n  slots = {},\r\n  canvasProps = {},\r\n  onChange,\r\n  onSelectionChange,\r\n  onDelete,\r\n  onEdgeDelete,\r\n  confirmDelete,\r\n  apiRef,\r\n}: FlowEditorProps & { apiRef?: React.ForwardedRef<FlowEditorApi> }) {\r\n  const internal = useFlowState(initial);\r\n  const runner = useFlowRun();\r\n  const rf = useReactFlow();\r\n\r\n  \/\/ In-editor human-input: when a run reaches a `user_input` \/ `human_approval`\r\n  \/\/ node, open a modal and BLOCK the run until the person submits. These are\r\n  \/\/ DEFAULT executors \u2014 a host that passes its own `user_input` \/ `human_approval`\r\n  \/\/ executor overrides them (host executors are spread last below).\r\n  const [prompt, setPrompt] = useState<HumanPromptRequest | null>(null);\r\n  const humanExecutors = useMemo<ExecutorRegistry>(\r\n    () => ({\r\n      user_input: ({ node }) =>\r\n        new Promise((resolve) => {\r\n          const cfg = (((node as any).data?.config ?? {}) as Record<string, unknown>);\r\n          setPrompt({\r\n            kind: \"input\",\r\n            title: (typeof cfg.title === \"string\" && cfg.title) || \"Your input\",\r\n            submitLabel: typeof cfg.submitLabel === \"string\" ? cfg.submitLabel : undefined,\r\n            fields: humanInputFields(cfg),\r\n            resolve: (values) => { setPrompt(null); resolve(values); },\r\n          });\r\n        }),\r\n      human_approval: ({ node }) =>\r\n        new Promise((resolve) => {\r\n          const cfg = (((node as any).data?.config ?? {}) as Record<string, unknown>);\r\n          setPrompt({\r\n            kind: \"approval\",\r\n            title: (typeof cfg.title === \"string\" && cfg.title) || \"Approve action\",\r\n            description: typeof cfg.description === \"string\" ? cfg.description : undefined,\r\n            resolve: (approved) => { setPrompt(null); resolve({ branch: approved ? \"approved\" : \"denied\" }); },\r\n          });\r\n        }),\r\n    }),\r\n    [],\r\n  );\r\n  const runExecutors = useMemo(() => ({ ...humanExecutors, ...executors }), [humanExecutors, executors]);\r\n  \/\/ Close the modal if the run ends (finished or cancelled) while it's open.\r\n  useEffect(() => {\r\n    if (!runner.running) setPrompt(null);\r\n  }, [runner.running]);\r\n\r\n  \/\/ When `value` is provided we run in controlled mode: host owns nodes\/edges,\r\n  \/\/ local edits go through onChange. Internal state is unused but the hook\r\n  \/\/ still has to run (rules of hooks).\r\n  const controlled = value !== undefined;\r\n  const baseFlow = controlled ? makeControlledFlowAdapter(value!, onChange) : internal;\r\n  \/\/ Wrap the sink with the commit\/undo pipeline \u2014 one interception point for\r\n  \/\/ every committing mutation, whichever mode we're in.\r\n  const hist = useFlowHistory(baseFlow);\r\n  const flow = hist.flow;\r\n\r\n  \/\/ Re-render when registry kinds change so the palette + nodeTypes reflect it.\r\n  const [, force] = useState(0);\r\n  useEffect(() => onNodeKindsChanged(() => force((n) => n + 1)), []);\r\n  const nodeTypes = useMemo(() => buildNodeTypes(), [listNodeKinds().length]); \/\/ eslint-disable-line react-hooks\/exhaustive-deps\r\n\r\n  const renderedNodes = useMemo(\r\n    () =>\r\n      applyOutputsToNodes(\r\n        applyStatusesToNodes(flow.nodes, runner.statuses, runner.statusText),\r\n        runner.outputs,\r\n      ),\r\n    [flow.nodes, runner.statuses, runner.statusText, runner.outputs],\r\n  );\r\n\r\n  const [selectedId, setSelectedId] = useState<string | null>(null);\r\n  const selected = useMemo(() => flow.nodes.find((n) => n.id === selectedId) ?? null, [flow.nodes, selectedId]);\r\n\r\n  useEffect(() => onSelectionChange?.(selected), [selected, onSelectionChange]);\r\n\r\n  const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);\r\n  const selectedEdge = useMemo(\r\n    () => flow.edges.find((e: Edge) => e.id === selectedEdgeId) ?? null,\r\n    [flow.edges, selectedEdgeId],\r\n  );\r\n\r\n  const handleNodeClick: NodeMouseHandler = (_e, node) => {\r\n    setSelectedId(node.id);\r\n    \/\/ Node and edge selection are mutually exclusive, so Delete is never\r\n    \/\/ ambiguous about what it would remove.\r\n    setSelectedEdgeId(null);\r\n  };\r\n\r\n  \/\/ Right-click menu, for a node OR a connection. Anchored in viewport coords\r\n  \/\/ (position: fixed), so it is not clipped by the canvas' overflow.\r\n  const [menu, setMenu] = useState<\r\n    { x: number; y: number; target: { type: \"node\" | \"edge\"; id: string } } | null\r\n  >(null);\r\n  \/\/ Inline \"label this connection\" editor, anchored like the menu.\r\n  const [labelEdit, setLabelEdit] = useState<{ x: number; y: number; edgeId: string } | null>(null);\r\n\r\n  const closeMenu = useCallback(() => setMenu(null), []);\r\n\r\n  const handleNodeContextMenu: NodeMouseHandler = (event, node) => {\r\n    event.preventDefault();\r\n    setSelectedId(node.id);\r\n    setSelectedEdgeId(null);\r\n    setMenu({ x: event.clientX, y: event.clientY, target: { type: \"node\", id: node.id } });\r\n  };\r\n\r\n  const handleEdgeClick = (_e: React.MouseEvent, edge: Edge) => {\r\n    setSelectedEdgeId(edge.id);\r\n  };\r\n\r\n  const handleEdgeContextMenu = (event: React.MouseEvent, edge: Edge) => {\r\n    event.preventDefault();\r\n    setSelectedEdgeId(edge.id);\r\n    setMenu({ x: event.clientX, y: event.clientY, target: { type: \"edge\", id: edge.id } });\r\n  };\r\n\r\n  \/\/ Dismiss on any outside click, scroll, or Escape.\r\n  useEffect(() => {\r\n    if (menu === null && labelEdit === null) return;\r\n    const close = () => { setMenu(null); setLabelEdit(null); };\r\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\r\n    window.addEventListener(\"click\", close);\r\n    window.addEventListener(\"scroll\", close, true);\r\n    window.addEventListener(\"keydown\", onKey);\r\n    return () => {\r\n      window.removeEventListener(\"click\", close);\r\n      window.removeEventListener(\"scroll\", close, true);\r\n      window.removeEventListener(\"keydown\", onKey);\r\n    };\r\n  }, [menu, labelEdit]);\r\n\r\n  \/\/ Editor keyboard shortcuts. Ignored while a field is focused, so Ctrl+Z does\r\n  \/\/ native text-undo inside inputs\/textareas rather than reverting the graph.\r\n  useEffect(() => {\r\n    const onKey = (e: KeyboardEvent) => {\r\n      const t = e.target as HTMLElement | null;\r\n      if (t && (t.tagName === \"INPUT\" || t.tagName === \"TEXTAREA\" || t.isContentEditable)) return;\r\n      if (!(e.ctrlKey || e.metaKey)) return;\r\n      const key = e.key.toLowerCase();\r\n      if (key === \"z\" && !e.shiftKey) {\r\n        e.preventDefault();\r\n        hist.undo();\r\n      } else if ((key === \"z\" && e.shiftKey) || key === \"y\") {\r\n        e.preventDefault();\r\n        hist.redo();\r\n      }\r\n    };\r\n    window.addEventListener(\"keydown\", onKey);\r\n    return () => window.removeEventListener(\"keydown\", onKey);\r\n  }, [hist]);\r\n\r\n  \/\/ Uncontrolled: notify host on every graph mutation. Controlled mode\r\n  \/\/ already notifies via the adapter on each setNodes\/setEdges call.\r\n  useEffect(() => {\r\n    if (!controlled) onChange?.({ nodes: flow.nodes, edges: flow.edges });\r\n  }, [flow.nodes, flow.edges, onChange, controlled]);\r\n\r\n  const addNode = useCallback(\r\n    (kindName: string, position?: { x: number; y: number }): string | null => {\r\n      const kind = getNodeKind(kindName);\r\n      if (!kind) return null;\r\n      const at = position ?? { x: 80, y: 80 };\r\n      const id = newNodeId();\r\n      flow.setNodes((all: FlowNode[]) => [\r\n        ...all,\r\n        {\r\n          id,\r\n          type: kind.name,\r\n          position: at,\r\n          data: { kind: kind.name, label: kind.label, config: defaultConfigFor(kind) } as any,\r\n        } as FlowNode,\r\n      ]);\r\n      setSelectedId(id);\r\n      return id;\r\n    },\r\n    [flow],\r\n  );\r\n\r\n  \/** Delete nodes AND every edge attached to them \u2014 an orphaned edge would\r\n   *  otherwise survive the node it connected. *\/\r\n  const deleteNodes = useCallback(\r\n    async (ids: string[]) => {\r\n      if (ids.length === 0) return;\r\n      const targets = flow.nodes.filter((n) => ids.includes(n.id));\r\n      if (targets.length === 0) return;\r\n      if (confirmDelete) {\r\n        const attached = flow.edges.filter((e: Edge) => ids.includes(e.source) || ids.includes(e.target));\r\n        if (!(await confirmDelete({ nodes: targets, edges: attached }))) return;\r\n      }\r\n      const doomed = new Set(ids);\r\n      \/\/ Atomic: prune edges + remove nodes in ONE commit \u2014 one undo step, and\r\n      \/\/ correct in controlled mode where two sequential writes would clobber.\r\n      flow.setGraph(removeNodes({ nodes: flow.nodes, edges: flow.edges }, ids));\r\n      setSelectedId((cur) => (cur !== null && doomed.has(cur) ? null : cur));\r\n      onDelete?.(ids);\r\n    },\r\n    [flow, onDelete, confirmDelete],\r\n  );\r\n\r\n  const deleteEdges = useCallback(\r\n    async (ids: string[]) => {\r\n      if (ids.length === 0) return;\r\n      const targets = flow.edges.filter((e: Edge) => ids.includes(e.id));\r\n      if (targets.length === 0) return;\r\n      if (confirmDelete && !(await confirmDelete({ nodes: [], edges: targets }))) return;\r\n      flow.setEdges((all: Edge[]) => removeEdges(all, ids));\r\n      setSelectedEdgeId((cur) => (cur !== null && ids.includes(cur) ? null : cur));\r\n      onEdgeDelete?.(ids);\r\n    },\r\n    [flow, onEdgeDelete, confirmDelete],\r\n  );\r\n\r\n  \/\/ \u2500\u2500 Multi-selection + clipboard (Release 2) \u2500\u2500\r\n  \/\/ xyflow owns the real multi-selection (box \/ shift-click) on `node.selected`;\r\n  \/\/ we read it rather than tracking a parallel list.\r\n  const selectedNodes = useMemo(() => flow.nodes.filter((n) => n.selected), [flow.nodes]);\r\n  const selectedIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]);\r\n\r\n  const clipboard = useRef<FlowGraph | null>(null);\r\n\r\n  const copySelection = useCallback(() => {\r\n    const sel = flow.nodes.filter((n) => n.selected);\r\n    if (sel.length === 0) return;\r\n    const ids = new Set(sel.map((n) => n.id));\r\n    const edges = flow.edges.filter((e: Edge) => ids.has(e.source) && ids.has(e.target));\r\n    clipboard.current = { nodes: sel.map((n) => ({ ...n })), edges: edges.map((e) => ({ ...e })) };\r\n  }, [flow]);\r\n\r\n  const pasteClipboard = useCallback(\r\n    (at?: { x: number; y: number }) => {\r\n      const clip = clipboard.current;\r\n      if (!clip || clip.nodes.length === 0) return;\r\n      const { nodes: clones, edges: cloneEdges } = cloneSubgraph(clip.nodes, clip.edges, { makeId: newNodeId, offset: 40 });\r\n      let placed = clones;\r\n      if (at) {\r\n        const minX = Math.min(...clones.map((n) => n.position.x));\r\n        const minY = Math.min(...clones.map((n) => n.position.y));\r\n        placed = clones.map((n) => ({ ...n, position: { x: n.position.x - minX + at.x, y: n.position.y - minY + at.y } }));\r\n      }\r\n      flow.setGraph({\r\n        nodes: [...flow.nodes.map((n) => ({ ...n, selected: false })), ...placed.map((n) => ({ ...n, selected: true }))],\r\n        edges: [...flow.edges, ...cloneEdges],\r\n      });\r\n    },\r\n    [flow],\r\n  );\r\n\r\n  const duplicateSelected = useCallback(() => {\r\n    const sel = flow.nodes.filter((n) => n.selected);\r\n    if (sel.length === 0) return;\r\n    const ids = new Set(sel.map((n) => n.id));\r\n    const internal = flow.edges.filter((e: Edge) => ids.has(e.source) && ids.has(e.target));\r\n    const { nodes: clones, edges: cloneEdges } = cloneSubgraph(sel, internal, { makeId: newNodeId, offset: 40 });\r\n    flow.setGraph({\r\n      nodes: [...flow.nodes.map((n) => ({ ...n, selected: false })), ...clones.map((n) => ({ ...n, selected: true }))],\r\n      edges: [...flow.edges, ...cloneEdges],\r\n    });\r\n  }, [flow]);\r\n\r\n  const alignSelected = useCallback(\r\n    (edge: AlignEdge) => {\r\n      const sel = flow.nodes.filter((n) => n.selected);\r\n      if (sel.length < 2) return;\r\n      const byId = new Map(alignNodes(sel, edge).map((n) => [n.id, n] as const));\r\n      flow.setNodes((all: FlowNode[]) => all.map((n) => byId.get(n.id) ?? n));\r\n    },\r\n    [flow],\r\n  );\r\n\r\n  const distributeSelected = useCallback(\r\n    (axis: \"h\" | \"v\") => {\r\n      const sel = flow.nodes.filter((n) => n.selected);\r\n      if (sel.length < 3) return;\r\n      const byId = new Map(distributeNodes(sel, axis).map((n) => [n.id, n] as const));\r\n      flow.setNodes((all: FlowNode[]) => all.map((n) => byId.get(n.id) ?? n));\r\n    },\r\n    [flow],\r\n  );\r\n\r\n  const onReconnect = useCallback(\r\n    (oldEdge: Edge, conn: Connection) => flow.setEdges((eds: Edge[]) => reconnectEdge(eds, oldEdge, conn)),\r\n    [flow],\r\n  );\r\n\r\n  \/\/ \u2500\u2500 Swimlanes (Release 4) \u2500\u2500\r\n  const isLaneNode = useCallback(\r\n    (n: FlowNode) => getNodeKind((n.data as any)?.kind ?? n.type)?.category === \"layout\",\r\n    [],\r\n  );\r\n\r\n  const addLane = useCallback(\r\n    (orientation: \"horizontal\" | \"vertical\" = \"horizontal\", title?: string): string => {\r\n      const vertical = orientation === \"vertical\";\r\n      const lanes = flow.nodes.filter(isLaneNode);\r\n      const w = vertical ? 280 : 680;\r\n      const h = vertical ? 480 : 168;\r\n      const end = lanes.reduce(\r\n        (m, l) =>\r\n          Math.max(m, vertical ? l.position.x + ((l as any).width ?? w) : l.position.y + ((l as any).height ?? h)),\r\n        0,\r\n      );\r\n      const id = newNodeId();\r\n      const name = title ?? `Lane ${lanes.length + 1}`;\r\n      const node = {\r\n        id,\r\n        type: \"@particle-academy\/lane\",\r\n        position: vertical ? { x: lanes.length ? end + 12 : 0, y: 0 } : { x: 0, y: lanes.length ? end + 12 : 0 },\r\n        width: w,\r\n        height: h,\r\n        data: { kind: \"@particle-academy\/lane\", label: name, config: { title: name, orientation } } as any,\r\n      } as FlowNode;\r\n      flow.setNodes((all: FlowNode[]) => [...all, node]);\r\n      setSelectedId(id);\r\n      return id;\r\n    },\r\n    [flow, isLaneNode],\r\n  );\r\n\r\n  \/\/ Drop a node onto a lane to file it there; drag it out to unfile it.\r\n  const handleNodeDragStop = useCallback(\r\n    (_e: MouseEvent | TouchEvent, node: FlowNode) => {\r\n      if (isLaneNode(node)) return; \/\/ v1: lanes don't nest inside lanes\r\n      const overLane = rf.getIntersectingNodes(node).find((n) => isLaneNode(n as FlowNode));\r\n      const currentParent = (node as any).parentId as string | undefined;\r\n      if (overLane && overLane.id !== currentParent) {\r\n        flow.setNodes((all: FlowNode[]) => assignToLaneOp(all, node.id, overLane.id));\r\n      } else if (!overLane && currentParent) {\r\n        flow.setNodes((all: FlowNode[]) => removeFromLaneOp(all, node.id));\r\n      }\r\n    },\r\n    [rf, flow, isLaneNode],\r\n  );\r\n\r\n  \/\/ Clipboard + duplicate shortcuts. (Undo\/redo live in the effect above.)\r\n  useEffect(() => {\r\n    const onKey = (e: KeyboardEvent) => {\r\n      const t = e.target as HTMLElement | null;\r\n      if (t && (t.tagName === \"INPUT\" || t.tagName === \"TEXTAREA\" || t.isContentEditable)) return;\r\n      if (!(e.ctrlKey || e.metaKey)) return;\r\n      const key = e.key.toLowerCase();\r\n      if (key === \"c\") copySelection();\r\n      else if (key === \"x\") {\r\n        e.preventDefault();\r\n        copySelection();\r\n        deleteNodes(selectedIds);\r\n      } else if (key === \"v\") {\r\n        e.preventDefault();\r\n        pasteClipboard();\r\n      } else if (key === \"d\") {\r\n        e.preventDefault();\r\n        duplicateSelected();\r\n      }\r\n    };\r\n    window.addEventListener(\"keydown\", onKey);\r\n    return () => window.removeEventListener(\"keydown\", onKey);\r\n  }, [copySelection, pasteClipboard, duplicateSelected, deleteNodes, selectedIds]);\r\n\r\n  const api: FlowEditorApi = useMemo(() => {\r\n    const toWorkflow = () => exportWorkflow({ nodes: flow.nodes, edges: flow.edges }, metadata);\r\n\r\n    return {\r\n      graph: { nodes: flow.nodes, edges: flow.edges },\r\n      nodes: flow.nodes,\r\n      edges: flow.edges,\r\n      selectedId,\r\n      selected,\r\n      selectedEdgeId,\r\n      selectedEdge,\r\n      selectedIds,\r\n      selectedNodes,\r\n      running: runner.running,\r\n      statuses: runner.statuses,\r\n\r\n      select: setSelectedId,\r\n      selectEdge: setSelectedEdgeId,\r\n\r\n      addNode,\r\n      updateNode: (next) => flow.setNodes((all: FlowNode[]) => all.map((x) => (x.id === next.id ? next : x))),\r\n      deleteNodes,\r\n      deleteSelected: () => deleteNodes(selectedId ? [selectedId] : []),\r\n      deleteEdges,\r\n      deleteSelectedEdge: () => deleteEdges(selectedEdgeId ? [selectedEdgeId] : []),\r\n      setEdgeLabel: (id, label) => flow.setEdges((all: Edge[]) => setEdgeLabel(all, id, label)),\r\n      duplicateNode: (id) => {\r\n        const src = flow.nodes.find((n) => n.id === id);\r\n        if (!src) return null;\r\n        const copy = cloneNode(src, newNodeId());\r\n        flow.setNodes((all: FlowNode[]) => [...all, copy]);\r\n        setSelectedId(copy.id);\r\n        return copy.id;\r\n      },\r\n      setGraph: (graph) => flow.setGraph(graph),\r\n\r\n      duplicateSelected,\r\n      alignSelected,\r\n      distributeSelected,\r\n      copy: copySelection,\r\n      cut: () => {\r\n        copySelection();\r\n        deleteNodes(selectedIds);\r\n      },\r\n      paste: pasteClipboard,\r\n\r\n      addLane,\r\n      assignToLane: (nodeId, laneId) => flow.setNodes((all: FlowNode[]) => assignToLaneOp(all, nodeId, laneId)),\r\n      removeFromLane: (nodeId) => flow.setNodes((all: FlowNode[]) => removeFromLaneOp(all, nodeId)),\r\n      \/\/ dagre is loaded lazily so it stays out of the eager bundle \u2014 consumers\r\n      \/\/ who never tidy pay nothing for it.\r\n      autoLayout: (opts) => {\r\n        void import(\"..\/..\/layout\").then(({ autoLayout }) =>\r\n          flow.setGraph({ nodes: autoLayout({ nodes: flow.nodes, edges: flow.edges }, opts), edges: flow.edges }),\r\n        );\r\n      },\r\n      tidyLane: (laneId) => {\r\n        void import(\"..\/..\/layout\").then(({ autoLayout }) =>\r\n          flow.setGraph({ nodes: autoLayout({ nodes: flow.nodes, edges: flow.edges }, { scope: laneId }), edges: flow.edges }),\r\n        );\r\n      },\r\n\r\n      run: () => runner.run({ nodes: flow.nodes, edges: flow.edges }, runExecutors),\r\n      cancel: runner.cancel,\r\n      reset: runner.reset,\r\n\r\n      toWorkflow,\r\n      exportWorkflow: () => downloadWorkflow(toWorkflow(), metadata),\r\n      importWorkflow: () => pickWorkflow((graph) => flow.setGraph(graph)),\r\n\r\n      fitView: () => rf.fitView({ padding: 0.2 }),\r\n\r\n      undo: hist.undo,\r\n      redo: hist.redo,\r\n      canUndo: hist.canUndo,\r\n      canRedo: hist.canRedo,\r\n    };\r\n  }, [flow, selectedId, selected, selectedEdgeId, selectedEdge, selectedIds, selectedNodes, runner, runExecutors, metadata, addNode, deleteNodes, deleteEdges, duplicateSelected, alignSelected, distributeSelected, copySelection, pasteClipboard, addLane, hist, rf]);\r\n\r\n  useImperativeHandle(apiRef, () => api, [api]);\r\n\r\n  const dropHandlers = paletteDropHandlers((kindName, evt) => {\r\n    const point = rf.screenToFlowPosition({ x: evt.clientX, y: evt.clientY });\r\n    addNode(kindName, { x: point.x - 100, y: point.y - 30 });\r\n  });\r\n\r\n  const startActions = actions.filter((a) => a.placement === \"start\");\r\n  const endActions = actions.filter((a) => a.placement !== \"start\");\r\n\r\n  const toolbar = slots.toolbar ? (\r\n    slots.toolbar(api)\r\n  ) : (\r\n    <>\r\n      {startActions.map((a) => renderAction(a, api))}\r\n      {builtins.run !== false && (\r\n        <FlowRunControls running={api.running} onRun={api.run} onCancel={api.cancel} onReset={api.reset} \/>\r\n      )}\r\n      {builtins.history !== false && (\r\n        <>\r\n          <span className=\"ff-editor__sep\" \/>\r\n          <button\r\n            className=\"ff-editor__btn\"\r\n            data-action=\"undo\"\r\n            title=\"Undo (Ctrl+Z)\"\r\n            disabled={!api.canUndo}\r\n            onClick={api.undo}\r\n          >\r\n            \u21b6 Undo\r\n          <\/button>\r\n          <button\r\n            className=\"ff-editor__btn\"\r\n            data-action=\"redo\"\r\n            title=\"Redo (Ctrl+Shift+Z)\"\r\n            disabled={!api.canRedo}\r\n            onClick={api.redo}\r\n          >\r\n            \u21b7 Redo\r\n          <\/button>\r\n        <\/>\r\n      )}\r\n      {builtins.addLane !== false && (\r\n        <>\r\n          <span className=\"ff-editor__sep\" \/>\r\n          <button className=\"ff-editor__btn\" data-action=\"add-lane\" title=\"Add a swimlane\" onClick={() => api.addLane()}>\r\n            \u25a4 Lane\r\n          <\/button>\r\n        <\/>\r\n      )}\r\n      {builtins.autoLayout !== false && (\r\n        <button className=\"ff-editor__btn\" data-action=\"auto-layout\" title=\"Tidy \u2014 auto-arrange\" onClick={() => api.autoLayout()}>\r\n          \u2922 Tidy\r\n        <\/button>\r\n      )}\r\n      {(builtins.export !== false || builtins.import !== false) && (\r\n        <span className=\"ff-editor__sep\" \/>\r\n      )}\r\n      {\/* Node deletion is not a toolbar button \u2014 it lives in NodeConfigPanel\r\n          (see below), so the affordance is a property of the reusable panel\r\n          rather than duplicated chrome that drifts. Keyboard Del\/Backspace and\r\n          the right-click menu still delete. `builtins.delete` gates the panel\r\n          button. *\/}\r\n      {builtins.export !== false && (\r\n        <button className=\"ff-editor__btn\" data-action=\"export\" onClick={api.exportWorkflow}>\u2193 Export<\/button>\r\n      )}\r\n      {builtins.import !== false && (\r\n        <button className=\"ff-editor__btn\" data-action=\"import\" onClick={api.importWorkflow}>\u2191 Import<\/button>\r\n      )}\r\n      {endActions.map((a) => renderAction(a, api))}\r\n      {extraToolbar}\r\n      {builtins.count !== false && (\r\n        <span className=\"ff-editor__count\">{api.nodes.length} nodes \u00b7 {api.edges.length} edges<\/span>\r\n      )}\r\n    <\/>\r\n  );\r\n\r\n  return (\r\n    <FlowEditorProvider value={api}>\r\n      {showPalette && (slots.palette ? slots.palette(api) : <NodePalette className=\"ff-editor__palette\" \/>)}\r\n      <div className=\"ff-editor__main\" {...dropHandlers}>\r\n        <FlowCanvas\r\n          nodes={renderedNodes}\r\n          edges={flow.edges}\r\n          nodeTypes={nodeTypes}\r\n          onNodesChange={flow.onNodesChange}\r\n          onEdgesChange={flow.onEdgesChange}\r\n          onConnect={flow.onConnect}\r\n          \/\/ Drag an edge endpoint to rewire it; the new endpoint is validated by\r\n          \/\/ the same isValidConnection rule (G2), so a bad reconnect is refused.\r\n          onReconnect={onReconnect}\r\n          edgesReconnectable\r\n          \/\/ Snapshot the pre-drag graph once so a drag is a single undo step.\r\n          onNodeDragStart={hist.onNodeDragStart}\r\n          \/\/ Drop a node onto a lane to file it there (or drag it out to unfile).\r\n          onNodeDragStop={handleNodeDragStop}\r\n          onNodeClick={handleNodeClick}\r\n          onNodeContextMenu={builtins.contextMenu === false ? undefined : handleNodeContextMenu}\r\n          onEdgeClick={handleEdgeClick}\r\n          onEdgeContextMenu={builtins.edgeContextMenu === false ? undefined : handleEdgeContextMenu}\r\n          onEdgesDelete={(deleted: Edge[]) => onEdgeDelete?.(deleted.map((e) => e.id))}\r\n          onNodesDelete={(deleted) => onDelete?.(deleted.map((n) => n.id))}\r\n          \/\/ Stage the native (keyboard) delete path when a confirm gate is wired.\r\n          onBeforeDelete={\r\n            confirmDelete\r\n              ? async ({ nodes, edges }) => confirmDelete({ nodes: nodes as FlowNode[], edges })\r\n              : undefined\r\n          }\r\n          \/\/ Both keys delete, so muscle memory from either platform works.\r\n          deleteKeyCode={[\"Delete\", \"Backspace\"]}\r\n          height=\"100%\"\r\n          toolbar={toolbar}\r\n          {...canvasProps}\r\n        \/>\r\n        {slots.empty && api.nodes.length === 0 && (\r\n          <div className=\"ff-editor__empty\">{slots.empty(api)}<\/div>\r\n        )}\r\n        {menu?.target.type === \"node\" && builtins.contextMenu !== false && (\r\n          <div\r\n            className=\"ff-editor__ctx\"\r\n            style={{ top: menu.y, left: menu.x }}\r\n            role=\"menu\"\r\n            \/\/ Keep the outside-click listener from closing us before the click lands.\r\n            onClick={(e) => e.stopPropagation()}\r\n          >\r\n            {slots.contextMenu ? (\r\n              slots.contextMenu(api, menu.target.id, closeMenu)\r\n            ) : (\r\n              <>\r\n                <button\r\n                  type=\"button\"\r\n                  role=\"menuitem\"\r\n                  className=\"ff-editor__ctx-item\"\r\n                  data-action=\"ctx-duplicate\"\r\n                  onClick={() => { api.duplicateNode(menu.target.id); closeMenu(); }}\r\n                >\r\n                  Duplicate\r\n                <\/button>\r\n                <button\r\n                  type=\"button\"\r\n                  role=\"menuitem\"\r\n                  className=\"ff-editor__ctx-item ff-editor__ctx-item--danger\"\r\n                  data-action=\"ctx-delete\"\r\n                  onClick={() => { api.deleteNodes([menu.target.id]); closeMenu(); }}\r\n                >\r\n                  Delete\r\n                <\/button>\r\n              <\/>\r\n            )}\r\n          <\/div>\r\n        )}\r\n\r\n        {menu?.target.type === \"edge\" && builtins.edgeContextMenu !== false && (\r\n          <div\r\n            className=\"ff-editor__ctx\"\r\n            style={{ top: menu.y, left: menu.x }}\r\n            role=\"menu\"\r\n            onClick={(e) => e.stopPropagation()}\r\n          >\r\n            {slots.edgeContextMenu ? (\r\n              slots.edgeContextMenu(api, menu.target.id, closeMenu)\r\n            ) : (\r\n              <>\r\n                <button\r\n                  type=\"button\"\r\n                  role=\"menuitem\"\r\n                  className=\"ff-editor__ctx-item\"\r\n                  data-action=\"ctx-edge-label\"\r\n                  onClick={() => {\r\n                    setLabelEdit({ x: menu.x, y: menu.y, edgeId: menu.target.id });\r\n                    setMenu(null);\r\n                  }}\r\n                >\r\n                  Label\u2026\r\n                <\/button>\r\n                <button\r\n                  type=\"button\"\r\n                  role=\"menuitem\"\r\n                  className=\"ff-editor__ctx-item ff-editor__ctx-item--danger\"\r\n                  data-action=\"ctx-edge-delete\"\r\n                  onClick={() => { api.deleteEdges([menu.target.id]); closeMenu(); }}\r\n                >\r\n                  Delete connection\r\n                <\/button>\r\n              <\/>\r\n            )}\r\n          <\/div>\r\n        )}\r\n\r\n        {labelEdit !== null && (\r\n          <EdgeLabelEditor\r\n            x={labelEdit.x}\r\n            y={labelEdit.y}\r\n            initial={\r\n              (flow.edges.find((e: Edge) => e.id === labelEdit.edgeId)?.label as string | undefined) ?? \"\"\r\n            }\r\n            onCommit={(text) => {\r\n              api.setEdgeLabel(labelEdit.edgeId, text);\r\n              setLabelEdit(null);\r\n            }}\r\n            onCancel={() => setLabelEdit(null)}\r\n          \/>\r\n        )}\r\n        {showFeed &&\r\n          (slots.feed ? slots.feed(api) : <FlowRunFeed entries={runner.feed} running={api.running} className=\"ff-editor__feed\" \/>)}\r\n        {prompt && <HumanPrompt request={prompt} onCancel={() => { setPrompt(null); runner.cancel(); }} \/>}\r\n      <\/div>\r\n      {showPanel &&\r\n        (slots.panel ? (\r\n          slots.panel(api)\r\n        ) : (\r\n          <div className=\"ff-editor__panel-wrap\">\r\n            <NodeConfigPanel\r\n              className=\"ff-editor__panel\"\r\n              node={api.selected}\r\n              onChange={api.updateNode}\r\n              \/\/ The delete affordance lives IN the panel (one source of truth),\r\n              \/\/ not a private FlowEditor toolbar button \u2014 so a dev composing\r\n              \/\/ their own editor from NodeConfigPanel gets it for free.\r\n              onDelete={builtins.delete === false ? undefined : (n) => api.deleteNodes([n.id])}\r\n            \/>\r\n            {slots.panelFooter && <div className=\"ff-editor__panel-footer\">{slots.panelFooter(api)}<\/div>}\r\n          <\/div>\r\n        ))}\r\n    <\/FlowEditorProvider>\r\n  );\r\n}\r\n\r\nfunction renderAction(action: FlowEditorAction, api: FlowEditorApi) {\r\n  if (action.visible && !action.visible(api)) return null;\r\n  const disabled = action.disabled ? action.disabled(api) : action.requiresSelection === true && api.selected === null;\r\n  return (\r\n    <button\r\n      key={action.id}\r\n      className=\"ff-editor__btn\"\r\n      data-action={action.id}\r\n      title={action.title}\r\n      disabled={disabled}\r\n      onClick={() => action.onSelect(api)}\r\n    >\r\n      {action.label}\r\n    <\/button>\r\n  );\r\n}\r\n\r\nfunction newNodeId(): string {\r\n  return `n_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;\r\n}\r\n\r\nfunction downloadWorkflow(schema: WorkflowSchema, metadata?: WorkflowMetadata) {\r\n  const url = URL.createObjectURL(workflowToBlob(schema));\r\n  const a = document.createElement(\"a\");\r\n  a.href = url;\r\n  a.download = `${metadata?.id ?? \"workflow\"}.json`;\r\n  a.click();\r\n  URL.revokeObjectURL(url);\r\n}\r\n\r\nfunction pickWorkflow(onLoad: (graph: FlowGraph) => void) {\r\n  const input = document.createElement(\"input\");\r\n  input.type = \"file\";\r\n  input.accept = \"application\/json\";\r\n  input.onchange = async () => {\r\n    const file = input.files?.[0];\r\n    if (!file) return;\r\n    try {\r\n      const result = importWorkflow(JSON.parse(await file.text()), { lenient: true });\r\n      onLoad(result.graph);\r\n    } catch (e) {\r\n      console.error(\"import failed\", e);\r\n    }\r\n  };\r\n  input.click();\r\n}\r\n\r\n\/**\r\n * Build a UseFlowStateReturn-shaped adapter that proxies through to the\r\n * host's onChange. Edits route through onChange; reads come from `value`.\r\n *\/\r\nfunction makeControlledFlowAdapter(\r\n  value: FlowGraph,\r\n  onChange?: (graph: FlowGraph) => void,\r\n): UseFlowStateReturn {\r\n  const apply = (next: FlowGraph) => onChange?.(next);\r\n  return {\r\n    nodes: value.nodes,\r\n    edges: value.edges,\r\n    setNodes: (next) => {\r\n      const nextNodes = typeof next === \"function\" ? (next as any)(value.nodes) : next;\r\n      apply({ nodes: nextNodes, edges: value.edges });\r\n    },\r\n    setEdges: (next) => {\r\n      const nextEdges = typeof next === \"function\" ? (next as any)(value.edges) : next;\r\n      apply({ nodes: value.nodes, edges: nextEdges });\r\n    },\r\n    \/\/ Atomic both-at-once commit \u2014 the reason this exists (see UseFlowStateReturn).\r\n    setGraph: (graph) => apply(graph),\r\n    onNodesChange: (changes) => {\r\n      apply({ nodes: applyNodeChanges(changes, value.nodes) as any, edges: value.edges });\r\n    },\r\n    onEdgesChange: (changes) => {\r\n      apply({ nodes: value.nodes, edges: applyEdgeChanges(changes, value.edges) });\r\n    },\r\n    onConnect: (connection: Connection) => {\r\n      apply({ nodes: value.nodes, edges: addEdge(connection, value.edges) as Edge[] });\r\n    },\r\n    toGraph: () => value,\r\n  };\r\n}\r\n\r\n\/**\r\n * EdgeLabelEditor \u2014 small popover for naming a connection.\r\n *\r\n * Anchored in viewport coords like the context menu it replaces. Enter\r\n * commits, Escape cancels, blur commits (so clicking away doesn't silently\r\n * discard the edit).\r\n *\/\r\nfunction EdgeLabelEditor({\r\n  x,\r\n  y,\r\n  initial,\r\n  onCommit,\r\n  onCancel,\r\n}: {\r\n  x: number;\r\n  y: number;\r\n  initial: string;\r\n  onCommit: (text: string) => void;\r\n  onCancel: () => void;\r\n}) {\r\n  const [text, setText] = useState(initial);\r\n  const ref = useRef<HTMLInputElement | null>(null);\r\n\r\n  useEffect(() => {\r\n    ref.current?.focus();\r\n    ref.current?.select();\r\n  }, []);\r\n\r\n  return (\r\n    <div\r\n      className=\"ff-editor__ctx ff-editor__edge-label\"\r\n      style={{ top: y, left: x }}\r\n      onClick={(e) => e.stopPropagation()}\r\n    >\r\n      <input\r\n        ref={ref}\r\n        className=\"ff-panel__input\"\r\n        value={text}\r\n        placeholder=\"Label this connection\"\r\n        aria-label=\"Connection label\"\r\n        data-action=\"edge-label-input\"\r\n        onChange={(e) => setText(e.target.value)}\r\n        onKeyDown={(e) => {\r\n          \/\/ Stop the canvas seeing these \u2014 Backspace would delete the edge\r\n          \/\/ out from under the editor.\r\n          e.stopPropagation();\r\n          if (e.key === \"Enter\") onCommit(text);\r\n          if (e.key === \"Escape\") onCancel();\r\n        }}\r\n        onBlur={() => onCommit(text)}\r\n      \/>\r\n    <\/div>\r\n  );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/flow-editor\/FlowEditor.tsx"},{"path":"components\/fancy\/flow-editor\/HumanPrompt.tsx","content":"import { useEffect, useRef, useState } from \"react\";\n\n\/**\n * The in-editor human-input modal. When a run reaches a `user_input` or\n * `human_approval` node, FlowEditor's default executor opens this and BLOCKS the\n * run (the executor returns a Promise) until the person submits \u2014 the same\n * async-executor pattern the headless engine already supports. A host that\n * passes its own `user_input` \/ `human_approval` executor overrides this.\n *\/\n\n\/** A field the input modal renders. Mirrors a `user_input` `fields` row. *\/\nexport type HumanField = {\n  key: string;\n  label?: string;\n  type?: \"text\" | \"textarea\" | \"number\" | \"select\" | \"switch\";\n  required?: boolean;\n  placeholder?: string;\n  options?: Array<{ value: string; label: string }>;\n  default?: unknown;\n};\n\nexport type HumanPromptRequest =\n  | { kind: \"input\"; title: string; submitLabel?: string; fields: HumanField[]; resolve: (values: Record<string, unknown>) => void }\n  | { kind: \"approval\"; title: string; description?: string; resolve: (approved: boolean) => void };\n\n\/**\n * Normalize a `user_input` node's `fields` config into renderable fields. Falls\n * back to a single text field so even an unconfigured User Input node still\n * collects something rather than silently returning nothing.\n *\/\nexport function humanInputFields(config: Record<string, unknown>): HumanField[] {\n  const raw = Array.isArray((config as any)?.fields) ? ((config as any).fields as any[]) : [];\n  const fields: HumanField[] = raw\n    .filter((f) => f && typeof f === \"object\" && typeof (f as any).key === \"string\" && (f as any).key)\n    .map((f: any) => ({\n      key: f.key,\n      label: typeof f.label === \"string\" && f.label ? f.label : f.key,\n      type: [\"text\", \"textarea\", \"number\", \"select\", \"switch\"].includes(f.type) ? f.type : \"text\",\n      required: !!f.required,\n      placeholder: typeof f.placeholder === \"string\" ? f.placeholder : undefined,\n      options: Array.isArray(f.options) ? f.options : undefined,\n      default: f.default,\n    }));\n  if (fields.length) return fields;\n  const title = typeof (config as any)?.title === \"string\" && (config as any).title ? (config as any).title : \"Your answer\";\n  return [{ key: \"value\", label: title, type: \"textarea\", required: true }];\n}\n\nfunction initialValues(fields: HumanField[]): Record<string, unknown> {\n  const v: Record<string, unknown> = {};\n  for (const f of fields) v[f.key] = f.type === \"switch\" ? !!f.default : (f.default ?? \"\");\n  return v;\n}\n\nexport function HumanPrompt({ request, onCancel }: { request: HumanPromptRequest; onCancel: () => void }) {\n  const firstRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);\n  useEffect(() => {\n    firstRef.current?.focus();\n    const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") onCancel(); };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [onCancel]);\n\n  return (\n    <div className=\"ff-prompt-overlay\" role=\"dialog\" aria-modal=\"true\" aria-label={request.title}>\n      <div className=\"ff-prompt\" onClick={(e) => e.stopPropagation()}>\n        <div className=\"ff-prompt__title\">{request.title}<\/div>\n        {request.kind === \"approval\" ? (\n          <ApprovalBody request={request} onCancel={onCancel} \/>\n        ) : (\n          <InputBody request={request} onCancel={onCancel} firstRef={firstRef} \/>\n        )}\n      <\/div>\n    <\/div>\n  );\n}\n\nfunction ApprovalBody({ request, onCancel }: { request: Extract<HumanPromptRequest, { kind: \"approval\" }>; onCancel: () => void }) {\n  return (\n    <>\n      {request.description && <p className=\"ff-prompt__desc\">{request.description}<\/p>}\n      <div className=\"ff-prompt__actions\">\n        <button type=\"button\" className=\"ff-prompt__btn ff-prompt__btn--ghost\" onClick={onCancel}>Cancel<\/button>\n        <button type=\"button\" className=\"ff-prompt__btn ff-prompt__btn--danger\" onClick={() => request.resolve(false)}>Deny<\/button>\n        <button type=\"button\" className=\"ff-prompt__btn ff-prompt__btn--primary\" onClick={() => request.resolve(true)}>Approve<\/button>\n      <\/div>\n    <\/>\n  );\n}\n\nfunction InputBody({\n  request,\n  onCancel,\n  firstRef,\n}: {\n  request: Extract<HumanPromptRequest, { kind: \"input\" }>;\n  onCancel: () => void;\n  firstRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement | null>;\n}) {\n  const [values, setValues] = useState<Record<string, unknown>>(() => initialValues(request.fields));\n  const set = (k: string, v: unknown) => setValues((prev) => ({ ...prev, [k]: v }));\n\n  const missing = request.fields.filter((f) => f.required && (values[f.key] === undefined || values[f.key] === \"\"));\n  const submit = () => { if (missing.length === 0) request.resolve(values); };\n\n  return (\n    <form\n      onSubmit={(e) => { e.preventDefault(); submit(); }}\n      onKeyDown={(e) => { if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submit(); } }}\n    >\n      <div className=\"ff-prompt__fields\">\n        {request.fields.map((f, i) => (\n          <label key={f.key} className=\"ff-prompt__field\">\n            <span className=\"ff-prompt__label\">{f.label ?? f.key}{f.required && <span className=\"ff-prompt__req\"> *<\/span>}<\/span>\n            {renderControl(f, values[f.key], (v) => set(f.key, v), i === 0 ? firstRef : undefined)}\n          <\/label>\n        ))}\n      <\/div>\n      <div className=\"ff-prompt__actions\">\n        <button type=\"button\" className=\"ff-prompt__btn ff-prompt__btn--ghost\" onClick={onCancel}>Cancel<\/button>\n        <button type=\"submit\" className=\"ff-prompt__btn ff-prompt__btn--primary\" disabled={missing.length > 0}>\n          {request.submitLabel || \"Continue\"}\n        <\/button>\n      <\/div>\n    <\/form>\n  );\n}\n\nfunction renderControl(\n  f: HumanField,\n  value: unknown,\n  onChange: (v: unknown) => void,\n  ref?: React.RefObject<HTMLInputElement | HTMLTextAreaElement | null>,\n) {\n  const common = { className: \"ff-prompt__input\", placeholder: f.placeholder };\n  if (f.type === \"textarea\") {\n    return (\n      <textarea\n        {...common}\n        ref={ref as React.RefObject<HTMLTextAreaElement>}\n        rows={3}\n        value={String(value ?? \"\")}\n        onChange={(e) => onChange(e.target.value)}\n      \/>\n    );\n  }\n  if (f.type === \"switch\") {\n    return (\n      <input\n        type=\"checkbox\"\n        className=\"ff-prompt__switch\"\n        checked={!!value}\n        onChange={(e) => onChange(e.target.checked)}\n      \/>\n    );\n  }\n  if (f.type === \"select\" && f.options && f.options.length) {\n    return (\n      <select className=\"ff-prompt__input\" value={String(value ?? \"\")} onChange={(e) => onChange(e.target.value)}>\n        <option value=\"\" disabled>Choose\u2026<\/option>\n        {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}<\/option>)}\n      <\/select>\n    );\n  }\n  return (\n    <input\n      {...common}\n      ref={ref as React.RefObject<HTMLInputElement>}\n      type={f.type === \"number\" ? \"number\" : \"text\"}\n      value={String(value ?? \"\")}\n      onChange={(e) => onChange(f.type === \"number\" ? (e.target.value === \"\" ? \"\" : Number(e.target.value)) : e.target.value)}\n    \/>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/flow-editor\/HumanPrompt.tsx"},{"path":"components\/fancy\/flow-editor\/api.ts","content":"import { createContext, useContext } from \"react\";\r\nimport type { ReactNode } from \"react\";\r\nimport type { Edge } from \"@xyflow\/react\";\r\nimport type { FlowGraph, FlowNode, NodeRunStatus } from \"..\/..\/types\";\r\nimport type { WorkflowSchema } from \"..\/..\/schema\";\r\nimport type { AlignEdge } from \".\/graph-ops\";\r\nimport type { AutoLayoutOptions } from \"..\/..\/layout\";\r\n\r\n\/**\r\n * Everything the editor knows and can do, handed to every extension point.\r\n *\r\n * This is the seam that makes `<FlowEditor>` composable instead of fixed: it is\r\n * passed to custom actions and slots, returned from `useFlowEditor()` inside\r\n * any child, and exposed on the editor `ref` so a host can drive the editor\r\n * imperatively (or an MCP bridge can drive it for an agent).\r\n *\/\r\nexport type FlowEditorApi = {\r\n  \/\/ \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  \/** The current graph. *\/\r\n  graph: FlowGraph;\r\n  nodes: FlowNode[];\r\n  edges: Edge[];\r\n  \/** Currently selected node id, or null. *\/\r\n  selectedId: string | null;\r\n  \/** Currently selected node, or null. *\/\r\n  selected: FlowNode | null;\r\n  \/** Currently selected edge id, or null. Independent of node selection. *\/\r\n  selectedEdgeId: string | null;\r\n  \/** Currently selected edge, or null. *\/\r\n  selectedEdge: Edge | null;\r\n  \/** All multi-selected node ids (xyflow box \/ shift selection). *\/\r\n  selectedIds: string[];\r\n  \/** All multi-selected nodes. *\/\r\n  selectedNodes: FlowNode[];\r\n  \/** True while a run is in flight. *\/\r\n  running: boolean;\r\n  \/** Per-node run status, keyed by node id. *\/\r\n  statuses: Record<string, NodeRunStatus>;\r\n\r\n  \/\/ \u2500\u2500 Selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  select: (id: string | null) => void;\r\n  \/** Select an edge (the connection between two nodes), or clear with null. *\/\r\n  selectEdge: (id: string | null) => void;\r\n\r\n  \/\/ \u2500\u2500 Graph mutation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  \/** Add a node of `kind` at an optional flow position. Returns the new id. *\/\r\n  addNode: (kind: string, position?: { x: number; y: number }) => string | null;\r\n  \/** Replace one node (matched by id). *\/\r\n  updateNode: (node: FlowNode) => void;\r\n  \/** Delete nodes by id, pruning every edge attached to them. *\/\r\n  deleteNodes: (ids: string[]) => void;\r\n  \/** Delete the current selection (no-op when nothing is selected). *\/\r\n  deleteSelected: () => void;\r\n  \/** Delete edges by id \u2014 i.e. break the connections between nodes. *\/\r\n  deleteEdges: (ids: string[]) => void;\r\n  \/** Delete the selected edge (no-op when no edge is selected). *\/\r\n  deleteSelectedEdge: () => void;\r\n  \/** Label a connection, or clear the label by passing undefined\/\"\". *\/\r\n  setEdgeLabel: (id: string, label: string | undefined) => void;\r\n  \/** Copy a node (offset slightly) and select the copy. Returns the new id. *\/\r\n  duplicateNode: (id: string) => string | null;\r\n  \/** Replace the whole graph. *\/\r\n  setGraph: (graph: FlowGraph) => void;\r\n\r\n  \/\/ \u2500\u2500 Bulk (multi-selection) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  \/** Duplicate the current multi-selection, preserving edges between them. *\/\r\n  duplicateSelected: () => void;\r\n  \/** Align the multi-selection to a shared edge\/center of its bounding box. *\/\r\n  alignSelected: (edge: AlignEdge) => void;\r\n  \/** Evenly distribute the multi-selection's gaps along an axis (needs 3+). *\/\r\n  distributeSelected: (axis: \"h\" | \"v\") => void;\r\n\r\n  \/\/ \u2500\u2500 Swimlanes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  \/** Add a swimlane, stacked with existing lanes. Returns the new lane id. *\/\r\n  addLane: (orientation?: \"horizontal\" | \"vertical\", title?: string) => string | null;\r\n  \/** Put a node inside a lane (sets parentId + a lane-relative position). *\/\r\n  assignToLane: (nodeId: string, laneId: string) => void;\r\n  \/** Remove a node from its lane (restores its absolute position). *\/\r\n  removeFromLane: (nodeId: string) => void;\r\n  \/** Auto-arrange the graph (or a lane's children) into a tidy DAG layout. *\/\r\n  autoLayout: (options?: AutoLayoutOptions) => void;\r\n  \/** Tidy just one lane's children. *\/\r\n  tidyLane: (laneId: string) => void;\r\n\r\n  \/\/ \u2500\u2500 Clipboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  \/** Copy the current selection to the editor clipboard. *\/\r\n  copy: () => void;\r\n  \/** Copy then delete the current selection. *\/\r\n  cut: () => void;\r\n  \/** Paste the clipboard (optionally at a flow position) and select the result. *\/\r\n  paste: (at?: { x: number; y: number }) => void;\r\n\r\n  \/\/ \u2500\u2500 Run \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  run: () => void;\r\n  cancel: () => void;\r\n  reset: () => void;\r\n\r\n  \/\/ \u2500\u2500 Workflow I\/O \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  \/** The current graph as a WorkflowSchema (what `export` downloads). *\/\r\n  toWorkflow: () => WorkflowSchema;\r\n  \/** Download the workflow as JSON. *\/\r\n  exportWorkflow: () => void;\r\n  \/** Open a file picker and load a workflow. *\/\r\n  importWorkflow: () => void;\r\n\r\n  \/\/ \u2500\u2500 Viewport \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  fitView: () => void;\r\n\r\n  \/\/ \u2500\u2500 History \u2500\u2500\r\n  \/** Undo the last committing edit (add \/ delete \/ connect \/ config \/ drag \/\r\n   *  import). Transient interactions (a drag in progress, selection) are not\r\n   *  their own steps. *\/\r\n  undo: () => void;\r\n  \/** Redo the last undone edit. *\/\r\n  redo: () => void;\r\n  \/** True when there is an edit to undo. *\/\r\n  canUndo: boolean;\r\n  \/** True when there is an undone edit to redo. *\/\r\n  canRedo: boolean;\r\n};\r\n\r\n\/**\r\n * A declarative toolbar button. JSON-friendly on purpose \u2014 a host (or an\r\n * agent) can describe editor affordances as data rather than JSX.\r\n *\/\r\nexport type FlowEditorAction = {\r\n  \/** Stable id \u2014 also the `data-action` handle, so agents never guess DOM. *\/\r\n  id: string;\r\n  label: ReactNode;\r\n  \/** Native tooltip. *\/\r\n  title?: string;\r\n  \/** Where it sits relative to the built-in buttons. Default \"end\". *\/\r\n  placement?: \"start\" | \"end\";\r\n  \/** Disable when this returns true. Re-evaluated on every render. *\/\r\n  disabled?: (api: FlowEditorApi) => boolean;\r\n  \/** Hide entirely when this returns false. *\/\r\n  visible?: (api: FlowEditorApi) => boolean;\r\n  \/** Only enabled when a node is selected. Sugar over `disabled`. *\/\r\n  requiresSelection?: boolean;\r\n  onSelect: (api: FlowEditorApi) => void;\r\n};\r\n\r\n\/** Which built-in toolbar affordances to render. All default to true. *\/\r\nexport type FlowEditorBuiltins = {\r\n  run?: boolean;\r\n  delete?: boolean;\r\n  \/** Undo\/redo toolbar buttons. Default true. *\/\r\n  history?: boolean;\r\n  \/** \"Add lane\" toolbar button. Default true. *\/\r\n  addLane?: boolean;\r\n  \/** \"Tidy\" (auto-layout) toolbar button. Default true. *\/\r\n  autoLayout?: boolean;\r\n  \/** Right-click a node for Delete \/ Duplicate. Default true. *\/\r\n  contextMenu?: boolean;\r\n  \/** Right-click a connection for Label \/ Delete. Default true. *\/\r\n  edgeContextMenu?: boolean;\r\n  export?: boolean;\r\n  import?: boolean;\r\n  count?: boolean;\r\n};\r\n\r\n\/** Replaceable regions. Each receives the editor API. *\/\r\nexport type FlowEditorSlots = {\r\n  \/** Replace the ENTIRE toolbar (built-ins and actions are not rendered). *\/\r\n  toolbar?: (api: FlowEditorApi) => ReactNode;\r\n  \/** Replace the left palette. *\/\r\n  palette?: (api: FlowEditorApi) => ReactNode;\r\n  \/** Replace the right config panel. *\/\r\n  panel?: (api: FlowEditorApi) => ReactNode;\r\n  \/** Appended inside the config panel, under the fields \u2014 per-node actions. *\/\r\n  panelFooter?: (api: FlowEditorApi) => ReactNode;\r\n  \/** Replace the run feed. *\/\r\n  feed?: (api: FlowEditorApi) => ReactNode;\r\n  \/** Rendered over the canvas when the graph is empty. *\/\r\n  empty?: (api: FlowEditorApi) => ReactNode;\r\n  \/** Replace the node right-click menu. Receives the right-clicked node id;\r\n   *  call `close` when an item is chosen. *\/\r\n  contextMenu?: (api: FlowEditorApi, nodeId: string, close: () => void) => ReactNode;\r\n  \/** Replace the connection right-click menu. Receives the right-clicked edge\r\n   *  id; call `close` when an item is chosen. *\/\r\n  edgeContextMenu?: (api: FlowEditorApi, edgeId: string, close: () => void) => ReactNode;\r\n};\r\n\r\nconst FlowEditorContext = createContext<FlowEditorApi | null>(null);\r\n\r\nexport const FlowEditorProvider = FlowEditorContext.Provider;\r\n\r\n\/**\r\n * Read the editor API from any child of `<FlowEditor>`. Throws outside one, so\r\n * a misplaced custom control fails loudly instead of silently doing nothing.\r\n *\/\r\nexport function useFlowEditor(): FlowEditorApi {\r\n  const api = useContext(FlowEditorContext);\r\n  if (api === null) {\r\n    throw new Error(\"useFlowEditor() must be called inside <FlowEditor>.\");\r\n  }\r\n  return api;\r\n}\r\n\r\n\/** Non-throwing variant, for components that may render outside an editor. *\/\r\nexport function useFlowEditorOptional(): FlowEditorApi | null {\r\n  return useContext(FlowEditorContext);\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/flow-editor\/api.ts"},{"path":"components\/fancy\/flow-editor\/graph-ops.ts","content":"import { reconnectEdge as xyReconnectEdge, type Connection, type Edge } from \"@xyflow\/react\";\nimport type { FlowGraph, FlowNode } from \"..\/..\/types\";\n\n\/**\n * Remove nodes AND every edge attached to them.\n *\n * Deleting a node without pruning its edges leaves danglers that point at\n * nothing \u2014 they survive a round-trip through the schema and blow up the\n * runner, so the two always move together.\n *\/\nexport function removeNodes(graph: FlowGraph, ids: string[]): FlowGraph {\n  if (ids.length === 0) return graph;\n  const doomed = new Set(ids);\n\n  return {\n    nodes: graph.nodes.filter((n) => !doomed.has(n.id)),\n    edges: graph.edges.filter((e) => !doomed.has(e.source) && !doomed.has(e.target)),\n  };\n}\n\n\/** Remove edges by id, leaving nodes untouched. *\/\nexport function removeEdges(edges: Edge[], ids: string[]): Edge[] {\n  if (ids.length === 0) return edges;\n  const doomed = new Set(ids);\n\n  return edges.filter((e) => !doomed.has(e.id));\n}\n\n\/**\n * Set (or clear) an edge's label.\n *\n * An empty\/whitespace label is removed rather than stored as `\"\"` \u2014 xyflow\n * renders an empty label as an empty chip on the wire, and a blank key would\n * survive export into the workflow schema.\n *\/\nexport function setEdgeLabel(edges: Edge[], id: string, label: string | undefined): Edge[] {\n  const next = label?.trim();\n  return edges.map((e) => {\n    if (e.id !== id) return e;\n    if (!next) {\n      const { label: _drop, ...rest } = e;\n      return rest as Edge;\n    }\n    return { ...e, label: next };\n  });\n}\n\n\/** Copy a node with a fresh id, offset so it doesn't sit exactly on top. *\/\nexport function duplicateNode(node: FlowNode, id: string, offset = 40): FlowNode {\n  return {\n    ...node,\n    id,\n    position: { x: node.position.x + offset, y: node.position.y + offset },\n    \/\/ Deep copy so the clone's config edits don't mutate the original.\n    data: JSON.parse(JSON.stringify(node.data ?? {})),\n  } as FlowNode;\n}\n\n\/**\n * Clone a set of nodes AND the edges internal to that set, remapping every id.\n * This is the piece `duplicateNode` lacks: a copy\/paste (or duplicate-selection)\n * that preserves the wiring *between* the copied nodes. Edges with an endpoint\n * outside the set are dropped (they'd dangle). A `parentId` pointing inside the\n * set is remapped; one pointing outside is detached.\n *\/\nexport function cloneSubgraph(\n  nodes: FlowNode[],\n  edges: Edge[],\n  opts: { makeId: () => string; offset?: number },\n): { nodes: FlowNode[]; edges: Edge[]; idMap: Map<string, string> } {\n  const offset = opts.offset ?? 40;\n  const idMap = new Map<string, string>();\n  for (const n of nodes) idMap.set(n.id, opts.makeId());\n\n  const clonedNodes = nodes.map((n) => {\n    const cloned = duplicateNode(n, idMap.get(n.id)!, offset) as any;\n    const parentId = (n as any).parentId;\n    if (parentId && idMap.has(parentId)) cloned.parentId = idMap.get(parentId);\n    else if (parentId) delete cloned.parentId;\n    return cloned as FlowNode;\n  });\n\n  const clonedEdges = edges\n    .filter((e) => idMap.has(e.source) && idMap.has(e.target))\n    .map((e) => ({ ...e, id: opts.makeId(), source: idMap.get(e.source)!, target: idMap.get(e.target)! }));\n\n  return { nodes: clonedNodes, edges: clonedEdges, idMap };\n}\n\n\/**\n * Rewire one edge's endpoint(s) \u2014 wraps xyflow's `reconnectEdge`. Keeps the\n * edge's id (`shouldReplaceId: false`) so its label and identity survive the\n * rewire instead of being regenerated from the new endpoints.\n *\/\nexport function reconnectEdge(edges: Edge[], oldEdge: Edge, newConnection: Connection): Edge[] {\n  return xyReconnectEdge(oldEdge, newConnection, edges, { shouldReplaceId: false });\n}\n\nexport type AlignEdge = \"left\" | \"hcenter\" | \"right\" | \"top\" | \"vcenter\" | \"bottom\";\n\nconst nodeW = (n: FlowNode): number => (n as any).width ?? (n as any).measured?.width ?? 0;\nconst nodeH = (n: FlowNode): number => (n as any).height ?? (n as any).measured?.height ?? 0;\n\n\/** Align a selection to a shared edge\/center of its bounding box. *\/\nexport function alignNodes(nodes: FlowNode[], edge: AlignEdge): FlowNode[] {\n  if (nodes.length < 2) return nodes;\n  const minL = Math.min(...nodes.map((n) => n.position.x));\n  const maxR = Math.max(...nodes.map((n) => n.position.x + nodeW(n)));\n  const minT = Math.min(...nodes.map((n) => n.position.y));\n  const maxB = Math.max(...nodes.map((n) => n.position.y + nodeH(n)));\n  const cx = (minL + maxR) \/ 2;\n  const cy = (minT + maxB) \/ 2;\n  return nodes.map((n) => {\n    let { x, y } = n.position;\n    if (edge === \"left\") x = minL;\n    else if (edge === \"right\") x = maxR - nodeW(n);\n    else if (edge === \"hcenter\") x = cx - nodeW(n) \/ 2;\n    else if (edge === \"top\") y = minT;\n    else if (edge === \"bottom\") y = maxB - nodeH(n);\n    else if (edge === \"vcenter\") y = cy - nodeH(n) \/ 2;\n    return { ...n, position: { x, y } };\n  });\n}\n\n\/**\n * Order nodes so every parent precedes its children \u2014 xyflow misrenders (and\n * warns) when a child appears before its `parentId` in the array. Stable\n * otherwise. Apply at the render boundary once grouping is in play.\n *\/\nexport function sortNodesParentFirst<T extends { id: string; parentId?: string }>(nodes: T[]): T[] {\n  const byId = new Map(nodes.map((n) => [n.id, n] as const));\n  const seen = new Set<string>();\n  const out: T[] = [];\n  const visit = (n: T) => {\n    if (seen.has(n.id)) return;\n    const pid = (n as any).parentId as string | undefined;\n    if (pid && byId.has(pid) && !seen.has(pid)) visit(byId.get(pid)!);\n    seen.add(n.id);\n    out.push(n);\n  };\n  for (const n of nodes) visit(n);\n  return out;\n}\n\n\/** Absolute position of a node, accounting for one level of parent nesting. *\/\nfunction absolutePosition(node: FlowNode, nodes: FlowNode[]): { x: number; y: number } {\n  const pid = (node as any).parentId as string | undefined;\n  if (!pid) return node.position;\n  const parent = nodes.find((n) => n.id === pid);\n  return parent ? { x: node.position.x + parent.position.x, y: node.position.y + parent.position.y } : node.position;\n}\n\n\/**\n * Put a node inside a lane\/container: set `parentId` + `extent:'parent'` and\n * convert its position to be relative to the lane. Idempotent-safe; a no-op if\n * either id is missing or they're the same node.\n *\/\nexport function assignToLane(nodes: FlowNode[], nodeId: string, laneId: string): FlowNode[] {\n  if (nodeId === laneId) return nodes;\n  const node = nodes.find((n) => n.id === nodeId);\n  const lane = nodes.find((n) => n.id === laneId);\n  if (!node || !lane) return nodes;\n  const abs = absolutePosition(node, nodes);\n  const laneAbs = absolutePosition(lane, nodes);\n  const rel = { x: abs.x - laneAbs.x, y: abs.y - laneAbs.y };\n  return nodes.map((n) => (n.id === nodeId ? ({ ...n, parentId: laneId, extent: \"parent\", position: rel } as FlowNode) : n));\n}\n\n\/** Take a node out of its lane: drop `parentId`\/`extent`, restore absolute position. *\/\nexport function removeFromLane(nodes: FlowNode[], nodeId: string): FlowNode[] {\n  const node = nodes.find((n) => n.id === nodeId);\n  if (!node || !(node as any).parentId) return nodes;\n  const abs = absolutePosition(node, nodes);\n  return nodes.map((n) => {\n    if (n.id !== nodeId) return n;\n    const { parentId: _p, extent: _e, ...rest } = n as any;\n    return { ...rest, position: abs } as FlowNode;\n  });\n}\n\n\/**\n * Stack lanes into contiguous rows (horizontal) or columns (vertical) \u2014 the\n * \"fixed rows\/columns\" behavior. `isLane` picks which nodes are lanes; they're\n * ordered by their current cross-axis position and repacked from the first\n * lane's origin. Each lane keeps its own size (independently resizable).\n *\/\nexport function stackLanes(\n  nodes: FlowNode[],\n  isLane: (n: FlowNode) => boolean,\n  opts: { orientation?: \"horizontal\" | \"vertical\"; gap?: number } = {},\n): FlowNode[] {\n  const orientation = opts.orientation ?? \"horizontal\";\n  const gap = opts.gap ?? 12;\n  const vertical = orientation === \"vertical\";\n  const lanes = nodes.filter(isLane).sort((a, b) => (vertical ? a.position.x - b.position.x : a.position.y - b.position.y));\n  if (lanes.length === 0) return nodes;\n  const originX = lanes[0].position.x;\n  const originY = lanes[0].position.y;\n  const moves = new Map<string, { x: number; y: number }>();\n  let cursor = vertical ? originX : originY;\n  for (const lane of lanes) {\n    moves.set(lane.id, vertical ? { x: cursor, y: originY } : { x: originX, y: cursor });\n    cursor += (vertical ? ((lane as any).width ?? 320) : ((lane as any).height ?? 160)) + gap;\n  }\n  return nodes.map((n) => (moves.has(n.id) ? { ...n, position: moves.get(n.id)! } : n));\n}\n\n\/** Evenly distribute a selection's gaps along an axis (needs 3+ nodes). *\/\nexport function distributeNodes(nodes: FlowNode[], axis: \"h\" | \"v\"): FlowNode[] {\n  if (nodes.length < 3) return nodes;\n  const size = (n: FlowNode) => (axis === \"h\" ? nodeW(n) : nodeH(n));\n  const coord = (n: FlowNode) => (axis === \"h\" ? n.position.x : n.position.y);\n  const sorted = [...nodes].sort((a, b) => coord(a) - coord(b));\n  const start = coord(sorted[0]);\n  const last = sorted[sorted.length - 1];\n  const end = coord(last) + size(last);\n  const totalSize = sorted.reduce((s, n) => s + size(n), 0);\n  const gap = (end - start - totalSize) \/ (sorted.length - 1);\n  const posById = new Map<string, number>();\n  let cursor = start;\n  for (const n of sorted) {\n    posById.set(n.id, cursor);\n    cursor += size(n) + gap;\n  }\n  return nodes.map((n) => {\n    const p = posById.get(n.id)!;\n    return { ...n, position: axis === \"h\" ? { ...n.position, x: p } : { ...n.position, y: p } };\n  });\n}\n","type":"registry:ui","target":"components\/fancy\/flow-editor\/graph-ops.ts"},{"path":"components\/fancy\/flow-editor\/index.ts","content":"export { FlowEditor, type FlowEditorProps } from \".\/FlowEditor\";\r\n\/\/ The in-editor human-input modal + its field-normalizer, exported so a host can\r\n\/\/ reuse them (e.g. in a custom run harness) or render the modal themselves.\r\nexport { HumanPrompt, humanInputFields, type HumanField, type HumanPromptRequest } from \".\/HumanPrompt\";\r\nexport {\r\n  useFlowEditor,\r\n  useFlowEditorOptional,\r\n  type FlowEditorApi,\r\n  type FlowEditorAction,\r\n  type FlowEditorBuiltins,\r\n  type FlowEditorSlots,\r\n} from \".\/api\";\r\n\/\/ Pure graph operations \u2014 reusable by hosts building custom editors and by the\r\n\/\/ agent bridge, so bridge and canvas share one implementation (no drift).\r\nexport {\r\n  cloneSubgraph,\r\n  reconnectEdge,\r\n  alignNodes,\r\n  distributeNodes,\r\n  duplicateNode,\r\n  removeNodes,\r\n  removeEdges,\r\n  setEdgeLabel,\r\n  type AlignEdge,\r\n} from \".\/graph-ops\";\r\n","type":"registry:ui","target":"components\/fancy\/flow-editor\/index.ts"}]}