Files
app/frontend/src/components/Flow/MessageSparkline.tsx
T
stroblme 4b7dfc066b 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.
2026-08-19 13:20:12 +02:00

137 lines
4.8 KiB
TypeScript

import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import type { DType, HistoryPoint } from "@/client"
import { Sparkline } from "@/components/Common/Sparkline"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { qualify } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { messageHistoryQueryOptions } from "./queries"
import { ValuePreview } from "./ValuePreview"
/** The same bound the server keeps, so the live tail cannot outgrow the window. */
const WINDOW = 120
/** A stretch of time in the coarsest unit that still says it. */
function span(seconds: number): string {
if (seconds < 90) return `${Math.round(seconds)}s`
if (seconds < 5400) return `${Math.round(seconds / 60)}m`
return `${Math.round(seconds / 3600)}h`
}
/**
* What the curve is actually showing.
*
* These are a ring of the last `WINDOW` values per message, not a window of
* time — a message that fires twice an hour and one that fires at 10 Hz draw
* the same width for wildly different spans. The readings themselves are what
* says which, so the caption reads it off them rather than claiming a range.
*/
function caption(points: HistoryPoint[]): string {
const readings = `The last ${points.length} reading${points.length === 1 ? "" : "s"}`
const covered = points[points.length - 1].ts - points[0].ts
return covered > 0 ? `${readings}, over ${span(covered)}.` : `${readings}.`
}
/**
* The name only settles once typing stops. Without this, every keystroke in the
* message field would ask the server for a history.
*/
function useSettled(value: string): string {
const [settled, setSettled] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setSettled(value), 400)
return () => clearTimeout(timer)
}, [value])
return settled
}
/**
* How one message has been moving, as a sparkline with a live end.
*
* The panel already knows the current value, so the three silences stay
* distinct: nothing has run yet, the message carries something a chart cannot
* say anything about, or there is a series to draw.
*/
export function MessageSparkline({
flow,
name,
dtype,
}: {
flow: string
name: string
/** What the port declares, so a value that never arrived still says what it would be. */
dtype?: DType
}) {
const message = useSettled(name)
const live = useLiveValue(qualify(flow, message))
const { data } = useQuery(messageHistoryQueryOptions(flow, message))
const [tail, setTail] = useState<HistoryPoint[]>([])
// Pointing the field at another message makes the collected tail meaningless.
// biome-ignore lint/correctness/useExhaustiveDependencies: the name is what invalidates the tail, not anything the effect reads.
useEffect(() => {
setTail((previous) => (previous.length ? [] : previous))
}, [message])
// Values keep arriving over the socket while the panel is open. Appending
// them beats refetching the whole series on every emission.
const ts = live?.ts
const value = live?.value
useEffect(() => {
if (typeof value !== "number" || !ts) return
setTail((previous) =>
previous[previous.length - 1]?.ts === ts
? previous
: [...previous, { ts, value }].slice(-WINDOW),
)
}, [ts, value])
const fetched = data?.points ?? []
// The fetch and the socket overlap; only what the server had not yet seen.
const since = fetched[fetched.length - 1]?.ts ?? 0
const points = [
...fetched,
...tail.filter((point) => point.ts > since),
].slice(-WINDOW)
if (points.length === 0) {
// Three silences, kept apart: nothing has run, or something arrived that
// no curve can say anything about. The second is worth showing; the first
// is worth admitting to in as few words as the row can spare.
if (live === undefined) {
// A dash rather than a sentence: one of these sits under every port, and
// nine ports of "nothing has come through yet" is a panel of prose about
// the absence of values. The row keeps its height either way, so nothing
// shifts when the first one arrives.
return (
<p
className="text-xs leading-5 text-muted-foreground"
title="Nothing has come through yet"
>
—
</p>
)
}
return <ValuePreview value={live.value} dtype={dtype} />
}
// The value is live here, so the dot on the newest reading is earned. The
// caption is on hover rather than beside the curve: these sit one per port
// in a dense panel, and a line of prose each would crowd it out.
return (
<Tooltip>
<TooltipTrigger asChild>
<div>
<Sparkline points={points} />
</div>
</TooltipTrigger>
<TooltipContent>{caption(points)}</TooltipContent>
</Tooltip>
)
}