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 (
-