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
+94 -32
View File
@@ -1,8 +1,11 @@
import { ArrowRight } from "lucide-react"
import { ArrowRight, Trash2 } from "lucide-react"
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { displayName } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
@@ -15,6 +18,60 @@ function relativeTime(ts: number | null | undefined): string {
return `${Math.round(seconds / 3600)}h ago`
}
/**
* A value short enough to share the summary row, or `null` for the objects and
* arrays that need the payload view instead.
*/
function formatScalar(value: unknown): string | null {
// Three decimals, trailing zeros dropped: enough precision to be useful, never
// wide enough to wrap the row.
if (typeof value === "number") return String(Number(value.toFixed(3)))
if (typeof value === "string") return value
if (typeof value === "boolean" || value === null) return String(value)
return null
}
/**
* Text that scrolls its own overflow into view and back, so a long name stays
* readable without widening the popover. Still text at rest when it fits.
*/
function Marquee({ text, className }: { text: string; className?: string }) {
const ref = useRef<HTMLSpanElement>(null)
const [overflow, setOverflow] = useState(0)
// biome-ignore lint/correctness/useExhaustiveDependencies: a new string is what changes the measurement.
useEffect(() => {
const el = ref.current
if (el) setOverflow(Math.max(0, el.scrollWidth - el.clientWidth))
}, [text])
return (
<span
ref={ref}
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
>
<motion.span
className="inline-block"
animate={{ x: -overflow }}
transition={
overflow
? {
duration: overflow / 25,
ease: "linear",
delay: 1.2,
repeat: Number.POSITIVE_INFINITY,
repeatType: "reverse",
repeatDelay: 1.2,
}
: undefined
}
>
{text}
</motion.span>
</span>
)
}
export type InspectedEdge = {
message: string
/** Node titles, so the popover names the two ends in the user's own words. */
@@ -41,6 +98,8 @@ export function EdgeInspector({
const live = useLiveValue(edge?.message)
if (!edge) return null
const scalar = live === undefined ? null : formatScalar(live.value)
return (
<Popover open onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor
@@ -52,44 +111,47 @@ export function EdgeInspector({
className="w-72 p-3"
data-testid="edge-inspector"
>
<p className="flex items-center gap-1.5 text-sm">
<span className="min-w-0 flex-1 truncate font-medium">
{edge.from}
</span>
<div className="flex items-center gap-1.5 text-sm">
<Marquee text={edge.from} className="flex-1 font-medium" />
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-right font-medium">
{edge.to}
<Marquee text={edge.to} className="flex-1 text-right font-medium" />
<Button
variant="ghost"
size="icon-sm"
className="-my-1 -mr-1 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onUnbind(edge.message)}
aria-label="Disconnect"
>
<Trash2 />
</Button>
</div>
<div className="mt-1.5 flex items-baseline gap-2 text-xs">
<Marquee
text={displayName(flow, edge.message)}
className="flex-1 font-mono text-muted-foreground"
/>
{scalar === null ? null : (
<span className="max-w-[45%] shrink-0 truncate font-mono font-medium">
{scalar}
</span>
)}
<span className="shrink-0 text-muted-foreground">
{relativeTime(live?.ts)}
</span>
</p>
<p className="mt-1 font-mono text-xs text-muted-foreground">
{displayName(flow, edge.message)}
</p>
</div>
{live === undefined ? (
<p className="mt-2 text-sm text-muted-foreground">
Nothing has come through yet. Run the flow to see a value here.
</p>
) : (
<>
<ScrollArea className="mt-2 max-h-48">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
{JSON.stringify(live.value, null, 2)}
</pre>
</ScrollArea>
<p className="mt-2 text-xs text-muted-foreground">
{relativeTime(live.ts)}
</p>
</>
)}
<Button
variant="ghost"
size="sm"
className="mt-2 h-7 w-full justify-start text-xs text-muted-foreground"
onClick={() => onUnbind(edge.message)}
>
Disconnect
</Button>
) : scalar === null ? (
<ScrollArea className="mt-2 max-h-48">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
{JSON.stringify(live.value, null, 2)}
</pre>
</ScrollArea>
) : null}
</PopoverContent>
</Popover>
)