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:
co-authored by
Claude Opus 5
parent
6b73ca3645
commit
9cede8dbb5
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user