import { Background, BackgroundVariant, type Connection, type EdgeChange, type Node as FlowCanvasNode, type NodeChange, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, useStore, useUpdateNodeInternals, } from "@xyflow/react" import "@xyflow/react/dist/style.css" import { useMutation, useQuery, useQueryClient, useSuspenseQuery, } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { Workflow } from "lucide-react" import { motion } from "motion/react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { type FlowDef_Input, type FlowDetail, FlowsService, type MessageSpec, type NodeDef_Input, } from "@/client" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import useCustomToast from "@/hooks/useCustomToast" import { useIsMobile } from "@/hooks/useMobile" import { scaleIn } from "@/lib/motion" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { CanvasTitle } from "./CanvasTitle" import { CommandPalette } from "./CommandPalette" import { bindingsKey, boundaryKey, deriveEdges, portOf, qualify, } from "./deriveEdges" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EndpointNode } from "./EndpointNode" import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints" import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock" import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowPanel } from "./FlowPanel" import { LiveEdge } from "./LiveEdge" import { type Direction, layoutGraph, nodeHeight } from "./layout" import { NodePanel } from "./NodePanel" import { RunDialog } from "./RunDialog" import "./flow.css" import { liveStore, useFlowPaused } from "./liveStore" import { flowKeys, flowQueryOptions, flowsQueryOptions, nodeTypesQueryOptions, useAutosave, useDiscardDraft, usePublish, } from "./queries" import { useFlowSocket } from "./useFlowSocket" const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode } /** Is this canvas node actually part of the flow document? */ const isDocumentNode = (node: { id: string }) => !isEndpointNode(node) const edgeTypes = { live: LiveEdge } type Rebind = { nodeId: string nodeLabel: string port: string from: string to: string dtype: MessageSpec["dtype"] } /** A finished message rename, waiting on an answer about the rest of the flow. */ type MessageRename = { from: string to: string /** How many other nodes are still bound to the old name. */ count: number } /** Documents to step back and forward through, newest last. */ type History = { past: FlowDef_Input[] future: FlowDef_Input[] /** When the last entry was recorded, so a burst of typing stays one edit. */ at: number } const HISTORY_LIMIT = 50 const TYPING_WINDOW = 500 /** Nodes copied here, and the code of the ones carrying their own. */ type NodeClipboard = { nodes: NodeDef_Input[] sources: Record } // ponytail: localStorage, so a copy survives a flow switch or a second tab but // not a second browser. The system clipboard if that ever matters. const CLIPBOARD_KEY = "fluksio.nodeClipboard" /** * Remember the document as it was before a change. * * Fields commit on every keystroke, so consecutive edits that leave the same * nodes in place fold into the entry already on the stack. Anything that adds * or removes a node is a finished action and starts its own. */ function record( history: History, previous: FlowDef_Input, next: FlowDef_Input, settled: boolean, ) { const before = previous.nodes ?? [] const after = next.nodes ?? [] const sameNodes = before.length === after.length && before.every((node, index) => node.id === after[index].id) const stillTyping = !settled && sameNodes && Date.now() - history.at < TYPING_WINDOW if (!stillTyping) { history.past.push(previous) if (history.past.length > HISTORY_LIMIT) history.past.shift() } history.at = settled ? 0 : Date.now() // A new change is a new branch: what was undone is not coming back. history.future = [] } /** Positions come from the layout, so xyflow's own state only tracks identity. */ function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] { return definitions.map((node) => ({ id: node.id, type: "flow", position: { x: 0, y: 0 }, data: {}, })) } /** * The dot grid lives in flow space, so React Flow shrinks it along with the * zoom. Below 1:1 that leaves sub-pixel dots on a fractional grid, which the * canvas can only render as a moiré haze — the whole viewport reads as blurry. * Taking the zoom back out of both the radius and the spacing gives the same * crisp 1.5px dots, 24px apart, whatever the zoom is. */ function CanvasBackground() { const zoom = useStore((state) => state.transform[2]) // In octaves, so the grid halves rather than drifting as you zoom. const step = 2 ** Math.round(Math.log2(1 / zoom)) return ( ) } /** A name that does not collide with the nodes already on the canvas. */ function uniqueNodeId(existing: NodeDef_Input[], type: string): string { const taken = new Set(existing.map((node) => node.id)) for (let index = 1; ; index += 1) { const candidate = index === 1 ? type : `${type}_${index}` if (!taken.has(candidate)) return candidate } } function FlowEditorInner({ flowName, focus, onReload, }: { flowName: string /** A node to arrive on, from the address bar. */ focus?: string onReload: () => void }) { const navigate = useNavigate() const queryClient = useQueryClient() const { showErrorToast, showSuccessToast } = useCustomToast() const { fitView } = useReactFlow() const updateNodeInternals = useUpdateNodeInternals() const { data: flows } = useSuspenseQuery(flowsQueryOptions()) const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName)) const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions()) const { save, flush, conflict, resolveConflict, mutation: saving, } = useAutosave(flowName) const publish = usePublish(flowName) const discard = useDiscardDraft(flowName) // The whole working document, so a flow-level edit is an edit like any // other — undoable, and on screen before the server has seen it. const [flowDoc, setFlowDoc] = useState(() => detail.definition) const definitions = useMemo(() => flowDoc.nodes ?? [], [flowDoc]) const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes( detail.definition.nodes ?? [], ) const [selectedId, setSelectedId] = useState(focus ?? null) const [paletteOpen, setPaletteOpen] = useState(false) const [inspected, setInspected] = useState(null) // The edges are derived, so xyflow's own selection would be thrown away on // every rebuild: the ids live here instead and are marked on at render. const [selectedEdges, setSelectedEdges] = useState>( () => new Set(), ) const [rebind, setRebind] = useState(null) const [renamed, setRenamed] = useState(null) const [flowPanelOpen, setFlowPanelOpen] = useState(false) // A batch run is asked for its parameters before it is submitted. const [runOpen, setRunOpen] = useState(false) // Throwing an edit away is offered from the dock, so its confirmation lives // here rather than inside the settings panel. const [discardOpen, setDiscardOpen] = useState(false) // The editor at full size takes the width the canvas chrome does not need. const [editorExpanded, setEditorExpanded] = useState(false) // The dock hosts the logs, but a failing node opens them too, at its own // lines — so which node and whether it is open belong here. const [logsOpen, setLogsOpen] = useState(false) const [logsNode, setLogsNode] = useState(null) const issues = detail.issues ?? [] const paused = useFlowPaused(flowName) // Keep the latest document in a ref so autosave never captures a stale copy. const latest = useRef(detail.definition) const history = useRef({ past: [], future: [], at: 0 }) /** Put a document on the canvas and on its way to the server. */ const applyDoc = useCallback( (next: FlowDef_Input) => { setFlowDoc(next) latest.current = next save(next) }, [save], ) /** Apply a change to the whole document and make it undoable. */ const commitDoc = useCallback( (next: FlowDef_Input, settled = false) => { record(history.current, latest.current, next, settled) applyDoc(next) }, [applyDoc], ) /** The same, for the changes that only touch the nodes. */ const commit = useCallback( (nodes: NodeDef_Input[]) => { commitDoc({ ...latest.current, nodes }) }, [commitDoc], ) /** * Step through the history. Undo and redo save like any other edit, so the * autosave debounce collapses a run of them into one write. */ const step = useCallback( (back: boolean) => { const { past, future } = history.current const remembered = (back ? past : future).pop() if (!remembered) return ;(back ? future : past).push(latest.current) history.current.at = 0 // xyflow owns the positions, so hand it the remembered ones too. setCanvasNodes(toCanvasNodes(remembered.nodes ?? [])) applyDoc(remembered) setInspected(null) }, [applyDoc, setCanvasNodes], ) const typeLabels = useMemo( () => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])), [nodeTypeInfo], ) // Which types bring code of their own, so a copy has something to carry. const sourceTypes = useMemo( () => new Set( (nodeTypeInfo ?? []) .filter((info) => info.has_source) .map((info) => info.type), ), [nodeTypeInfo], ) // Which types came from an installed connector rather than the engine. They // cannot be in the icon map, so they share one. const pluginTypes = useMemo( () => new Set( (nodeTypeInfo ?? []) .filter((info) => info.plugin) .map((info) => info.type), ), [nodeTypeInfo], ) const issuesByNode = useMemo(() => { const map = new Map() for (const issue of issues) { if (!issue.node) continue const list = map.get(issue.node) ?? [] list.push(issue.message) map.set(issue.node, list) } return map }, [issues]) const showLogs = useCallback((nodeId: string) => { setLogsNode(nodeId) setLogsOpen(true) }, []) // Canvas nodes carry the definition so the node component can render it. const renderedNodes = useMemo( () => canvasNodes.map((node) => { const definition = definitions.find((entry) => entry.id === node.id) const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? [] return { ...node, selected: node.selected || node.id === selectedId, data: { definition: definition ?? { id: node.id }, flow: flowName, typeLabel: typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "", isPlugin: pluginTypes.has(definition?.type ?? ""), issueText: nodeIssues.join("\n"), onShowLogs: showLogs, } satisfies FlowNodeData, } }), [ canvasNodes, definitions, flowName, issuesByNode, pluginTypes, selectedId, showLogs, typeLabels, ], ) // A cheap fingerprint of the wiring, and the only thing the layout depends // on: what the graph looks like follows from what is wired to what. The // flow's own boundary is drawn too, so a declared input counts as wiring. const key = `${bindingsKey(definitions)}|${boundaryKey(flowDoc)}` // Offer the names already in play: everything published is worth reading, // and an input nobody provides yet is worth publishing. // biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes names. const suggestions = useMemo(() => { const provided = new Set() const consumed = new Set() for (const node of definitions) { for (const spec of node.provides ?? []) { if (spec.name) provided.add(spec.name) } for (const spec of node.requires ?? []) { if (spec.name) consumed.add(spec.name) } } return { consumes: [...provided].sort(), provides: [...consumed].filter((name) => !provided.has(name)).sort(), } }, [key]) /** Where clicking an endpoint takes you: the thing it stands for. */ const openEndpoint = useCallback( (id: string) => { const [kind, rest] = id.split(":", 2) if (kind === "input" || kind === "output") { // This flow's own boundary, declared in its settings. setSelectedId(null) setFlowPanelOpen(true) } else if (kind === "dashboard") { navigate({ to: "/dashboards/$name", params: { name: (rest ?? "").split(":")[0] }, }) } else if (kind === "flow") { navigate({ to: "/flows/$flowName", params: { flowName: (rest ?? "").split(".")[0] }, }) } }, [navigate], ) // React Flow measures a node once and keeps the size on it. An endpoint is // not in `canvasNodes`, so the measurement it reports back has nowhere to // land: without carrying it over by hand the endpoint arrives unmeasured on // the next render, and React Flow draws an unmeasured node hidden, taking // the edges attached to it with it. const measured = useRef(new Map()) const trackMeasured = useCallback( (changes: NodeChange[]) => { for (const change of changes) { if (change.type !== "dimensions" || !change.dimensions) continue if (isEndpointNode({ id: change.id })) { measured.current.set(change.id, change.dimensions) } } onNodesChange(changes) }, [onNodesChange], ) /** Selection is all an edge change can carry here: nothing else is stored. */ const onEdgesChange = useCallback((changes: EdgeChange[]) => { const selections = changes.filter((change) => change.type === "select") if (!selections.length) return setSelectedEdges((current) => { const next = new Set(current) for (const change of selections) { if (change.selected) next.add(change.id) else next.delete(change.id) } return next }) }, []) // Dashboards and other flows wired into this one. They are drawn but never // stored: they join at render, after everything that reads or writes // canvasNodes, so an autosave, an undo or a delete cannot reach them. // biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on. const external = useMemo(() => { const built = deriveEndpoints( detail.endpoints ?? [], definitions, flowName, flowDoc, ) return { ...built, nodes: built.nodes.map((node) => { const size = measured.current.get(node.id) return size ? { ...node, measured: size, ...size } : node }), } }, [detail.endpoints, key, flowName]) // Edges follow from the name bindings, so they are derived, never stored. // biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every render. const edges = useMemo( () => [...deriveEdges(definitions, flowName), ...external.edges], [key, flowName, detail.endpoints], ) const isMobile = useIsMobile() // Which way the graph runs. A phone has height to spare and no width, so it // reads top to bottom; everything else reads left to right. const direction: Direction = isMobile ? "TB" : "LR" // Expanding is a desktop affordance, so a window narrowed past `md` gives the // room back: the sheet it becomes has no second column to hold, and its body // only scrolls while the editor is its normal size. useEffect(() => { if (isMobile) setEditorExpanded(false) }, [isMobile]) /** * Nobody places a node here — the graph lays itself out, endpoints included, * so a producer lands upstream of what it feeds without a lane of its own. * * Keyed on which nodes exist and how they are wired, never on the node * objects: React Flow writes measurements back through `onNodesChange`, so * their identity changes constantly and the layout would run on every frame. */ const ids = [ ...canvasNodes.map((node) => node.id), ...external.nodes.map((node) => node.id), ] const shapeKey = `${direction}|${key}|${ids.join(",")}` // How tall each node's ports make it, which `FlowNode` draws to the same // number. A function of the document — the bindings key above already covers // every port, so this changes exactly when the layout has to run again, and // nothing measured is ever fed back into it. const heights = direction === "LR" ? new Map( definitions.map((node) => [ node.id, nodeHeight( Math.max( (node.requires ?? []).length, (node.provides ?? []).length, ), ), ]), ) : new Map() // biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render. const positions = useMemo( () => layoutGraph(ids, edges, direction, heights), [shapeKey, edges], ) /** * The endpoints, placed. * * Memoised rather than mapped at render: React Flow keeps a node's * measurement against the object it measured, and an endpoint is not in * `canvasNodes`, so handing over a fresh one every render would leave it * permanently unmeasured — which React Flow draws as hidden. */ const externalNodes = useMemo( () => external.nodes.map((node) => ({ ...node, position: positions.get(node.id) ?? node.position, })), [external, positions], ) // Marked after the layout has had the derived array, so selecting an edge // never sends the graph through the layout again. const shownEdges = useMemo( () => edges.map((edge) => ({ ...edge, selected: selectedEdges.has(edge.id) })), [edges, selectedEdges], ) const shownNodes = useMemo( () => [ ...renderedNodes.map((node) => ({ ...node, position: positions.get(node.id) ?? node.position, })), ...externalNodes, ], [renderedNodes, externalNodes, positions], ) // Editing ports adds and removes handles, and flipping direction moves them // to the other side. React Flow measures those once, so it has to be told, // or an edge to a brand-new handle never gets drawn. // biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles. useEffect(() => { updateNodeInternals([ ...definitions.map((node) => node.id), ...external.nodes.map((node) => node.id), ]) }, [key, direction, external, updateNodeInternals]) // The canvas is not remounted per node — that would throw the session away // on every click in the brain graph — so arriving at a flow already open // only changes the address. Seeded above for the first arrival, set here for // the ones after it. useEffect(() => { if (focus) setSelectedId(focus) }, [focus]) const selected = definitions.find((node) => node.id === selectedId) ?? null // A panel is the view you are working in, so it takes the room — but never // the lanes the bars sit in: publishing is most wanted right after editing. const panelOpen = Boolean(selected) || flowPanelOpen // One effect owns the viewport, so nothing fights over it. Selecting a node // brings that node into the lane the panel leaves; every other change to the // graph — new wiring, a new endpoint, a panel opening — re-fits the whole flow // into the same lane. Which of the two runs is decided by what changed, not by // what is true: a selection centres once, and the port edits that follow it // re-fit around it, because a new edge's far end is what wants to be seen. // The first fit is instant: an animated one travels from React Flow's // default viewport to the content, which is the whole flow visibly sliding // in from the corner every time one is opened. Later fits move from // somewhere the user was already looking, so those stay animated. const fitted = useRef(false) const centred = useRef(null) // biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape or the panel changes, not on every render. useEffect(() => { const focus = selectedId && selectedId !== centred.current ? selectedId : null centred.current = selectedId // A phone's sheet covers the canvas outright, and so does the expanded // editor: there is no viewport to aim. if (editorExpanded) return const frame = requestAnimationFrame(() => { const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW const duration = fitted.current ? 300 : 0 fitView( focus ? { ...view, nodes: [{ id: focus }], duration } : { ...view, duration }, ) fitted.current = true }) return () => cancelAnimationFrame(frame) }, [ direction, definitions.length, external.nodes.length, edges.length, selectedId, panelOpen, editorExpanded, isMobile, fitView, ]) const runMutation = useMutation({ mutationFn: (inputs: Record = {}) => FlowsService.runFlow({ name: flowName, requestBody: { inputs } }), onSuccess: (state) => { liveStore.setValues( Object.fromEntries( Object.entries(state.values ?? {}).map(([name, value]) => [ name, { value: value.value, ts: value.ts ?? null }, ]), ), ) }, onError: () => showErrorToast("The flow could not run. Check the node errors."), }) /** * Pressing Run. * * A batch flow is submitted as a run, and a run is identified by its * parameters — so it asks for them rather than quietly using the defaults. */ const startRun = useCallback(async () => { // Running executes what is stored, so the queued edit goes first. await flush() if (latest.current.mode === "batch") setRunOpen(true) else runMutation.mutate({}) }, [flush, runMutation]) const enableMutation = useMutation({ mutationFn: (next: boolean) => next ? FlowsService.startFlow({ name: flowName }) : FlowsService.stopFlow({ name: flowName }), onSuccess: (detail) => { queryClient.setQueryData(flowKeys.detail(flowName), detail) queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) }, onError: () => showErrorToast("The flow could not be started or stopped."), }) const pauseMutation = useMutation({ mutationFn: (next: boolean) => next ? FlowsService.pauseFlow({ name: flowName }) : FlowsService.resumeFlow({ name: flowName }), // The engine answers with a flow_paused event, which is what the dock reads. onError: () => showErrorToast("The flow could not be paused."), }) const stepMutation = useMutation({ mutationFn: () => FlowsService.stepFlow({ name: flowName }), // Says which node ran, or that nothing was held back — both are answers. onSuccess: (result) => showSuccessToast(result.message), onError: () => showErrorToast("The flow could not be stepped."), }) const deleteMutation = useMutation({ mutationFn: () => FlowsService.deleteFlow({ name: flowName }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: flowKeys.all }) setFlowPanelOpen(false) navigate({ to: "/flows", replace: true }) }, onError: () => showErrorToast("The flow could not be deleted."), }) const sourceMutation = useMutation({ mutationFn: ({ nodeId, code }: { nodeId: string; code: string }) => FlowsService.saveNodeSource({ name: flowName, nodeId, requestBody: { code }, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["flows", flowName] }) }, }) const addNode = useCallback( (type: string, sourceRef?: string) => { const id = uniqueNodeId(definitions, sourceRef ?? type) const node: NodeDef_Input = { id, type, params: {}, requires: [], provides: [], // A shared node brings the code; the ports and settings are this // flow's own. ...(sourceRef ? { source_ref: sourceRef } : {}), } // Unwired, so the layout puts it in a rank of its own until it is bound. setCanvasNodes([ ...canvasNodes, { id, type: "flow", position: { x: 0, y: 0 }, data: {} }, ]) commit([...definitions, node]) setSelectedId(id) }, [canvasNodes, commit, definitions, setCanvasNodes], ) const updateNode = useCallback( (next: NodeDef_Input) => { commit(definitions.map((node) => (node.id === next.id ? next : node))) }, [commit, definitions], ) const deleteNodes = useCallback( (ids: string[]) => { const remaining = definitions.filter((node) => !ids.includes(node.id)) setCanvasNodes(canvasNodes.filter((node) => !ids.includes(node.id))) commit(remaining) if (selectedId && ids.includes(selectedId)) setSelectedId(null) }, [canvasNodes, commit, definitions, selectedId, setCanvasNodes], ) const applyBinding = useCallback( (nodeId: string, port: string, message: string) => { commit( definitions.map((node) => node.id === nodeId ? { ...node, requires: (node.requires ?? []).map((spec) => portOf(spec) === port ? { ...spec, name: message, port } : spec, ), } : node, ), ) }, [commit, definitions], ) /** * Dragging output to input is shorthand for "consume what that node * publishes": it points the input at the producer's message name. */ const onConnect = useCallback( (connection: Connection) => { const producer = definitions.find((node) => node.id === connection.source) const consumer = definitions.find((node) => node.id === connection.target) if (!producer || !consumer) return const outSpec = (producer.provides ?? []).find( (spec) => portOf(spec) === connection.sourceHandle, ) const inSpec = (consumer.requires ?? []).find( (spec) => portOf(spec) === connection.targetHandle, ) if (!outSpec?.name || !inSpec) return // Already reading something else: the user may want either message, so // offer the extra port rather than assuming a replacement. if (inSpec.name && inSpec.name !== outSpec.name) { setRebind({ nodeId: consumer.id, nodeLabel: consumer.title || consumer.id, port: portOf(inSpec), from: inSpec.name, to: outSpec.name, dtype: outSpec.dtype, }) return } applyBinding(consumer.id, portOf(inSpec), outSpec.name) }, [definitions, applyBinding], ) /** Give the consumer a second input, bound to the producer's message. */ const addInputPort = useCallback( (nodeId: string, message: string, dtype: MessageSpec["dtype"]) => { commit( definitions.map((node) => node.id === nodeId ? { ...node, requires: [ ...(node.requires ?? []), { name: message, port: "", dtype }, ], } : node, ), ) }, [commit, definitions], ) const unbind = useCallback( (message: string) => { const qualified = qualify(flowName, message) commit( definitions.map((node) => ({ ...node, requires: (node.requires ?? []).map((spec) => qualify(flowName, spec.name ?? "") === qualified ? { ...spec, name: "", port: portOf(spec) } : spec, ), })), ) setInspected(null) }, [commit, definitions, flowName], ) /** * A message name is shared, not owned: renaming it where it is published * strands every node still reading the old one. Offer to bring them along * rather than deciding for the user. */ const proposeRename = useCallback( (from: string, to: string) => { if (!from || !to) return const message = qualify(flowName, from) const bound = definitions.filter((node) => [...(node.requires ?? []), ...(node.provides ?? [])].some( (spec) => qualify(flowName, spec.name ?? "") === message, ), ) if (bound.length) setRenamed({ from, to, count: bound.length }) }, [definitions, flowName], ) /** Carry a rename to everything bound to the old name, as one edit. */ const applyRename = useCallback( ({ from, to }: MessageRename) => { const message = qualify(flowName, from) const follow = (specs: MessageSpec[] | undefined) => (specs ?? []).map((spec) => qualify(flowName, spec.name ?? "") === message ? { ...spec, name: to, port: "" } : spec, ) commit( definitions.map((node) => ({ ...node, requires: follow(node.requires), provides: follow(node.provides), })), ) setRenamed(null) }, [commit, definitions, flowName], ) const focusNode = useCallback( (qualifiedId: string) => { const id = qualifiedId.startsWith(`${flowName}.`) ? qualifiedId.slice(flowName.length + 1) : qualifiedId setSelectedId(id) }, [flowName], ) /** Put the stored draft live. Publishing what is queued means saving first. */ const publishFlow = useCallback(async () => { await flush() // Publish what was actually stored: the version only advances once the // queued save has landed. const current = queryClient.getQueryData( flowKeys.detail(flowName), ) publish.mutate(current?.definition.version ?? 1) }, [flowName, flush, publish.mutate, queryClient]) /** Copy the selected nodes, with the code of the ones carrying their own. */ const copyNodes = useCallback(async () => { const ids = new Set( canvasNodes.filter((node) => node.selected).map((node) => node.id), ) if (selectedId) ids.add(selectedId) const picked = definitions.filter((node) => ids.has(node.id)) if (!picked.length) return const sources: Record = {} await Promise.all( picked // A shared node already points at the library copy; only a private // one has code that has to travel with it. .filter((node) => !node.source_ref && sourceTypes.has(node.type ?? "")) .map(async (node) => { const { code } = await FlowsService.readNodeSource({ name: flowName, nodeId: node.id, }) sources[node.id] = code }), ).catch(() => undefined) localStorage.setItem( CLIPBOARD_KEY, JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard), ) }, [canvasNodes, definitions, flowName, selectedId, sourceTypes]) /** * Paste them here, renamed around whatever this flow already holds. The * document only names a node's code, so the copy is written per node once * the definition is on its way. */ const pasteNodes = useCallback(() => { const stored = localStorage.getItem(CLIPBOARD_KEY) if (!stored) return let clipboard: NodeClipboard try { clipboard = JSON.parse(stored) } catch { return } let pool = definitions const pasted: NodeDef_Input[] = [] for (const node of clipboard.nodes ?? []) { const copy = { ...node, id: uniqueNodeId(pool, node.id) } pool = [...pool, copy] pasted.push(copy) } if (!pasted.length) return setCanvasNodes([ ...canvasNodes, ...pasted.map((node) => ({ id: node.id, type: "flow", position: { x: 0, y: 0 }, data: {}, })), ]) commit(pool) setSelectedId(pasted[pasted.length - 1].id) clipboard.nodes.forEach((node, index) => { const code = clipboard.sources?.[node.id] if (code !== undefined) { sourceMutation.mutate({ nodeId: pasted[index].id, code }) } }) }, [canvasNodes, commit, definitions, setCanvasNodes, sourceMutation.mutate]) useShortcuts( { "mod+z": () => step(true), "mod+shift+z": () => step(false), "mod+c": () => void copyNodes(), "mod+v": pasteNodes, "mod+k": () => setPaletteOpen((open) => !open), // Inside the code editor ⌘S applies that code, which the node panel // owns; anywhere else on the canvas it puts the flow live. "mod+s": (event) => { if (!inCodeEditor(event.target)) void publishFlow() }, }, // Both stay reachable while typing: one is the editor's own save, the // other is how you reach anything at all. ["mod+s", "mod+k"], ) return ( <> {/* * Grows in from the centre rather than arriving mid-pan, the same * entrance the brain view uses. React Flow reads a node's handle bounds * out of the DOM once and never again, and a reading taken mid-scale is * stored a few percent short for good — `LiveEdge` draws off the * `sourceX`/`targetX` that come from those handles, so every edge would * land short of its port forever. Remeasuring once the wrapper is back * at `scale: 1` is what makes the entrance safe. */} updateNodeInternals([ ...definitions.map((node) => node.id), ...external.nodes.map((node) => node.id), ]) } className="h-full w-full" > deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id)) } onNodeClick={(_event, node) => { // An endpoint is somewhere else's: opening its panel here would // offer to edit a node this flow does not contain. if (!isDocumentNode(node)) { openEndpoint(node.id) return } setFlowPanelOpen(false) setSelectedId(node.id) }} onPaneClick={() => { // Clicking the canvas is how you put a panel away, whichever one it // is: the graph is what you went back to look at. setSelectedId(null) setFlowPanelOpen(false) setEditorExpanded(false) setInspected(null) }} onEdgeClick={(event, edge) => { const label = (id: string) => { const node = definitions.find((entry) => entry.id === id) return node?.title || node?.id || id } setInspected({ message: (edge.data as { message: string }).message, from: label(edge.source), to: label(edge.target), sourceId: edge.source, x: event.clientX, y: event.clientY, }) }} onConnect={onConnect} nodeTypes={nodeTypes} edgeTypes={edgeTypes} proOptions={{ hideAttribution: true }} fitView fitViewOptions={FIT_VIEW} // Low enough that the fit can always show the whole graph. A phone is // 390px wide and a rank of several nodes is thousands, so a floor of // 0.25 left the fit silently short and the flow running off screen. minZoom={0.1} maxZoom={2} // The graph places itself. Nothing here is arranged by hand, which is // what keeps a flow small enough to read at a glance. nodesDraggable={false} connectionRadius={30} connectOnClick autoPanOnConnect edgesReconnectable={false} deleteKeyCode={["Backspace", "Delete"]} className="h-full w-full" > {/* * The bars keep their own lane rather than making way for a panel: they * carry Publish, which is what you reach for the moment a node is done. * The floating panel is inset out of that lane instead. */}
{flowDoc.title || flowName} { setSelectedId(null) setFlowPanelOpen(true) }} onPublish={() => void publishFlow()} onDiscard={() => setDiscardOpen(true)} logs={{ open: logsOpen, node: logsNode, onOpenChange: (open) => { setLogsOpen(open) if (!open) setLogsNode(null) }, onClearNode: () => setLogsNode(null), }} onAddNode={() => setPaletteOpen(true)} onRun={startRun} onTogglePause={() => pauseMutation.mutate(!paused)} onStep={() => stepMutation.mutate()} stepping={stepMutation.isPending} onFocusNode={focusNode} />
{definitions.length === 0 ? (

This flow is empty

Add a node to get started. Press ⌘K, or use the plus in the bar below.

) : null} commitDoc({ ...next, nodes: definitions }, true)} onDelete={() => deleteMutation.mutate()} enabled={detail.enabled ?? true} toggling={enableMutation.isPending} onToggleEnabled={(next) => enableMutation.mutate(next)} hasDraft={detail.has_draft ?? false} onClose={() => setFlowPanelOpen(false)} /> setEditorExpanded((wide) => !wide)} onChange={updateNode} onRenameMessage={proposeRename} onSaveSource={(code) => { if (selected) sourceMutation.mutate({ nodeId: selected.id, code }) }} // Sharing rewrites the stored document, so the canvas takes the // server's copy rather than keeping its own. onShared={onReload} onClose={() => { flush() setEditorExpanded(false) setSelectedId(null) }} onDelete={() => selected && deleteNodes([selected.id])} /> setInspected(null)} onUnbind={unbind} /> addNode("python", libName)} onRun={startRun} /> { setRunOpen(false) runMutation.mutate(params) }} /> !open && setRebind(null)} > How should {rebind?.nodeLabel} read this? Its "{rebind?.port}" input already reads{" "} {rebind?.from}. It can take{" "} {rebind?.to} as well, or instead.
!open && setRenamed(null)} > Rename this message everywhere? {renamed?.count === 1 ? "One other node is" : `${renamed?.count} other nodes are`}{" "} still bound to {renamed?.from}. They keep the old name unless they follow it to{" "} {renamed?.to}. Discard the unpublished changes? The canvas goes back to the version the engine is running. What you edited since is dropped, though the flow store's git history keeps it. {/* * Not dismissable: until one version wins, every further save fails, so * there is nothing useful to go back to. */} event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} > Someone else changed this flow Another editor saved {flowName}{" "} while you were working on it. Load their version, or keep yours and write over theirs. ) } /** * Seed xyflow's own node state once. It tracks which nodes exist and which are * selected; the positions on it are placeholders the layout replaces at render. */ function useUnpositionedNodes(definitions: NodeDef_Input[]) { return useNodesState(toCanvasNodes(definitions)) } export function FlowEditor({ flowName, focus, }: { flowName: string /** A node to select on arrival — see `FlowEditorInner`. */ focus?: string }) { const navigate = useNavigate() const onAuthFailure = useCallback(() => { navigate({ to: "/login" }) }, [navigate]) // Bumped when the canvas has to take the server's document over its own: // local edits live in state seeded on mount, so a remount is the reset. const [epoch, setEpoch] = useState(0) const reload = useCallback(() => setEpoch((n) => n + 1), []) useFlowSocket(onAuthFailure) return ( {/* * Remounting per flow keeps canvas state from leaking between them, and * it is what makes `fitView` run once per flow: xyflow queues the fit on * mount and resolves it as soon as the nodes have been measured. * Deliberately not per focused node — that is a selection, not another * document, and remounting the canvas on every click in the brain graph * would throw an unsaved edit away with it. */} ) }