Files
app/frontend/src/components/Flow/EdgeInspector.tsx
T
stroblmeandClaude Fable 5 929bb56662 Let an edge pulse end where it started, and draw what it carried
The pulse named its own end colour, so a selected edge decayed to the
neutral stroke and snapped back to blue — a second pulse. Dropping the
`to` keyframe lets the animation land on whatever the edge rests at.

Clicking an edge now shows the same sparkline the node panel draws for
that message, so a single value is read against how it has been moving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:43:26 +02:00

212 lines
6.9 KiB
TypeScript

import { useMutation } from "@tanstack/react-query"
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { FlowsService } from "@/client"
import { Button } from "@/components/ui/button"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import useCustomToast from "@/hooks/useCustomToast"
import { cn } from "@/lib/utils"
import { displayName } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { MessageSparkline } from "./MessageSparkline"
function relativeTime(ts: number | null | undefined): string {
if (!ts) return "not seen yet"
const seconds = Math.max(0, Math.round(Date.now() / 1000 - ts))
if (seconds < 5) return "just now"
if (seconds < 60) return `${seconds}s ago`
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`
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. */
from: string
to: string
/** The producing node's id, which is what replaying the message goes through. */
sourceId: string
x: number
y: number
}
/**
* What last travelled along an edge, and between which two nodes.
*/
export function EdgeInspector({
edge,
flow,
onClose,
onUnbind,
}: {
edge: InspectedEdge | null
flow: string
onClose: () => void
onUnbind: (message: string) => void
}) {
const live = useLiveValue(edge?.message)
const { showErrorToast } = useCustomToast()
// Publishing the value again from the node that produced it runs everything
// downstream exactly as the original did.
const replay = useMutation({
mutationFn: (value: unknown) =>
FlowsService.triggerNode({
name: flow,
nodeId: edge?.sourceId ?? "",
requestBody: { values: { [edge?.message ?? ""]: value } },
}),
onError: () => showErrorToast("The message could not be sent again."),
})
if (!edge) return null
const scalar = live === undefined ? null : formatScalar(live.value)
return (
<Popover open onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor
style={{ position: "fixed", left: edge.x, top: edge.y }}
className="size-0"
/>
<PopoverContent
align="center"
className="w-72 p-3"
data-testid="edge-inspector"
>
<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" />
<Marquee text={edge.to} className="flex-1 text-right font-medium" />
{live === undefined ? null : (
<Button
variant="ghost"
size="icon-sm"
className="-my-1 shrink-0 text-muted-foreground"
onClick={() => replay.mutate(live.value)}
disabled={replay.isPending}
aria-label="Send this message again"
title="Send this message again"
data-testid="replay-message"
>
<RotateCcw />
</Button>
)}
<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>
</div>
{/* The same curve the node panel draws for this message: one value
says little, how it has been moving says the rest. */}
{live === undefined ? null : (
<div className="mt-2">
<MessageSparkline
flow={flow}
name={displayName(flow, edge.message)}
/>
</div>
)}
{/* Several nodes can publish one message, and so can a dashboard or
another flow — so the value alone does not say what caused it. */}
{live?.source && live.source.kind !== "node" ? (
<p className="mt-2 text-sm text-muted-foreground">
Last set from {live.source.label}
{live.source.detail ? ` (${live.source.detail})` : ""}.
</p>
) : null}
{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>
) : 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>
)
}