Files
app/frontend/src/components/Flow/FlowEditor.tsx
T
stroblmeandClaude Opus 5 98bf8fb7b0 Tell React Flow which theme it is drawing in
React Flow stamps `colorMode` on its wrapper as a class and defaults it to
`light`. The app's own token scopes are named `.light` / `.dark` — the classes
that let a dashboard be forced to one theme inside a shell on the other — so
every canvas was silently redeclaring the light palette on its own subtree:
white node cards and a white canvas in dark mode, with the inherited
`--foreground` text still near-white and therefore invisible. Home's neurons
had the same fault, drawn with white ring gaps and the light `--primary`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
2026-08-23 09:29:30 +02:00

1441 lines
48 KiB
TypeScript

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 { useTheme } from "@/components/theme-provider"
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<string, string>
}
// 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 (
<Background
variant={BackgroundVariant.Dots}
gap={24 * step}
size={1.5 / zoom}
/>
)
}
/** 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 { resolvedTheme } = useTheme()
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<FlowDef_Input>(() => detail.definition)
const definitions = useMemo(() => flowDoc.nodes ?? [], [flowDoc])
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
detail.definition.nodes ?? [],
)
const [selectedId, setSelectedId] = useState<string | null>(focus ?? null)
const [paletteOpen, setPaletteOpen] = useState(false)
const [inspected, setInspected] = useState<InspectedEdge | null>(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<ReadonlySet<string>>(
() => new Set(),
)
const [rebind, setRebind] = useState<Rebind | null>(null)
const [renamed, setRenamed] = useState<MessageRename | null>(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<string | null>(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<FlowDef_Input>(detail.definition)
const history = useRef<History>({ 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<string, string[]>()
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<string>()
const consumed = new Set<string>()
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<string, { width: number; height: number }>())
const trackMeasured = useCallback(
(changes: NodeChange<FlowCanvasNode>[]) => {
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<string, number>()
// 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<string | null>(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<string, unknown> = {}) =>
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<FlowDetail>(
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<string, string> = {}
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.
*/}
<motion.div
variants={scaleIn}
initial="hidden"
animate="visible"
onAnimationComplete={() =>
updateNodeInternals([
...definitions.map((node) => node.id),
...external.nodes.map((node) => node.id),
])
}
className="h-full w-full"
>
<ReactFlow
nodes={shownNodes}
edges={shownEdges}
onNodesChange={trackMeasured}
onEdgesChange={onEdgesChange}
onNodesDelete={(deleted) =>
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 }}
// React Flow stamps this on the wrapper as a class, and the app's own
// token scopes are named `.light` / `.dark` (index.css) — so leaving it
// unset re-themes the whole canvas to light inside a dark shell.
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"
>
<CanvasBackground />
</ReactFlow>
</motion.div>
{/*
* 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.
*/}
<div
className={cn(
"pointer-events-none absolute inset-0 transition-[right] duration-200",
panelOpen && !editorExpanded && "md:right-[27rem]",
)}
>
<CanvasTitle
className={cn(
"transition-transform duration-200",
editorExpanded && "-translate-y-[calc(100%+1rem)]",
)}
>
<span className="truncate px-3 py-1.5 text-sm font-medium">
{flowDoc.title || flowName}
</span>
</CanvasTitle>
<FlowDock
className={cn(
"transition-transform duration-200",
editorExpanded && "translate-y-[calc(100%+1rem)]",
)}
flow={flowName}
issues={issues}
running={runMutation.isPending}
enabled={detail.enabled ?? true}
paused={paused}
saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onEditFlow={() => {
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}
/>
</div>
{definitions.length === 0 ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center"
data-testid="flow-empty"
>
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-6" />
</span>
<p className="text-lg font-medium">This flow is empty</p>
<p className="max-w-xs text-sm text-muted-foreground">
Add a node to get started. Press K, or use the plus in the bar
below.
</p>
</div>
</div>
) : null}
<FlowPanel
open={flowPanelOpen && !selected}
definition={flowDoc}
nodeCount={definitions.length}
// Confirmed, so it is a finished edit rather than a run of keystrokes.
onChange={(next) => 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)}
/>
<NodePanel
node={selected}
flow={flowName}
nodeTypes={nodeTypeInfo ?? []}
suggestions={suggestions}
expanded={editorExpanded}
onToggleExpand={() => 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])}
/>
<EdgeInspector
edge={inspected}
flow={flowName}
onClose={() => setInspected(null)}
onUnbind={unbind}
/>
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
nodeTypes={nodeTypeInfo ?? []}
flows={flows.data}
onAddNode={addNode}
onAddSharedNode={(libName) => addNode("python", libName)}
onRun={startRun}
/>
<RunDialog
open={runOpen}
definition={flowDoc}
pending={runMutation.isPending}
onOpenChange={setRunOpen}
onRun={(params) => {
setRunOpen(false)
runMutation.mutate(params)
}}
/>
<Dialog
open={Boolean(rebind)}
onOpenChange={(open) => !open && setRebind(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>How should {rebind?.nodeLabel} read this?</DialogTitle>
<DialogDescription>
Its "{rebind?.port}" input already reads{" "}
<span className="font-mono">{rebind?.from}</span>. It can take{" "}
<span className="font-mono">{rebind?.to}</span> as well, or
instead.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button variant="ghost" onClick={() => setRebind(null)}>
Cancel
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
if (rebind) {
applyBinding(rebind.nodeId, rebind.port, rebind.to)
}
setRebind(null)
}}
>
Replace
</Button>
<Button
onClick={() => {
if (rebind) {
addInputPort(rebind.nodeId, rebind.to, rebind.dtype)
}
setRebind(null)
}}
>
Add as another input
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(renamed)}
onOpenChange={(open) => !open && setRenamed(null)}
>
<DialogContent data-testid="rename-message">
<DialogHeader>
<DialogTitle>Rename this message everywhere?</DialogTitle>
<DialogDescription>
{renamed?.count === 1
? "One other node is"
: `${renamed?.count} other nodes are`}{" "}
still bound to <span className="font-mono">{renamed?.from}</span>.
They keep the old name unless they follow it to{" "}
<span className="font-mono">{renamed?.to}</span>.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button variant="ghost" onClick={() => setRenamed(null)}>
Leave them
</Button>
<Button onClick={() => renamed && applyRename(renamed)}>
Rename everywhere
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
disabled={discard.isPending}
onClick={() => {
setDiscardOpen(false)
discard.mutate(undefined, {
// The published document replaces what is on the canvas, and
// the version counter goes back with it.
onSuccess: () => {
setFlowPanelOpen(false)
onReload()
},
})
}}
data-testid="confirm-discard-draft"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/*
* Not dismissable: until one version wins, every further save fails, so
* there is nothing useful to go back to.
*/}
<Dialog open={conflict}>
<DialogContent
data-testid="save-conflict"
showCloseButton={false}
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>Someone else changed this flow</DialogTitle>
<DialogDescription>
Another editor saved <span className="font-mono">{flowName}</span>{" "}
while you were working on it. Load their version, or keep yours
and write over theirs.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
onClick={() => {
void resolveConflict("theirs")
onReload()
}}
>
Load theirs
</Button>
<Button onClick={() => void resolveConflict("mine")}>
Keep mine
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 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<FlowCanvasNode>(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 (
<ReactFlowProvider>
{/*
* 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.
*/}
<FlowEditorInner
key={`${flowName}:${epoch}`}
flowName={flowName}
focus={focus}
onReload={reload}
/>
</ReactFlowProvider>
)
}