Files
app/frontend/src/components/Flow/EdgeInspector.tsx
T
stroblmeandClaude Fable 5 8f72728437 A message shows its shape, and its contents when asked
A record or an artifact reference was serialised into the port row, and the
panel widened until the type selects and the buttons beside them were pushed
off its edge — a checkpoint reference is 130 characters of digest, and none of
them are what you want while wiring a flow. What shows now is what the value
*is*: 'artifact · weights.json · 60B', 'record · 3 fields'. A chevron unfolds
the whole of it, wrapped, inside the panel it belongs to.

A scalar still reads as itself, and scrolls its own overflow into view when it
is longer than the room it was given. That behaviour already existed inside
the edge inspector; it moves to Common/Marquee so the panel can have it too,
and the inspector drops its own copy of the raw-JSON block along with it.

The rows are smaller for it: the type select finally fits the word 'artifact',
and a port nothing has come through on says so with a dash rather than a
sentence — nine ports of 'nothing has come through yet' is a panel of prose
about the absence of values.

The e2e check asserts both halves: that the summary is what appears, and that
the panel is still exactly 400px with the value unfolded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-19 13:20:12 +02:00

165 lines
5.6 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. */}
{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 ? (
<ValuePreview value={live.value} className="mt-2" defaultOpen />
) : null}
</PopoverContent>
</Popover>
)
}