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
This commit is contained in:
2026-08-19 13:20:12 +02:00
co-authored by Claude Fable 5
parent b49fd58545
commit 8feabd33c5
6 changed files with 313 additions and 74 deletions
@@ -0,0 +1,71 @@
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
/** Pixels a second. Slow enough to read, quick enough not to be a wait. */
const SPEED = 25
/** Long enough to read the start before it moves, and again at the far end. */
const DWELL = 1.2
/**
* Text that scrolls its own overflow into view and back.
*
* A long message name, or a value that does not fit, would otherwise widen
* whatever holds it until the panel around it gives way. This keeps the width
* the layout asked for and moves the text instead — and stays perfectly still
* when it already fits, so a column of these is not a column of motion.
*
* Reduced motion is handled by the `MotionConfig reducedMotion="user"` each app
* root is wrapped in: the text simply sits at its start.
*/
export function Marquee({
text,
className,
}: {
text: string
className?: string
}) {
const ref = useRef<HTMLSpanElement>(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) return
const measure = () =>
setOverflow(Math.max(0, el.scrollWidth - el.clientWidth))
measure()
// The panel it sits in can be resized, and the same text overflows or does
// not depending on how much room it was given.
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [text])
return (
<span
ref={ref}
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
>
<motion.span
className="inline-block"
animate={{ x: -overflow }}
transition={
overflow
? {
duration: overflow / SPEED,
ease: "linear",
delay: DWELL,
repeat: Number.POSITIVE_INFINITY,
repeatType: "reverse",
repeatDelay: DWELL,
}
: undefined
}
>
{text}
</motion.span>
</span>
)
}
+3 -50
View File
@@ -1,17 +1,15 @@
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 { Marquee } from "@/components/Common/Marquee"
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"
import { ValuePreview } from "./ValuePreview"
function relativeTime(ts: number | null | undefined): string {
if (!ts) return "not seen yet"
@@ -35,47 +33,6 @@ function formatScalar(value: unknown): string | null {
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<HTMLSpanElement>(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 (
<span
ref={ref}
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
>
<motion.span
className="inline-block"
animate={{ x: -overflow }}
transition={
overflow
? {
duration: overflow / 25,
ease: "linear",
delay: 1.2,
repeat: Number.POSITIVE_INFINITY,
repeatType: "reverse",
repeatDelay: 1.2,
}
: undefined
}
>
{text}
</motion.span>
</span>
)
}
export type InspectedEdge = {
message: string
/** Node titles, so the popover names the two ends in the user's own words. */
@@ -199,11 +156,7 @@ export function EdgeInspector({
Nothing has come through yet. Run the flow to see a value here.
</p>
) : scalar === null ? (
<ScrollArea className="mt-2 max-h-48">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
{JSON.stringify(live.value, null, 2)}
</pre>
</ScrollArea>
<ValuePreview value={live.value} className="mt-2" defaultOpen />
) : null}
</PopoverContent>
</Popover>
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import type { HistoryPoint } from "@/client"
import type { DType, HistoryPoint } from "@/client"
import { Sparkline } from "@/components/Common/Sparkline"
import {
Tooltip,
@@ -11,16 +11,11 @@ import {
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
/** How a value that cannot be plotted still reads. */
function describe(value: unknown): string {
if (typeof value === "string") return value
return JSON.stringify(value) ?? String(value)
}
/** 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`
@@ -65,9 +60,12 @@ function useSettled(value: string): string {
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))
@@ -102,21 +100,24 @@ export function MessageSparkline({
].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 text-muted-foreground">
Nothing has come through yet.
<p
className="text-xs leading-5 text-muted-foreground"
title="Nothing has come through yet"
>
</p>
)
}
return (
<div className="flex items-baseline gap-2 text-xs">
<span className="min-w-0 flex-1 truncate font-mono">
{describe(live.value)}
</span>
<span className="shrink-0 text-muted-foreground">no history</span>
</div>
)
return <ValuePreview value={live.value} dtype={dtype} />
}
// The value is live here, so the dot on the newest reading is earned. The
+13 -7
View File
@@ -239,7 +239,7 @@ function PortList({
}
return (
<div className="grid gap-3">
<div className="grid min-w-0 gap-3">
<div className="flex items-center justify-between">
<span className={SECTION}>{title}</span>
<Button
@@ -262,8 +262,8 @@ function PortList({
{specs.map((spec, index) => (
// The curve reads as its own thing rather than as part of the row
// above it, so it gets a little air.
<div key={`port-${index}`} className="grid gap-2">
<div className="flex items-center gap-1.5">
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
@@ -279,7 +279,7 @@ function PortList({
}
>
<SelectTrigger
className="!h-8 w-[92px] text-sm"
className="!h-8 w-[104px] text-sm"
aria-label="Type"
>
<SelectValue />
@@ -323,7 +323,7 @@ function PortList({
placeholder="∞"
aria-label="Deliver at most every n seconds"
title="Deliver at most every n seconds; empty is every time"
className="h-8 w-16 text-sm"
className="h-8 w-14 text-sm"
onChange={(event) =>
update(index, { interval: Number(event.target.value) || 0 })
}
@@ -357,7 +357,13 @@ function PortList({
<X />
</Button>
</div>
{spec.name ? <MessageSparkline flow={flow} name={spec.name} /> : null}
{spec.name ? (
<MessageSparkline
flow={flow}
name={spec.name}
dtype={spec.dtype ?? undefined}
/>
) : null}
</div>
))}
</div>
@@ -967,7 +973,7 @@ function PanelBody({
return (
<>
<div className={cn("grid gap-6 p-4", expanded && "max-w-2xl")}>
<div className={cn("grid min-w-0 gap-6 p-4", expanded && "max-w-2xl")}>
<PortList
title="Consumes"
specs={node.requires ?? []}
@@ -0,0 +1,115 @@
import { ChevronRight } from "lucide-react"
import { useState } from "react"
import type { DType } from "@/client"
import { Marquee } from "@/components/Common/Marquee"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn, si } from "@/lib/utils"
/** How much of a structured value is worth unfolding in a side panel. */
const MAX_HEIGHT = "max-h-48"
function count(n: number, one: string, many = `${one}s`): string {
return `${n} ${n === 1 ? one : many}`
}
/** Whether this is an artifact reference rather than data of its own. */
function isArtifact(value: Record<string, unknown>): boolean {
return typeof value.digest === "string" && value.digest.startsWith("sha256:")
}
/**
* What a structured value *is*, in the space a value would have taken.
*
* The shape is what you want while wiring — that this port carries a record of
* three fields, or a checkpoint of sixty kilobytes — and the contents are what
* you want once something looks wrong. Serialising the whole thing into a line
* answers the second question badly and the first one not at all.
*/
function summarize(value: object, dtype?: DType): string {
if (Array.isArray(value)) {
return `${dtype ?? "list"} · ${count(value.length, "item")}`
}
const record = value as Record<string, unknown>
if (isArtifact(record)) {
const name =
typeof record.name === "string" && record.name ? record.name : ""
const size = typeof record.size === "number" ? `${si(record.size)}B` : ""
return ["artifact", name, size].filter(Boolean).join(" · ")
}
if (Array.isArray(record.lines)) {
const points = record.lines.reduce(
(total: number, line: unknown) =>
total +
(Array.isArray((line as { points?: unknown[] })?.points)
? ((line as { points: unknown[] }).points.length as number)
: 0),
0,
)
return `series · ${count(record.lines.length, "line")}, ${count(points, "point")}`
}
return `${dtype ?? "record"} · ${count(Object.keys(record).length, "field")}`
}
/**
* What a message is carrying: its shape at rest, its contents on request.
*
* A scalar simply reads, scrolling itself when it is longer than the room it
* was given. Anything structured shows what it is and unfolds when asked —
* which is what keeps a panel four hundred pixels wide from being pushed open
* by one checkpoint reference.
*/
export function ValuePreview({
value,
dtype,
defaultOpen = false,
className,
}: {
value: unknown
dtype?: DType
/** Start unfolded, where there is room for it — an inspector, not a row. */
defaultOpen?: boolean
className?: string
}) {
const [open, setOpen] = useState(defaultOpen)
const structured = value !== null && typeof value === "object"
if (!structured) {
return (
<Marquee
text={value === undefined ? "" : String(value)}
className={cn("block font-mono text-xs", className)}
/>
)
}
return (
<div className={cn("min-w-0", className)}>
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
className="flex w-full min-w-0 items-center gap-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="value-preview-toggle"
>
<ChevronRight
className={cn(
"size-3 shrink-0 transition-transform duration-[--duration-fast]",
open && "rotate-90",
)}
/>
<Marquee text={summarize(value, dtype)} className="flex-1 font-mono" />
</button>
{open ? (
<ScrollArea className={cn("mt-1", MAX_HEIGHT)}>
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
{JSON.stringify(value, null, 2)}
</pre>
</ScrollArea>
) : null}
</div>
)
}