Files
app/frontend/src/components/Flow/FlowEditor.tsx
T
stroblmeandClaude Opus 5 fdf3c86eae Let one effect own the flow canvas viewport
Three things moved the viewport independently — the shape-fit effect, focusNode,
and React Flow's own fitView prop — so a fourth for "centre the node I just
selected" would have been a fourth party to the argument. There is one effect
now, and which branch it takes is decided by what changed rather than by what is
true: selecting a node brings that node into the lane the panel leaves, and
every other change — new wiring, a new endpoint, a panel opening — re-fits the
whole flow into the same lane. A selection centres once, so the port edits that
follow re-fit around it, which is what makes a new edge's far end visible.

The refit triggers on the edge count, not the bindings key: that key changes on
every keystroke in a message-name field, and refitting per character is not what
"an edge was created" means.

renderedNodes overwrote xyflow's own `selected` flag, so a box-selection of
several nodes was invisible even though delete and copy acted on all of them.

The logs panel was a popover anchored on its own button, which is why it sat off
centre, hugged the button and closed on any outside click. It is a plain surface
above the dock now, and the button is stateful. Escape still closes it.

Expanding a node's editor gives the panel the whole inset and puts the code on
the left with the settings beside it, while the toolbar and the flow name
translate off screen. Narrowing the window past `md` gives the room back — the
sheet it becomes has no second column to hold.

The zoom buttons are gone: there is a mouse, or there is a pinch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 10:12:10 +02:00

1395 lines
46 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 { 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 } 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,
onReload,
}: {
flowName: 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<FlowDef_Input>(() => detail.definition)
const definitions = useMemo(() => flowDoc.nodes ?? [], [flowDoc])
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
detail.definition.nodes ?? [],
)
const [selectedId, setSelectedId] = useState<string | null>(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(",")}`
// biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render.
const positions = useMemo(
() => layoutGraph(ids, edges, direction),
[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])
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 }}
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 }: { flowName: 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.
*/}
<FlowEditorInner
key={`${flowName}:${epoch}`}
flowName={flowName}
onReload={reload}
/>
</ReactFlowProvider>
)
}