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
+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