Files
app/frontend/src/components/Flow/EdgeInspector.tsx
T
stroblmeandClaude Opus 5 9ad44fd3f6 Draw the edge sparkline only for a value it can plot
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
2026-08-25 22:13:32 +02:00

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>
)
}