From 8f72728437dd9b213b4dcc61410e2acdc20e8892 Mon Sep 17 00:00:00 2001 From: stroblme Date: Wed, 19 Aug 2026 13:20:12 +0200 Subject: [PATCH] A message shows its shape, and its contents when asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD --- frontend/src/components/Common/Marquee.tsx | 71 +++++++++++ .../src/components/Flow/EdgeInspector.tsx | 53 +------- .../src/components/Flow/MessageSparkline.tsx | 35 +++--- frontend/src/components/Flow/NodePanel.tsx | 20 +-- frontend/src/components/Flow/ValuePreview.tsx | 115 ++++++++++++++++++ frontend/tests/values.spec.ts | 93 ++++++++++++++ 6 files changed, 313 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/Common/Marquee.tsx create mode 100644 frontend/src/components/Flow/ValuePreview.tsx create mode 100644 frontend/tests/values.spec.ts diff --git a/frontend/src/components/Common/Marquee.tsx b/frontend/src/components/Common/Marquee.tsx new file mode 100644 index 0000000..44c1abf --- /dev/null +++ b/frontend/src/components/Common/Marquee.tsx @@ -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(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 ( + + + {text} + + + ) +} diff --git a/frontend/src/components/Flow/EdgeInspector.tsx b/frontend/src/components/Flow/EdgeInspector.tsx index c0db9f3..f2aa90e 100644 --- a/frontend/src/components/Flow/EdgeInspector.tsx +++ b/frontend/src/components/Flow/EdgeInspector.tsx @@ -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(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 ( - - - {text} - - - ) -} - 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.

) : scalar === null ? ( - -
-              {JSON.stringify(live.value, null, 2)}
-            
-
+ ) : null} diff --git a/frontend/src/components/Flow/MessageSparkline.tsx b/frontend/src/components/Flow/MessageSparkline.tsx index 074cdab..2779eb7 100644 --- a/frontend/src/components/Flow/MessageSparkline.tsx +++ b/frontend/src/components/Flow/MessageSparkline.tsx @@ -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 ( -

- Nothing has come through yet. +

+ —

) } - return ( -
- - {describe(live.value)} - - no history -
- ) + return } // The value is live here, so the dot on the newest reading is earned. The diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 824b7d6..ca367b4 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -239,7 +239,7 @@ function PortList({ } return ( -
+
{title}
- {spec.name ? : null} + {spec.name ? ( + + ) : null}
))}
@@ -967,7 +973,7 @@ function PanelBody({ return ( <> -
+
): 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 + 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 ( + + ) + } + + return ( +
+