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 { useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import type { FlowSummary, NodeTypeInfo } from "@/client"
import {
@@ -40,17 +39,6 @@ export function CommandPalette({
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
// exit animation and leave its overlay swallowing clicks. Unmounting the
// 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"
import { slideUp, transitions } from "@/lib/motion"
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
@@ -47,6 +47,7 @@ export function FlowDock({
running,
enabled,
paused,
logs,
onAddNode,
onRun,
onTogglePause,
@@ -57,6 +58,8 @@ export function FlowDock({
running: boolean
enabled: boolean
paused: boolean
/** Owned by the editor, because a failing node can open it too. */
logs: LogsFilter
onAddNode: () => void
onRun: () => void
onTogglePause: () => void
@@ -164,7 +167,7 @@ export function FlowDock({
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<LogsPanel flow={flow} />
<LogsPanel flow={flow} {...logs} />
<Tooltip>
<TooltipTrigger asChild>
+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}
+28 -1
View File
@@ -2,6 +2,7 @@ import { Handle, type NodeProps, Position } from "@xyflow/react"
import {
Bell,
Braces,
Bug,
Clock,
Code2,
Database,
@@ -20,6 +21,7 @@ import {
import { memo } from "react"
import type { MessageSpec, NodeDef_Input } from "@/client"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
@@ -61,6 +63,8 @@ export type FlowNodeData = {
typeLabel: string
isPlugin?: boolean
issueText: string
/** Open the logs at this node's own lines. */
onShowLogs?: (nodeId: string) => void
[key: string]: unknown
}
@@ -103,7 +107,7 @@ function PortHandles({
}
function FlowNodeComponent({ data, selected }: NodeProps) {
const { definition, flow, typeLabel, isPlugin, issueText } =
const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } =
data as FlowNodeData
const live = useNodeStatus(`${flow}.${definition.id}`)
const emits = useNodeEmits(`${flow}.${definition.id}`)
@@ -153,6 +157,29 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
{typeLabel}
</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 ? (
<Tooltip>
<TooltipTrigger asChild>
+8 -53
View File
@@ -10,25 +10,18 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { PANEL_SECTION, SidePanel } from "./SidePanel"
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
/**
* The flow's own settings, in the same panel its nodes use.
*
* 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.
* 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.
*/
export function FlowPanel({
open,
definition,
nodeCount,
renaming,
onChange,
onRename,
onDelete,
hasDraft,
discarding,
@@ -41,9 +34,7 @@ export function FlowPanel({
open: boolean
definition: FlowDef_Input
nodeCount: number
renaming: boolean
onChange: (next: FlowDef_Input) => void
onRename: (newName: string) => void
onDelete: () => void
hasDraft: boolean
discarding: boolean
@@ -53,13 +44,9 @@ export function FlowPanel({
onToggleEnabled: (next: boolean) => void
onClose: () => void
}) {
const [name, setName] = useState(definition.name)
const [confirmOpen, setConfirmOpen] = useState(false)
const [discardOpen, setDiscardOpen] = useState(false)
const valid = NAME_PATTERN.test(name)
const changed = name !== definition.name
return (
<>
<SidePanel
@@ -69,14 +56,11 @@ export function FlowPanel({
bodyKey={definition.name}
onClose={onClose}
header={
<Input
<PanelTitle
value={definition.title ?? ""}
placeholder={definition.name}
aria-label="Flow title"
className="h-8 flex-1 text-sm font-medium"
onChange={(event) =>
onChange({ ...definition, title: event.target.value })
}
label="Flow title"
onConfirm={(title) => onChange({ ...definition, title })}
/>
}
footer={
@@ -110,40 +94,11 @@ export function FlowPanel({
</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">
<span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{definition.name}</span> namespaces
every message in here.{" "}
{nodeCount === 0
? "No nodes yet."
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`}
+45 -8
View File
@@ -1,4 +1,4 @@
import { Terminal } from "lucide-react"
import { Terminal, X } from "lucide-react"
import { useEffect, useRef } from "react"
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
}
/** 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
* 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 }) {
const lines = useLiveLogs().filter((line) => line.flow === flow)
export function LogsPanel({
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)
// 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])
return (
<Popover>
<Popover open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@@ -65,9 +86,23 @@ export function LogsPanel({ flow }: { flow: string }) {
<PopoverContent align="center" className="w-[28rem] p-0">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Logs
</p>
<div className="flex min-w-0 items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Logs
</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
variant="ghost"
size="sm"
@@ -81,7 +116,9 @@ export function LogsPanel({ flow }: { flow: string }) {
{lines.length === 0 ? (
<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>
) : (
<ScrollArea className="h-72">
+25 -7
View File
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { MessageSparkline } from "./MessageSparkline"
import {
@@ -37,7 +38,7 @@ import {
nodeSourceQueryOptions,
secretsQueryOptions,
} from "./queries"
import { PANEL_SECTION, SidePanel } from "./SidePanel"
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor"))
@@ -679,6 +680,15 @@ function PanelBody({
const save = useRef(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) => {
setCode(next)
pending.current = next
@@ -689,6 +699,17 @@ function PanelBody({
}, 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.
useEffect(() => {
return () => {
@@ -827,13 +848,10 @@ export function NodePanel({
<span className="min-w-0 truncate font-mono text-sm text-muted-foreground">
/{flow}/
</span>
<Input
<PanelTitle
value={node.title || node.id}
aria-label="Node name"
className="h-8 flex-1 text-sm font-medium"
onChange={(event) =>
onChange({ ...node, title: event.target.value })
}
label="Node name"
onConfirm={(title) => onChange({ ...node, title })}
/>
</div>
) : null
+61 -6
View File
@@ -1,9 +1,10 @@
import { X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react"
import { useEffect } from "react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useIsMobile } from "@/hooks/useMobile"
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
@@ -27,6 +28,58 @@ const panelSlide = {
export const PANEL_SECTION =
"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
* visible and running behind it, a full-screen sheet where there is no room
@@ -51,7 +104,7 @@ export function SidePanel({
testId: string
/** Remounts the contents when the thing being edited changes. */
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
header: ReactNode
footer?: ReactNode
@@ -101,6 +154,7 @@ export function SidePanel({
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
<SheetContent
side="right"
data-testid={testId}
// The panel header carries its own close button, and opening should
// 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"
@@ -131,11 +185,12 @@ export function SidePanel({
data-testid={testId}
data-expanded={expanded || undefined}
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
? // Fills the content region, which already starts after the sidebar.
"inset-0 rounded-none"
: "inset-y-4 right-4 w-[400px] rounded-lg",
? // Still a floating surface, only given the room code needs —
// and the canvas chrome keeps its lanes above and below.
"inset-x-4 bottom-16 top-16"
: "inset-y-4 right-4 w-[400px]",
)}
>
{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)
}, [])
}