Copy nodes, bind the keys, and keep publish within reach

The floating bars used to make way for a side panel, which hid Publish
exactly when a node had just been edited. They keep their lane now and
the panel is inset out of it; the enlarged code editor stays a floating
surface between the two bars rather than covering them.

Shortcuts are one small registry (`lib/shortcuts.ts`): undo, redo, ⌘K,
copy, paste, and ⌘S — which publishes the flow, or applies the code when
the editor has focus. Copying carries a node's own source through
localStorage, so a paste works in another flow too.

A flow is now named where a node is, at the top of its panel and only
there, and both take effect on Confirm. Flow-level edits go through the
undo stack with everything else, clicking the canvas puts the flow panel
away, a failing node opens the logs filtered to its own traceback, and
the panel carries its test id on a phone as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 16:43:41 +02:00
co-authored by Claude Fable 5
parent 929bb56662
commit fa12ffd323
9 changed files with 477 additions and 222 deletions
+241 -133
View File
@@ -19,7 +19,6 @@ import {
} from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
import { AnimatePresence } from "motion/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
@@ -39,6 +38,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
@@ -94,8 +95,8 @@ type MessageRename = {
/** Documents to step back and forward through, newest last. */
type History = {
past: NodeDef_Input[][]
future: NodeDef_Input[][]
past: FlowDef_Input[]
future: FlowDef_Input[]
/** When the last entry was recorded, so a burst of typing stays one edit. */
at: number
}
@@ -103,6 +104,16 @@ type History = {
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.
*
@@ -112,13 +123,15 @@ const TYPING_WINDOW = 500
*/
function record(
history: History,
previous: NodeDef_Input[],
next: NodeDef_Input[],
previous: FlowDef_Input,
next: FlowDef_Input,
settled: boolean,
) {
const before = previous.nodes ?? []
const after = next.nodes ?? []
const sameNodes =
previous.length === next.length &&
previous.every((node, index) => node.id === next[index].id)
before.length === after.length &&
before.every((node, index) => node.id === after[index].id)
const stillTyping =
!settled && sameNodes && Date.now() - history.at < TYPING_WINDOW
@@ -131,15 +144,6 @@ function record(
history.future = []
}
/** Monaco and every text field keep their own undo, so leave theirs alone. */
function isTextEntry(target: EventTarget | null): boolean {
return Boolean(
(target as Element | null)?.closest?.(
"input, textarea, [contenteditable='true'], .monaco-editor",
),
)
}
function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] {
return definitions.map((node) => ({
id: node.id,
@@ -225,9 +229,10 @@ function FlowEditorInner({
const publish = usePublish(flowName)
const discard = useDiscardDraft(flowName)
const [definitions, setDefinitions] = useState<NodeDef_Input[]>(
() => detail.definition.nodes ?? [],
)
// 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 ?? [],
)
@@ -237,8 +242,12 @@ function FlowEditorInner({
const [rebind, setRebind] = useState<Rebind | null>(null)
const [renamed, setRenamed] = useState<MessageRename | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
// The editor at full size covers the canvas, so its chrome steps aside.
// 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)
@@ -247,33 +256,35 @@ function FlowEditorInner({
const latest = useRef<FlowDef_Input>(detail.definition)
const history = useRef<History>({ past: [], future: [], at: 0 })
/** Put a set of nodes on the canvas and on their way to the server. */
const apply = useCallback(
/** 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[], positions?: FlowCanvasNode[]) => {
const placed = nodes.map((node) => {
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
return canvas ? { ...node, position: canvas.position } : node
})
setDefinitions(placed)
const next: FlowDef_Input = { ...detail.definition, nodes: placed }
latest.current = next
save(next)
commitDoc({ ...latest.current, nodes: placed }, Boolean(positions))
},
[canvasNodes, detail.definition, save],
)
/** Apply a change and make it undoable. */
const commit = useCallback(
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
record(
history.current,
latest.current.nodes ?? [],
nodes,
Boolean(positions),
)
apply(nodes, positions)
},
[apply],
[canvasNodes, commitDoc],
)
/**
@@ -285,35 +296,32 @@ function FlowEditorInner({
const { past, future } = history.current
const remembered = (back ? past : future).pop()
if (!remembered) return
;(back ? future : past).push(latest.current.nodes ?? [])
;(back ? future : past).push(latest.current)
history.current.at = 0
// xyflow owns the positions, so hand it the remembered ones too.
const canvas = toCanvasNodes(remembered)
setCanvasNodes(canvas)
apply(remembered, canvas)
setCanvasNodes(toCanvasNodes(remembered.nodes ?? []))
applyDoc(remembered)
setInspected(null)
},
[apply, setCanvasNodes],
[applyDoc, setCanvasNodes],
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Shift makes it a capital Z, so compare on the letter alone.
const undoKey =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z"
if (!undoKey || isTextEntry(event.target)) return
event.preventDefault()
step(!event.shiftKey)
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [step])
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(
@@ -337,6 +345,11 @@ function FlowEditorInner({
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(
() =>
@@ -353,6 +366,7 @@ function FlowEditorInner({
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
isPlugin: pluginTypes.has(definition?.type ?? ""),
issueText: nodeIssues.join("\n"),
onShowLogs: showLogs,
} satisfies FlowNodeData,
}
}),
@@ -363,6 +377,7 @@ function FlowEditorInner({
issuesByNode,
pluginTypes,
selectedId,
showLogs,
typeLabels,
],
)
@@ -492,25 +507,6 @@ function FlowEditorInner({
onError: () => showErrorToast("The flow could not be paused."),
})
const renameMutation = useMutation({
mutationFn: (newName: string) =>
FlowsService.renameFlow({
name: flowName,
requestBody: { new_name: newName },
}),
onSuccess: (detail) => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
setFlowPanelOpen(false)
navigate({
to: "/flows/$flowName",
params: { flowName: detail.definition.name },
replace: true,
})
},
onError: () =>
showErrorToast("That name is taken, or is not a valid flow name."),
})
const deleteMutation = useMutation({
mutationFn: () => FlowsService.deleteFlow({ name: flowName }),
onSuccess: () => {
@@ -731,10 +727,121 @@ function FlowEditorInner({
[fitView, 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 position = freePosition(pool, {
// Offset, so a copy of a node in this flow is visibly its own.
x: (node.position?.x ?? 0) + 48,
y: (node.position?.y ?? 0) + 48,
})
const copy = { ...node, id: uniqueNodeId(pool, node.id), position }
pool = [...pool, copy]
pasted.push(copy)
}
if (!pasted.length) return
const nextCanvas = [
...canvasNodes,
...pasted.map(
(node) =>
({
id: node.id,
type: "flow",
position: node.position,
data: {},
}) as FlowCanvasNode,
),
]
setCanvasNodes(nextCanvas)
commit(pool, nextCanvas)
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"],
)
const selected = definitions.find((node) => node.id === selectedId) ?? null
// A panel is the view you are working in: the bars would only compete with
// it, so they step aside until it closes. On a phone the panel covers them
// anyway, and its own close button is the way back.
// 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
return (
@@ -776,7 +883,10 @@ function FlowEditorInner({
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)
}}
@@ -813,50 +923,55 @@ function FlowEditorInner({
<CanvasBackground />
</ReactFlow>
<AnimatePresence>
{panelOpen ? null : (
<FlowTabs
key="flow-tabs"
flows={flows.data}
active={flowName}
saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onPublish={async () => {
// Publish what was actually stored: the version only advances
// once the queued save has landed.
await flush()
const current = queryClient.getQueryData<FlowDetail>(
flowKeys.detail(flowName),
)
publish.mutate(current?.definition.version ?? 1)
}}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
/>
{/*
* 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]",
)}
>
<FlowTabs
flows={flows.data}
active={flowName}
saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onPublish={() => void publishFlow()}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
/>
{panelOpen ? null : (
<FlowDock
key="flow-dock"
flow={flowName}
issues={issues}
running={runMutation.isPending}
enabled={detail.enabled ?? true}
paused={paused}
onAddNode={() => setPaletteOpen(true)}
onRun={async () => {
// Running executes what is stored, so the queued edit goes first.
await flush()
runMutation.mutate()
}}
onTogglePause={() => pauseMutation.mutate(!paused)}
onFocusNode={focusNode}
/>
)}
</AnimatePresence>
<FlowDock
flow={flowName}
issues={issues}
running={runMutation.isPending}
enabled={detail.enabled ?? true}
paused={paused}
logs={{
open: logsOpen,
node: logsNode,
onOpenChange: (open) => {
setLogsOpen(open)
if (!open) setLogsNode(null)
},
onClearNode: () => setLogsNode(null),
}}
onAddNode={() => setPaletteOpen(true)}
onRun={async () => {
// Running executes what is stored, so the queued edit goes first.
await flush()
runMutation.mutate()
}}
onTogglePause={() => pauseMutation.mutate(!paused)}
onFocusNode={focusNode}
/>
</div>
{definitions.length === 0 ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -875,17 +990,10 @@ function FlowEditorInner({
<FlowPanel
open={flowPanelOpen && !selected}
definition={{ ...detail.definition, nodes: definitions }}
definition={flowDoc}
nodeCount={definitions.length}
renaming={renameMutation.isPending}
onChange={(next) => {
flush()
save({ ...next, nodes: definitions })
}}
onRename={async (newName) => {
await flush()
renameMutation.mutate(newName)
}}
// 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}