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(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. */ 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 ( !open && onClose()}>
{live === undefined ? null : ( )}
{scalar === null ? null : ( {scalar} )} {relativeTime(live?.ts)} {/* 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 : (
)} {/* 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" ? (

Last set from {live.source.label} {live.source.detail ? ` (${live.source.detail})` : ""}.

) : null} {live === undefined ? (

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

) : scalar === null ? (
              {JSON.stringify(live.value, null, 2)}
            
) : null} ) }