The popover showed a non-numeric value twice: only numbers are recorded, so MessageSparkline fell through to a collapsed ValuePreview and the inspector rendered its own expanded one below it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
import { useMutation } from "@tanstack/react-query"
|
|
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
|
|
|
|
import { FlowsService } from "@/client"
|
|
import { Marquee } from "@/components/Common/Marquee"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { displayName } from "./deriveEdges"
|
|
import { useLiveValue } from "./liveStore"
|
|
import { MessageSparkline } from "./MessageSparkline"
|
|
import { ValuePreview } from "./ValuePreview"
|
|
|
|
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
|
|
}
|
|
|
|
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. Only for a
|
|
number, because only numbers are recorded — for anything else the
|
|
sparkline falls through to its own copy of the value, which the
|
|
popover already shows below. */}
|
|
{typeof live?.value === "number" ? (
|
|
<div className="mt-2">
|
|
<MessageSparkline
|
|
flow={flow}
|
|
name={displayName(flow, edge.message)}
|
|
/>
|
|
</div>
|
|
) : 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" ? (
|
|
<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 ? (
|
|
<ValuePreview value={live.value} className="mt-2" defaultOpen />
|
|
) : null}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|