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
+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"}.`}