Add undo on the canvas, and sharpen the flow chrome

Deleting a node cost its source with no way back. Every mutation already
funnels through one commit, so undo/redo is a bounded stack of node
snapshots replayed through the same debounced save. Deleting a node only
drops it from flow.json — the source file survives — so restoring the id
restores the code. Renaming a message now offers to follow the rename
across every node still bound to the old name, as one undoable step.

Autosave never fired: flush depended on the whole mutation object, which
react-query rebuilds every render, so the effect re-ran and its cleanup
cancelled the pending timer. Unmounting now flushes rather than drops.

The canvas looked blurry zoomed out because Background scales the dot
radius by zoom, leaving quarter-pixel dots on a drifting tile; radius
and spacing now divide the zoom back out, spacing in octaves so the grid
halves. Edge value labels are opaque, the edge popover fits its summary
on one row with a trash icon and scrolls names that overflow, and flow
settings moved to the flowbar. The flowbar was sized against the
viewport rather than the canvas, so its buttons left the screen once
enough flows were open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
This commit is contained in:
Melvin Strobl
2026-08-15 21:19:53 +02:00
co-authored by Claude Opus 5
parent 6b73ca3645
commit 9cede8dbb5
8 changed files with 410 additions and 75 deletions
+37 -3
View File
@@ -44,14 +44,20 @@ function MessageNameInput({
placeholder,
autoFocus,
onChange,
onRenamed,
}: {
value: string
suggestions: string[]
placeholder: string
autoFocus: boolean
onChange: (next: string) => void
/** The name as it was before this edit, once the field is done with. */
onRenamed?: (previous: string, next: string) => void
}) {
const [open, setOpen] = useState(false)
// Every keystroke commits, so a rename is only a rename once the user is
// finished with the field.
const before = useRef(value)
const matches = suggestions.filter(
(name) =>
name !== value && name.toLowerCase().includes(value.toLowerCase()),
@@ -68,8 +74,15 @@ function MessageNameInput({
// A port added by hand is meant to be named right away.
autoFocus={autoFocus}
className="h-8 flex-1 font-mono text-sm"
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onFocus={() => {
before.current = value
setOpen(true)
}}
onBlur={() => {
setOpen(false)
if (before.current !== value) onRenamed?.(before.current, value)
before.current = value
}}
onChange={(event) => {
onChange(event.target.value)
setOpen(true)
@@ -122,6 +135,7 @@ function PortList({
emptyHint,
suggestions,
onChange,
onRenamed,
}: {
title: string
specs: MessageSpec[]
@@ -129,6 +143,7 @@ function PortList({
emptyHint: string
suggestions: string[]
onChange: (next: MessageSpec[]) => void
onRenamed?: (previous: string, next: string) => void
}) {
// The port just added, so its name field can take focus.
const [freshIndex, setFreshIndex] = useState<number | null>(null)
@@ -169,6 +184,7 @@ function PortList({
placeholder={`name in ${flow}`}
autoFocus={index === freshIndex}
onChange={(name) => update(index, { name, port: "" })}
onRenamed={onRenamed}
/>
<Select
value={spec.dtype ?? "float"}
@@ -212,7 +228,12 @@ function ParamsForm({
}) {
const properties = (schema?.properties ?? {}) as Record<
string,
{ type?: string; title?: string; default?: unknown }
{
type?: string
title?: string
description?: string
default?: unknown
}
>
const entries = Object.entries(properties)
if (entries.length === 0) return null
@@ -265,6 +286,11 @@ function ParamsForm({
)
}
/>
{property.description ? (
<p className="text-xs text-muted-foreground">
{property.description}
</p>
) : null}
</div>
)
})}
@@ -279,6 +305,7 @@ function PanelBody({
suggestions,
expanded,
onChange,
onRenameMessage,
onSaveSource,
onToggleExpand,
}: {
@@ -288,6 +315,7 @@ function PanelBody({
suggestions: PortSuggestions
expanded: boolean
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onToggleExpand: () => void
}) {
@@ -341,6 +369,9 @@ function PanelBody({
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => onChange({ ...node, provides })}
// Only the publishing side names a message; an input is as often
// re-pointed at a different one as it is renamed.
onRenamed={onRenameMessage}
/>
<ParamsForm
schema={nodeType?.params_schema}
@@ -398,6 +429,7 @@ export function NodePanel({
suggestions,
expanded,
onChange,
onRenameMessage,
onSaveSource,
onToggleExpand,
onClose,
@@ -409,6 +441,7 @@ export function NodePanel({
suggestions: PortSuggestions
expanded: boolean
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onToggleExpand: () => void
onClose: () => void
@@ -455,6 +488,7 @@ export function NodePanel({
suggestions={suggestions}
expanded={expanded}
onChange={onChange}
onRenameMessage={onRenameMessage}
onSaveSource={onSaveSource}
onToggleExpand={onToggleExpand}
/>