import { useMutation, useQuery, useQueryClient, useSuspenseQuery, } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { Background, BackgroundVariant, type Connection, type Edge, type EdgeChange, type Node as FlowCanvasNode, type NodeChange, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, useStore, useUpdateNodeInternals, } from "@xyflow/react" import { Workflow } from "lucide-react" import { motion } from "motion/react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { type DevicePublic, type FlowDef_Input, type FlowDetail, FlowsService, type MessageSpec, type NodeDef_Input, RunsService, } from "@/client" import { DiscardDraftDialog } from "@/components/Common/Dock" import { DEVICE_TYPE, deviceNodeOf, type ReportedPort, withPort, } from "@/components/Devices/ports" import { useTheme } from "@/components/theme-provider" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import { useIsMobile } from "@/hooks/useMobile" import { scaleIn } from "@/lib/motion" import { notify } from "@/lib/notificationStore" import { safeStorage } from "@/lib/safeStorage" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { CanvasTitle } from "./CanvasTitle" import { CommandPalette } from "./CommandPalette" import { bindingsKey, boundaryKey, deriveEdges, type FlowEdgeData, flowOf, portOf, qualify, } from "./deriveEdges" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EndpointNode } from "./EndpointNode" import { deriveEndpoints, ENDPOINT_TYPE, type EndpointNodeData, isEndpointNode, } from "./endpoints" import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock" import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowPanel } from "./FlowPanel" import { InputInspector, type InspectedInput } from "./InputInspector" import { LiveEdge } from "./LiveEdge" import { type Direction, type Layout, layoutGraph, type NodeShape, } 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 } /** * Where every node sits and how every edge runs, once the engine has said so. * * The layout is a chunk of its own and answers asynchronously, so a canvas has * a moment before it knows where anything goes. It draws nothing at all until * then — a route already shows a skeleton for that — and an *edit* keeps the * arrangement it had, so only the node that changed lands a frame late rather * than the whole graph flickering through the corner. */ function useLayout( ids: string[], edges: Edge[], direction: Direction, shapes: Map, shapeKey: string, ): Layout | null { const [placed, setPlaced] = useState(null) // biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render. useEffect(() => { let stale = false layoutGraph(ids, edges, direction, shapes).then((layout) => { // A graph edited while the last one was still being laid out has already // asked for another; that one is the answer, not this. if (!stale) setPlaced(layout) }) return () => { stale = true } }, [shapeKey, edges]) return placed } /** 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 ( ) } /** * Whether arriving on this id selects it. A node does, and so does another * flow's label, which is where a link from that flow lands; every other * endpoint opens what it stands for instead. */ function landsOn(id: string): boolean { return !isEndpointNode({ id }) || id.startsWith("flow:") } /** 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 or endpoint 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 { resolvedTheme } = useTheme() const { data: flows } = useSuspenseQuery(flowsQueryOptions()) const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName)) const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions()) const { save, flush, conflict, resolveConflict, adopt, 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 ?? [], ) // An endpoint is never selected; arriving on one opens it instead (below). // Another flow's label is the exception: it is where a link from that flow // lands, so it is selected like a node. const [selectedId, setSelectedId] = useState( focus && landsOn(focus) ? focus : null, ) const [paletteOpen, setPaletteOpen] = useState(false) const [inspected, setInspected] = useState(null) const [inspectedInput, setInspectedInput] = 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 run code of their own, a source or a command. Adding one is a // superuser's, so nobody else is offered one. const { user } = useAuth() const mayEditCode = Boolean(user?.is_superuser) const lockedTypes = useMemo( () => new Set( mayEditCode ? [] : (nodeTypeInfo ?? []) .filter((info) => info.runs_code) .map((info) => info.type), ), [nodeTypeInfo, mayEditCode], ) // 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) { // A node's dot and border are its status channel, and status has no // warning tier — an advisory is not a fault, so it says its piece in the // dock rather than painting the node red. if (!issue.node || issue.advisory) 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. * * `via` is the node here it is wired to. The other flow draws that node as * a label of its own, which is where following the link lands. */ const openEndpoint = useCallback( (id: string, via?: 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] }, search: { node: `flow:${via ? `${flowName}.${via}` : flowName}` }, }) } }, [navigate, flowName], ) // 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], ) // Arrived from another flow: the label standing for the node the link left // from, or failing that any label of that flow. Its edges are lit with it, // so the connection that was followed is the one that reads first. const landing = useMemo(() => { if (!selectedId?.startsWith("flow:")) return null const ids = external.nodes.map((node) => node.id) if (ids.includes(selectedId)) return selectedId const origin = flowOf(selectedId.slice("flow:".length)) return ids.find((id) => id.startsWith(`flow:${origin}.`)) ?? null }, [selectedId, external]) 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(",")}` // What each node is, as far as the layout is concerned: the ports it draws, // in the order it draws them. 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 shapes = new Map([ ...definitions.map( (node) => [ node.id, { requires: (node.requires ?? []).map(portOf), provides: (node.provides ?? []).map(portOf), }, ] as const, ), ...external.nodes.map((node) => { const data = node.data as EndpointNodeData return [ node.id, { requires: data.requires, provides: data.provides, endpoint: true }, ] as const }), ]) const layout = useLayout(ids, edges, direction, shapes, shapeKey) const positions = layout?.positions ?? null /** * 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 // A node the layout has not placed yet — one just added, or the whole // graph on the first render — is left out rather than drawn at the // origin, which is where every unplaced node would pile up. .filter((node) => positions?.has(node.id)) .map((node) => ({ ...node, selected: node.id === landing, position: positions?.get(node.id) ?? node.position, })), [external, positions, landing], ) // Marked after the layout has had the derived array, so selecting an edge // never sends the graph through the layout again. The route rides along // the same way. const shownEdges = useMemo( () => edges.map((edge) => ({ ...edge, data: { ...(edge.data as FlowEdgeData), route: layout?.routes.get(edge.id), }, selected: selectedEdges.has(edge.id) || edge.source === landing || edge.target === landing, })), [edges, selectedEdges, landing, layout], ) const shownNodes = useMemo( () => [ ...renderedNodes .filter((node) => positions?.has(node.id)) .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. An endpoint — an input or output found by the search — // does what clicking its label does, which also covers one the canvas does // not draw because a dashboard already accounts for it. Another flow's label // is landed on instead, being where a link from that flow arrives. useEffect(() => { if (!focus) return if (landsOn(focus)) { setFlowPanelOpen(false) setSelectedId(focus) } else { openEndpoint(focus) } }, [focus, openEndpoint]) 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(() => { // Recorded as centred only once the fit below has run: arriving from // another screen selects before the layout has answered, and spending the // selection on the early returns is what fitted the whole flow instead. // Another flow's label is centred once it has been found on this canvas. const target = selectedId?.startsWith("flow:") ? landing : selectedId const focus = target && selectedId !== centred.current ? target : null // A phone's sheet covers the canvas outright, and so does the expanded // editor: there is no viewport to aim. if (editorExpanded) return // Nothing is on the canvas until the layout has answered, and fitting an // empty one would spend the single instant fit on nothing. if (!positions) 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 centred.current = selectedId }) return () => cancelAnimationFrame(frame) }, [ direction, definitions.length, external.nodes.length, edges.length, positions, selectedId, landing, 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: async (withRuns: boolean) => { await FlowsService.deleteFlow({ name: flowName }) // After the flow, which is refused while a run is live, so a refusal // leaves both standing. if (withRuns) await RunsService.deleteRuns({ flow: flowName, history: true }) }, 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: async ({ nodeId, code, drop, }: { nodeId: string code: string drop?: boolean }) => { // The server reads the ports off the header and compares them with its // draft, so the ports on the canvas have to be there first. await flush() return FlowsService.saveNodeSource({ name: flowName, nodeId, requestBody: { code }, drop, }) }, // One at a time: the save that drops a port must not land before the // autosave that kept it. scope: { id: `source:${flowName}` }, onSuccess: (saved) => { const followed = saved.node if (followed && saved.version) { // The header changed the ports in the draft: take them, and the // version they moved it to, so the next save is not a conflict. adopt(saved.version) commit( (latest.current.nodes ?? []).map((node) => node.id === followed.id ? { ...node, requires: followed.requires, params: followed.params, } : node, ), ) } 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], ) /** * A port a device reports, on this flow's node for that device. One node per * device keeps its ports together, so it is made only when there is none. */ const addDevicePort = useCallback( (device: DevicePublic, port: ReportedPort) => { const existing = deviceNodeOf(definitions, device.id) if (existing) { updateNode(withPort(existing, port)) setSelectedId(existing.id) return } const id = uniqueNodeId(definitions, device.id) const node = withPort( { id, type: DEVICE_TYPE, title: device.title || device.id, params: { device: device.id, config: {} }, requires: [], provides: [], }, port, ) setCanvasNodes([ ...canvasNodes, { id, type: "flow", position: { x: 0, y: 0 }, data: {} }, ]) commit([...definitions, node]) setSelectedId(id) }, [canvasNodes, commit, definitions, setCanvasNodes, updateNode], ) 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) // A multi-node copy carries whole Python sources, which can genuinely // reach the origin's quota — and a throw from a key handler would take // the app down rather than the copy. const stored = safeStorage.set( CLIPBOARD_KEY, JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard), ) if (!stored) { notify("That is too much to copy. The browser store is full.", "error") } }, [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 = safeStorage.get(CLIPBOARD_KEY) if (!stored) return let clipboard: NodeClipboard try { clipboard = JSON.parse(stored) } catch { return } // A node that runs code is a superuser's to add, so anyone else pastes // the rest without it. const nodes = (clipboard.nodes ?? []).filter( (node) => !lockedTypes.has(node.type ?? "python"), ) if (nodes.length < (clipboard.nodes ?? []).length) { notify("Only a superuser can add nodes that run code.", "error") } let pool = definitions const pasted: NodeDef_Input[] = [] for (const node of 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) nodes.forEach((node, index) => { const code = clipboard.sources?.[node.id] if (code !== undefined) { sourceMutation.mutate({ nodeId: pasted[index].id, code }) } }) }, [ canvasNodes, commit, definitions, lockedTypes, setCanvasNodes, sourceMutation.mutate, ]) useShortcuts( { "mod+z": () => step(true), "mod+shift+z": () => step(false), // Both decline the key unless the node clipboard has something to do // with it, so the native copy and paste still work on the canvas. A // text selection wins outright: that is what the user asked to copy. "mod+c": () => { const selection = document.getSelection() if (selection && !selection.isCollapsed) return false if (!canvasNodes.some((node) => node.selected) && !selectedId) { return false } void copyNodes() }, "mod+v": () => { if (!safeStorage.get(CLIPBOARD_KEY)) return false pasteNodes() }, // ⌘P, not ⌘K: the sidebar's global search owns that everywhere, and this // palette is the canvas's own, narrower thing. "mod+p": () => 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+p"], ) 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. // An input is read where it sits; editing it is one press on. const declared = node.id.startsWith("input:") ? (flowDoc.inputs ?? []).find( (one) => `input:${qualify(flowName, one.spec?.name ?? "")}` === node.id, ) : undefined if (declared) { const { left, top, width, height } = event.currentTarget.getBoundingClientRect() setInspectedInput({ declared, rect: { left, top, width, height }, }) return } if (!isDocumentNode(node)) { const wire = edges.find( (edge) => edge.source === node.id || edge.target === node.id, ) openEndpoint( node.id, wire && (wire.source === node.id ? wire.target : wire.source), ) 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) setInspectedInput(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 }} // React Flow's own defaults, for whatever flow.css does not route // through the tokens, follow the theme too. colorMode={resolvedTheme} 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} {/* Declared in code somewhere else, at a commit that names what actually ran. A dirty tree says so, because then it does not. */} {flowDoc.origin ? ( {flowDoc.origin.commit ? flowDoc.origin.commit.slice(0, 7) : "no commit"} {flowDoc.origin.dirty ? "*" : ""} ) : null} { 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 ⌘P, or use the plus in the bar below.

) : null} commitDoc({ ...next, nodes: definitions }, true)} onDelete={(withRuns) => deleteMutation.mutate(withRuns)} 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, drop) => { if (selected) sourceMutation.mutate({ nodeId: selected.id, code, drop }) }} // 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} /> setInspectedInput(null)} onEdit={() => { setInspectedInput(null) setSelectedId(null) setFlowPanelOpen(true) }} /> !lockedTypes.has(info.type), )} flows={flows.data} onAddNode={addNode} onAddSharedNode={ mayEditCode ? (libName) => addNode("python", libName) : undefined } onAddDevicePort={addDevicePort} 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.mutate(undefined, { // The published document replaces what is on the canvas, and // the version counter goes back with it. onSuccess: () => { setFlowPanelOpen(false) onReload() }, }) } > 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 or endpoint to arrive on — 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. */} ) }