From 9cede8dbb5171bb27734b932827b3ae3af8d2317 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Sat, 15 Aug 2026 21:19:53 +0200 Subject: [PATCH] Add undo on the canvas, and sharpen the flow chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o --- .../src/components/Flow/EdgeInspector.tsx | 126 +++++++--- frontend/src/components/Flow/FlowDock.tsx | 19 -- frontend/src/components/Flow/FlowEditor.tsx | 227 ++++++++++++++++-- frontend/src/components/Flow/FlowTabs.tsx | 24 +- frontend/src/components/Flow/LiveEdge.tsx | 2 +- frontend/src/components/Flow/NodePanel.tsx | 40 ++- frontend/src/components/Flow/queries.ts | 12 +- frontend/tests/flows.spec.ts | 35 +++ 8 files changed, 410 insertions(+), 75 deletions(-) diff --git a/frontend/src/components/Flow/EdgeInspector.tsx b/frontend/src/components/Flow/EdgeInspector.tsx index 51b5105..d45f118 100644 --- a/frontend/src/components/Flow/EdgeInspector.tsx +++ b/frontend/src/components/Flow/EdgeInspector.tsx @@ -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(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 ( + + + {text} + + + ) +} + 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 ( !open && onClose()}> -

- - {edge.from} - +

+ - - {edge.to} + + + + +
+ + {scalar === null ? null : ( + + {scalar} + + )} + + {relativeTime(live?.ts)} -

-

- {displayName(flow, edge.message)} -

+ {live === undefined ? (

Nothing has come through yet. Run the flow to see a value here.

- ) : ( - <> - -
-                {JSON.stringify(live.value, null, 2)}
-              
-
-

- {relativeTime(live.ts)} -

- - )} - - + ) : scalar === null ? ( + +
+              {JSON.stringify(live.value, null, 2)}
+            
+
+ ) : null} ) diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index 2d0bd6c..acbe2af 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -3,7 +3,6 @@ import { AlertCircle, Loader2, Maximize2, - Pencil, Play, Plus, ZoomIn, @@ -34,14 +33,12 @@ export function FlowDock({ issues, running, onAddNode, - onEditFlow, onRun, onFocusNode, }: { issues: ValidationIssue[] running: boolean onAddNode: () => void - onEditFlow: () => void onRun: () => void onFocusNode: (nodeId: string) => void }) { @@ -71,22 +68,6 @@ export function FlowDock({ Add a node (⌘K) - - - - - Flow settings - - + + + + ) } @@ -634,14 +840,7 @@ function mergeDragged( /** Seed xyflow's own node state once; it owns positions while you drag. */ function useUnpositionedNodes(definitions: NodeDef_Input[]) { - return useNodesState( - definitions.map((node) => ({ - id: node.id, - type: "flow", - position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 }, - data: {}, - })), - ) + return useNodesState(toCanvasNodes(definitions)) } export function FlowEditor({ flowName }: { flowName: string }) { diff --git a/frontend/src/components/Flow/FlowTabs.tsx b/frontend/src/components/Flow/FlowTabs.tsx index 046c981..baf39e8 100644 --- a/frontend/src/components/Flow/FlowTabs.tsx +++ b/frontend/src/components/Flow/FlowTabs.tsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useMutation, useQueryClient } from "@tanstack/react-query" import { Link, useNavigate } from "@tanstack/react-router" -import { Check, Loader2, Plus, WifiOff } from "lucide-react" +import { Check, Loader2, Pencil, Plus, WifiOff } from "lucide-react" import { motion } from "motion/react" import { useState } from "react" import { useForm } from "react-hook-form" @@ -114,10 +114,12 @@ export function FlowTabs({ flows, active, saving, + onEditFlow, }: { flows: FlowSummary[] active: string saving: boolean + onEditFlow: () => void }) { const [dialogOpen, setDialogOpen] = useState(false) const connected = useLiveConnection() @@ -129,7 +131,7 @@ export function FlowTabs({ initial="hidden" animate="visible" transition={transitions.emphasized} - className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md" + className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md" > @@ -166,6 +168,22 @@ export function FlowTabs({ New flow + + + + + Flow settings + + @@ -182,7 +200,7 @@ export function FlowTabs({ {!connected ? "Reconnecting to the engine" : saving - ? "Saving" + ? "Saving" : "All changes saved"} diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx index 45d3bf3..a236c9f 100644 --- a/frontend/src/components/Flow/LiveEdge.tsx +++ b/frontend/src/components/Flow/LiveEdge.tsx @@ -72,7 +72,7 @@ function LiveEdgeComponent({ style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, }} - className="pointer-events-none absolute max-w-[140px] truncate rounded-full border border-border bg-card/80 px-2 py-0.5 font-mono text-xs text-muted-foreground backdrop-blur-md" + className="pointer-events-none absolute max-w-[140px] truncate rounded-full border border-border bg-card px-2 py-0.5 font-mono text-xs text-foreground" > {formatValue(live.value)} diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 38c2896..5901a3f 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -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(null) @@ -169,6 +184,7 @@ function PortList({ placeholder={`name in ${flow}`} autoFocus={index === freshIndex} onChange={(name) => update(index, { name, port: "" })} + onRenamed={onRenamed} />