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
212 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|