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
@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import type { FlowSummary, NodeTypeInfo } from "@/client" import type { FlowSummary, NodeTypeInfo } from "@/client"
import { import {
@@ -40,17 +39,6 @@ export function CommandPalette({
enabled: open, enabled: open,
}) })
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
onOpenChange(!open)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, onOpenChange])
// Picking an item re-renders the canvas, which can interrupt the dialog's // Picking an item re-renders the canvas, which can interrupt the dialog's
// exit animation and leave its overlay swallowing clicks. Unmounting the // exit animation and leave its overlay swallowing clicks. Unmounting the
// dialog outright is deterministic; the palette does not need to fade out. // dialog outright is deterministic; the palette does not need to fade out.
+5 -2
View File
@@ -27,7 +27,7 @@ import {
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion" import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { LogsPanel } from "./LogsPanel" import { type LogsFilter, LogsPanel } from "./LogsPanel"
/** /**
* What "fit" means on this canvas: the view a flow opens with, and the one the * What "fit" means on this canvas: the view a flow opens with, and the one the
@@ -47,6 +47,7 @@ export function FlowDock({
running, running,
enabled, enabled,
paused, paused,
logs,
onAddNode, onAddNode,
onRun, onRun,
onTogglePause, onTogglePause,
@@ -57,6 +58,8 @@ export function FlowDock({
running: boolean running: boolean
enabled: boolean enabled: boolean
paused: boolean paused: boolean
/** Owned by the editor, because a failing node can open it too. */
logs: LogsFilter
onAddNode: () => void onAddNode: () => void
onRun: () => void onRun: () => void
onTogglePause: () => void onTogglePause: () => void
@@ -164,7 +167,7 @@ export function FlowDock({
<Separator orientation="vertical" className="mx-0.5 !h-5" /> <Separator orientation="vertical" className="mx-0.5 !h-5" />
<LogsPanel flow={flow} /> <LogsPanel flow={flow} {...logs} />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
+216 -108
View File
@@ -19,7 +19,6 @@ import {
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react" import { Workflow } from "lucide-react"
import { AnimatePresence } from "motion/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { import {
@@ -39,6 +38,8 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { CommandPalette } from "./CommandPalette" import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges" import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
@@ -94,8 +95,8 @@ type MessageRename = {
/** Documents to step back and forward through, newest last. */ /** Documents to step back and forward through, newest last. */
type History = { type History = {
past: NodeDef_Input[][] past: FlowDef_Input[]
future: NodeDef_Input[][] future: FlowDef_Input[]
/** When the last entry was recorded, so a burst of typing stays one edit. */ /** When the last entry was recorded, so a burst of typing stays one edit. */
at: number at: number
} }
@@ -103,6 +104,16 @@ type History = {
const HISTORY_LIMIT = 50 const HISTORY_LIMIT = 50
const TYPING_WINDOW = 500 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. * Remember the document as it was before a change.
* *
@@ -112,13 +123,15 @@ const TYPING_WINDOW = 500
*/ */
function record( function record(
history: History, history: History,
previous: NodeDef_Input[], previous: FlowDef_Input,
next: NodeDef_Input[], next: FlowDef_Input,
settled: boolean, settled: boolean,
) { ) {
const before = previous.nodes ?? []
const after = next.nodes ?? []
const sameNodes = const sameNodes =
previous.length === next.length && before.length === after.length &&
previous.every((node, index) => node.id === next[index].id) before.every((node, index) => node.id === after[index].id)
const stillTyping = const stillTyping =
!settled && sameNodes && Date.now() - history.at < TYPING_WINDOW !settled && sameNodes && Date.now() - history.at < TYPING_WINDOW
@@ -131,15 +144,6 @@ function record(
history.future = [] 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[] { function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] {
return definitions.map((node) => ({ return definitions.map((node) => ({
id: node.id, id: node.id,
@@ -225,9 +229,10 @@ function FlowEditorInner({
const publish = usePublish(flowName) const publish = usePublish(flowName)
const discard = useDiscardDraft(flowName) const discard = useDiscardDraft(flowName)
const [definitions, setDefinitions] = useState<NodeDef_Input[]>( // The whole working document, so a flow-level edit is an edit like any
() => detail.definition.nodes ?? [], // 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( const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
detail.definition.nodes ?? [], detail.definition.nodes ?? [],
) )
@@ -237,8 +242,12 @@ function FlowEditorInner({
const [rebind, setRebind] = useState<Rebind | null>(null) const [rebind, setRebind] = useState<Rebind | null>(null)
const [renamed, setRenamed] = useState<MessageRename | null>(null) const [renamed, setRenamed] = useState<MessageRename | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false) 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) 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 issues = detail.issues ?? []
const paused = useFlowPaused(flowName) const paused = useFlowPaused(flowName)
@@ -247,33 +256,35 @@ function FlowEditorInner({
const latest = useRef<FlowDef_Input>(detail.definition) const latest = useRef<FlowDef_Input>(detail.definition)
const history = useRef<History>({ past: [], future: [], at: 0 }) const history = useRef<History>({ past: [], future: [], at: 0 })
/** Put a set of nodes on the canvas and on their way to the server. */ /** Put a document on the canvas and on its way to the server. */
const apply = useCallback( 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[]) => { (nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
const placed = nodes.map((node) => { const placed = nodes.map((node) => {
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id) const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
return canvas ? { ...node, position: canvas.position } : node return canvas ? { ...node, position: canvas.position } : node
}) })
setDefinitions(placed) commitDoc({ ...latest.current, nodes: placed }, Boolean(positions))
const next: FlowDef_Input = { ...detail.definition, nodes: placed }
latest.current = next
save(next)
}, },
[canvasNodes, detail.definition, save], [canvasNodes, commitDoc],
)
/** 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],
) )
/** /**
@@ -285,35 +296,32 @@ function FlowEditorInner({
const { past, future } = history.current const { past, future } = history.current
const remembered = (back ? past : future).pop() const remembered = (back ? past : future).pop()
if (!remembered) return if (!remembered) return
;(back ? future : past).push(latest.current.nodes ?? []) ;(back ? future : past).push(latest.current)
history.current.at = 0 history.current.at = 0
// xyflow owns the positions, so hand it the remembered ones too. // xyflow owns the positions, so hand it the remembered ones too.
const canvas = toCanvasNodes(remembered) setCanvasNodes(toCanvasNodes(remembered.nodes ?? []))
setCanvasNodes(canvas) applyDoc(remembered)
apply(remembered, canvas)
setInspected(null) 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( const typeLabels = useMemo(
() => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])), () => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])),
[nodeTypeInfo], [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 // Which types came from an installed connector rather than the engine. They
// cannot be in the icon map, so they share one. // cannot be in the icon map, so they share one.
const pluginTypes = useMemo( const pluginTypes = useMemo(
@@ -337,6 +345,11 @@ function FlowEditorInner({
return map return map
}, [issues]) }, [issues])
const showLogs = useCallback((nodeId: string) => {
setLogsNode(nodeId)
setLogsOpen(true)
}, [])
// Canvas nodes carry the definition so the node component can render it. // Canvas nodes carry the definition so the node component can render it.
const renderedNodes = useMemo( const renderedNodes = useMemo(
() => () =>
@@ -353,6 +366,7 @@ function FlowEditorInner({
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "", typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
isPlugin: pluginTypes.has(definition?.type ?? ""), isPlugin: pluginTypes.has(definition?.type ?? ""),
issueText: nodeIssues.join("\n"), issueText: nodeIssues.join("\n"),
onShowLogs: showLogs,
} satisfies FlowNodeData, } satisfies FlowNodeData,
} }
}), }),
@@ -363,6 +377,7 @@ function FlowEditorInner({
issuesByNode, issuesByNode,
pluginTypes, pluginTypes,
selectedId, selectedId,
showLogs,
typeLabels, typeLabels,
], ],
) )
@@ -492,25 +507,6 @@ function FlowEditorInner({
onError: () => showErrorToast("The flow could not be paused."), 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({ const deleteMutation = useMutation({
mutationFn: () => FlowsService.deleteFlow({ name: flowName }), mutationFn: () => FlowsService.deleteFlow({ name: flowName }),
onSuccess: () => { onSuccess: () => {
@@ -731,10 +727,121 @@ function FlowEditorInner({
[fitView, flowName], [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 const selected = definitions.find((node) => node.id === selectedId) ?? null
// A panel is the view you are working in: the bars would only compete with // A panel is the view you are working in, so it takes the room — but never
// it, so they step aside until it closes. On a phone the panel covers them // the lanes the bars sit in: publishing is most wanted right after editing.
// anyway, and its own close button is the way back.
const panelOpen = Boolean(selected) || flowPanelOpen const panelOpen = Boolean(selected) || flowPanelOpen
return ( return (
@@ -776,7 +883,10 @@ function FlowEditorInner({
setSelectedId(node.id) setSelectedId(node.id)
}} }}
onPaneClick={() => { 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) setSelectedId(null)
setFlowPanelOpen(false)
setEditorExpanded(false) setEditorExpanded(false)
setInspected(null) setInspected(null)
}} }}
@@ -813,39 +923,45 @@ function FlowEditorInner({
<CanvasBackground /> <CanvasBackground />
</ReactFlow> </ReactFlow>
<AnimatePresence> {/*
{panelOpen ? null : ( * 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 <FlowTabs
key="flow-tabs"
flows={flows.data} flows={flows.data}
active={flowName} active={flowName}
saving={saving.isPending} saving={saving.isPending}
hasDraft={detail.has_draft ?? false} hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending} publishing={publish.isPending || saving.isPending}
onPublish={async () => { onPublish={() => void publishFlow()}
// 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={() => { onEditFlow={() => {
setSelectedId(null) setSelectedId(null)
setFlowPanelOpen(true) setFlowPanelOpen(true)
}} }}
/> />
)}
{panelOpen ? null : (
<FlowDock <FlowDock
key="flow-dock"
flow={flowName} flow={flowName}
issues={issues} issues={issues}
running={runMutation.isPending} running={runMutation.isPending}
enabled={detail.enabled ?? true} enabled={detail.enabled ?? true}
paused={paused} paused={paused}
logs={{
open: logsOpen,
node: logsNode,
onOpenChange: (open) => {
setLogsOpen(open)
if (!open) setLogsNode(null)
},
onClearNode: () => setLogsNode(null),
}}
onAddNode={() => setPaletteOpen(true)} onAddNode={() => setPaletteOpen(true)}
onRun={async () => { onRun={async () => {
// Running executes what is stored, so the queued edit goes first. // Running executes what is stored, so the queued edit goes first.
@@ -855,8 +971,7 @@ function FlowEditorInner({
onTogglePause={() => pauseMutation.mutate(!paused)} onTogglePause={() => pauseMutation.mutate(!paused)}
onFocusNode={focusNode} onFocusNode={focusNode}
/> />
)} </div>
</AnimatePresence>
{definitions.length === 0 ? ( {definitions.length === 0 ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -875,17 +990,10 @@ function FlowEditorInner({
<FlowPanel <FlowPanel
open={flowPanelOpen && !selected} open={flowPanelOpen && !selected}
definition={{ ...detail.definition, nodes: definitions }} definition={flowDoc}
nodeCount={definitions.length} nodeCount={definitions.length}
renaming={renameMutation.isPending} // Confirmed, so it is a finished edit rather than a run of keystrokes.
onChange={(next) => { onChange={(next) => commitDoc({ ...next, nodes: definitions }, true)}
flush()
save({ ...next, nodes: definitions })
}}
onRename={async (newName) => {
await flush()
renameMutation.mutate(newName)
}}
onDelete={() => deleteMutation.mutate()} onDelete={() => deleteMutation.mutate()}
enabled={detail.enabled ?? true} enabled={detail.enabled ?? true}
toggling={enableMutation.isPending} toggling={enableMutation.isPending}
+28 -1
View File
@@ -2,6 +2,7 @@ import { Handle, type NodeProps, Position } from "@xyflow/react"
import { import {
Bell, Bell,
Braces, Braces,
Bug,
Clock, Clock,
Code2, Code2,
Database, Database,
@@ -20,6 +21,7 @@ import {
import { memo } from "react" import { memo } from "react"
import type { MessageSpec, NodeDef_Input } from "@/client" import type { MessageSpec, NodeDef_Input } from "@/client"
import { Button } from "@/components/ui/button"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -61,6 +63,8 @@ export type FlowNodeData = {
typeLabel: string typeLabel: string
isPlugin?: boolean isPlugin?: boolean
issueText: string issueText: string
/** Open the logs at this node's own lines. */
onShowLogs?: (nodeId: string) => void
[key: string]: unknown [key: string]: unknown
} }
@@ -103,7 +107,7 @@ function PortHandles({
} }
function FlowNodeComponent({ data, selected }: NodeProps) { function FlowNodeComponent({ data, selected }: NodeProps) {
const { definition, flow, typeLabel, isPlugin, issueText } = const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } =
data as FlowNodeData data as FlowNodeData
const live = useNodeStatus(`${flow}.${definition.id}`) const live = useNodeStatus(`${flow}.${definition.id}`)
const emits = useNodeEmits(`${flow}.${definition.id}`) const emits = useNodeEmits(`${flow}.${definition.id}`)
@@ -153,6 +157,29 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
{typeLabel} {typeLabel}
</span> </span>
</span> </span>
{status === "error" && onShowLogs ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
// `nodrag` keeps xyflow from reading the press as a drag; the
// click itself is stopped so the node panel stays closed.
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Show what this node printed"
data-testid="node-traceback"
onClick={(event) => {
event.stopPropagation()
onShowLogs(definition.id)
}}
>
<Bug />
</Button>
</TooltipTrigger>
<TooltipContent>Show the traceback</TooltipContent>
</Tooltip>
) : null}
{style ? ( {style ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
+8 -53
View File
@@ -10,25 +10,18 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { PANEL_SECTION, SidePanel } from "./SidePanel" import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/
/** /**
* The flow's own settings, in the same panel its nodes use. * The flow's own settings, in the same panel its nodes use — down to where the
* * name sits and how confirming a new one works.
* The name is also the namespace of every message in the flow, which is why
* renaming goes through the server rather than being another autosaved field.
*/ */
export function FlowPanel({ export function FlowPanel({
open, open,
definition, definition,
nodeCount, nodeCount,
renaming,
onChange, onChange,
onRename,
onDelete, onDelete,
hasDraft, hasDraft,
discarding, discarding,
@@ -41,9 +34,7 @@ export function FlowPanel({
open: boolean open: boolean
definition: FlowDef_Input definition: FlowDef_Input
nodeCount: number nodeCount: number
renaming: boolean
onChange: (next: FlowDef_Input) => void onChange: (next: FlowDef_Input) => void
onRename: (newName: string) => void
onDelete: () => void onDelete: () => void
hasDraft: boolean hasDraft: boolean
discarding: boolean discarding: boolean
@@ -53,13 +44,9 @@ export function FlowPanel({
onToggleEnabled: (next: boolean) => void onToggleEnabled: (next: boolean) => void
onClose: () => void onClose: () => void
}) { }) {
const [name, setName] = useState(definition.name)
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
const [discardOpen, setDiscardOpen] = useState(false) const [discardOpen, setDiscardOpen] = useState(false)
const valid = NAME_PATTERN.test(name)
const changed = name !== definition.name
return ( return (
<> <>
<SidePanel <SidePanel
@@ -69,14 +56,11 @@ export function FlowPanel({
bodyKey={definition.name} bodyKey={definition.name}
onClose={onClose} onClose={onClose}
header={ header={
<Input <PanelTitle
value={definition.title ?? ""} value={definition.title ?? ""}
placeholder={definition.name} placeholder={definition.name}
aria-label="Flow title" label="Flow title"
className="h-8 flex-1 text-sm font-medium" onConfirm={(title) => onChange({ ...definition, title })}
onChange={(event) =>
onChange({ ...definition, title: event.target.value })
}
/> />
} }
footer={ footer={
@@ -110,40 +94,11 @@ export function FlowPanel({
</div> </div>
</div> </div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Name</span>
<div className="flex items-center gap-1.5">
<Input
value={name}
aria-label="Flow name"
autoComplete="off"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && valid && changed) {
onRename(name)
}
}}
/>
<Button
size="sm"
className="h-8"
disabled={!valid || !changed || renaming}
onClick={() => onRename(name)}
>
{renaming ? "Renaming…" : "Rename"}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{valid || !name
? "Messages in this flow are named after it, so other flows reading them follow the rename."
: "Lowercase letters, digits and underscores, starting with a letter."}
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span> <span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
<span className="font-mono">{definition.name}</span> namespaces
every message in here.{" "}
{nodeCount === 0 {nodeCount === 0
? "No nodes yet." ? "No nodes yet."
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`} : `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`}
+42 -5
View File
@@ -1,4 +1,4 @@
import { Terminal } from "lucide-react" import { Terminal, X } from "lucide-react"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -30,12 +30,33 @@ function nodeLabel(nodeId: string, flow: string): string {
return nodeId.startsWith(`${flow}.`) ? nodeId.slice(flow.length + 1) : nodeId return nodeId.startsWith(`${flow}.`) ? nodeId.slice(flow.length + 1) : nodeId
} }
/** What one node printed, or the whole flow when nothing is singled out. */
export type LogsFilter = {
open: boolean
/** A node id within this flow, as the canvas asked for it. */
node: string | null
onOpenChange: (open: boolean) => void
onClearNode: () => void
}
/** /**
* What the nodes of this flow printed, and the tracebacks of the ones that * What the nodes of this flow printed, and the tracebacks of the ones that
* failed — the detail the one-line error bubble on a node has no room for. * failed — the detail the one-line error bubble on a node has no room for.
*
* Opening it is not only the dock's to do: a failing node points straight at
* its own traceback, which is why the open state lives in the editor.
*/ */
export function LogsPanel({ flow }: { flow: string }) { export function LogsPanel({
const lines = useLiveLogs().filter((line) => line.flow === flow) flow,
open,
node,
onOpenChange,
onClearNode,
}: { flow: string } & LogsFilter) {
const lines = useLiveLogs().filter(
(line) =>
line.flow === flow && (!node || nodeLabel(line.node, flow) === node),
)
const bottom = useRef<HTMLLIElement | null>(null) const bottom = useRef<HTMLLIElement | null>(null)
// Follow the tail, which is where a running flow puts what just happened. // Follow the tail, which is where a running flow puts what just happened.
@@ -45,7 +66,7 @@ export function LogsPanel({ flow }: { flow: string }) {
}, [lines.length]) }, [lines.length])
return ( return (
<Popover> <Popover open={open} onOpenChange={onOpenChange}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -65,9 +86,23 @@ export function LogsPanel({ flow }: { flow: string }) {
<PopoverContent align="center" className="w-[28rem] p-0"> <PopoverContent align="center" className="w-[28rem] p-0">
<div className="flex items-center justify-between border-b border-border px-3 py-2"> <div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="flex min-w-0 items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Logs Logs
</p> </p>
{node ? (
<Button
variant="ghost"
size="sm"
className="h-6 min-w-0 gap-1 px-2 font-mono text-xs"
onClick={onClearNode}
data-testid="clear-logs-filter"
>
<span className="truncate">{node}</span>
<X className="size-3 shrink-0" />
</Button>
) : null}
</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -81,7 +116,9 @@ export function LogsPanel({ flow }: { flow: string }) {
{lines.length === 0 ? ( {lines.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground"> <p className="px-3 py-6 text-center text-sm text-muted-foreground">
Nothing yet. Anything a node prints shows up here. {node
? `Nothing from ${node} yet.`
: "Nothing yet. Anything a node prints shows up here."}
</p> </p>
) : ( ) : (
<ScrollArea className="h-72"> <ScrollArea className="h-72">
+25 -7
View File
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { MessageSparkline } from "./MessageSparkline" import { MessageSparkline } from "./MessageSparkline"
import { import {
@@ -37,7 +38,7 @@ import {
nodeSourceQueryOptions, nodeSourceQueryOptions,
secretsQueryOptions, secretsQueryOptions,
} from "./queries" } from "./queries"
import { PANEL_SECTION, SidePanel } from "./SidePanel" import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor")) const NodeEditor = lazy(() => import("./NodeEditor"))
@@ -679,6 +680,15 @@ function PanelBody({
const save = useRef(onSaveSource) const save = useRef(onSaveSource)
save.current = onSaveSource save.current = onSaveSource
/** Send what is typed now rather than a second from now. */
const saveNow = () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current !== null) save.current(pending.current)
}
const editCode = (next: string) => { const editCode = (next: string) => {
setCode(next) setCode(next)
pending.current = next pending.current = next
@@ -689,6 +699,17 @@ function PanelBody({
}, 1000) }, 1000)
} }
// ⌘S in the editor means "apply this code"; the editor's own state is here,
// so the binding is too. Anywhere else on the canvas it publishes the flow.
useShortcuts(
{
"mod+s": (event) => {
if (inCodeEditor(event.target)) saveNow()
},
},
["mod+s"],
)
// Closing the panel must not lose the last keystrokes. // Closing the panel must not lose the last keystrokes.
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -827,13 +848,10 @@ export function NodePanel({
<span className="min-w-0 truncate font-mono text-sm text-muted-foreground"> <span className="min-w-0 truncate font-mono text-sm text-muted-foreground">
/{flow}/ /{flow}/
</span> </span>
<Input <PanelTitle
value={node.title || node.id} value={node.title || node.id}
aria-label="Node name" label="Node name"
className="h-8 flex-1 text-sm font-medium" onConfirm={(title) => onChange({ ...node, title })}
onChange={(event) =>
onChange({ ...node, title: event.target.value })
}
/> />
</div> </div>
) : null ) : null
+61 -6
View File
@@ -1,9 +1,10 @@
import { X } from "lucide-react" import { X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { useEffect } from "react" import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useIsMobile } from "@/hooks/useMobile" import { useIsMobile } from "@/hooks/useMobile"
import { duration, easeEmphasized, easeStandard } from "@/lib/motion" import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
@@ -27,6 +28,58 @@ const panelSlide = {
export const PANEL_SECTION = export const PANEL_SECTION =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground" "text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
/**
* What the thing in this panel is called, and the one place to rename it.
*
* Renaming is an explicit act: the field keeps what is typed until it is
* confirmed, so a half-typed name never reaches the canvas.
*/
export function PanelTitle({
value,
placeholder,
label,
onConfirm,
}: {
value: string
placeholder?: string
/** Names the field for screen readers. */
label: string
onConfirm: (next: string) => void
}) {
const [draft, setDraft] = useState(value)
// Another thing to name is another field.
useEffect(() => setDraft(value), [value])
const next = draft.trim()
const changed = next !== value
return (
<>
<Input
value={draft}
placeholder={placeholder}
aria-label={label}
autoComplete="off"
className="h-8 min-w-0 flex-1 text-sm font-medium"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && changed) onConfirm(next)
}}
/>
{changed ? (
<Button
size="sm"
className="h-8 shrink-0"
onClick={() => onConfirm(next)}
data-testid="confirm-rename"
>
Confirm
</Button>
) : null}
</>
)
}
/** /**
* The editor's settings panel: floating over the canvas so the graph stays * The editor's settings panel: floating over the canvas so the graph stays
* visible and running behind it, a full-screen sheet where there is no room * visible and running behind it, a full-screen sheet where there is no room
@@ -51,7 +104,7 @@ export function SidePanel({
testId: string testId: string
/** Remounts the contents when the thing being edited changes. */ /** Remounts the contents when the thing being edited changes. */
bodyKey: string bodyKey: string
/** Fill the content area instead of floating beside the canvas. */ /** Take the width a code editor needs, still floating over the canvas. */
expanded?: boolean expanded?: boolean
header: ReactNode header: ReactNode
footer?: ReactNode footer?: ReactNode
@@ -101,6 +154,7 @@ export function SidePanel({
<Sheet open={open} onOpenChange={(next) => !next && onClose()}> <Sheet open={open} onOpenChange={(next) => !next && onClose()}>
<SheetContent <SheetContent
side="right" side="right"
data-testid={testId}
// The panel header carries its own close button, and opening should // The panel header carries its own close button, and opening should
// not drop the caret into the first field. // not drop the caret into the first field.
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden" className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
@@ -131,11 +185,12 @@ export function SidePanel({
data-testid={testId} data-testid={testId}
data-expanded={expanded || undefined} data-expanded={expanded || undefined}
className={cn( className={cn(
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden border border-border bg-card/80 shadow-e2 backdrop-blur-md", "pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
expanded expanded
? // Fills the content region, which already starts after the sidebar. ? // Still a floating surface, only given the room code needs —
"inset-0 rounded-none" // and the canvas chrome keeps its lanes above and below.
: "inset-y-4 right-4 w-[400px] rounded-lg", "inset-x-4 bottom-16 top-16"
: "inset-y-4 right-4 w-[400px]",
)} )}
> >
{contents} {contents}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react"
/**
* Window-level keyboard shortcuts.
*
* A chord reads as `mod+shift+z`, where `mod` is ⌘ on a Mac and Ctrl
* everywhere else. Bindings are plain data, so a view declares the whole set
* it answers to in one place.
*/
export type Shortcuts = Record<string, (event: KeyboardEvent) => void>
/** Text fields and the code editor keep their own bindings and their own undo. */
export function isTextEntry(target: EventTarget | null): boolean {
return Boolean(
(target as Element | null)?.closest?.(
"input, textarea, [contenteditable='true'], .monaco-editor",
),
)
}
/** Focus is inside the embedded code editor. */
export function inCodeEditor(target: EventTarget | null): boolean {
return Boolean((target as Element | null)?.closest?.(".monaco-editor"))
}
function chordOf(event: KeyboardEvent): string {
const parts: string[] = []
if (event.metaKey || event.ctrlKey) parts.push("mod")
if (event.altKey) parts.push("alt")
if (event.shiftKey) parts.push("shift")
// Shift makes it a capital Z, so compare on the letter alone.
parts.push(event.key.toLowerCase())
return parts.join("+")
}
/**
* Bind chords for as long as the component is mounted.
*
* A chord listed in `inTextEntry` fires even while a field or the code editor
* has focus; every other one steps aside, because typing there means typing.
*/
export function useShortcuts(bindings: Shortcuts, inTextEntry: string[] = []) {
// Read through a ref, so a fresh handler on every render never re-binds.
const latest = useRef({ bindings, inTextEntry })
latest.current = { bindings, inTextEntry }
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const chord = chordOf(event)
const run = latest.current.bindings[chord]
if (!run) return
if (
isTextEntry(event.target) &&
!latest.current.inTextEntry.includes(chord)
) {
return
}
event.preventDefault()
run(event)
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [])
}