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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { api, apiPage, deleteAll } from "./utils/api"
|
||||
|
||||
/**
|
||||
* What a message carrying a dictionary looks like in a panel four hundred
|
||||
* pixels wide.
|
||||
*
|
||||
* The regression this exists for: a record or an artifact reference was
|
||||
* serialised into the row, and the panel widened until the type selects and
|
||||
* the buttons beside them were pushed off the edge. The shape shows instead,
|
||||
* and the contents unfold on request — so the check is both that the summary
|
||||
* is what appears, and that the panel is still the width it was told to be.
|
||||
*/
|
||||
|
||||
const flowName = `test_values_${Date.now().toString(36)}`
|
||||
/** The panel's fixed width; the whole point is that a value cannot change it. */
|
||||
const PANEL_WIDTH = 400
|
||||
|
||||
test.use({ storageState: "playwright/.auth/user.json" })
|
||||
test.describe.configure({ mode: "serial" })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await apiPage(browser)
|
||||
await api(page, `/flows/${flowName}`, {
|
||||
method: "PUT",
|
||||
data: {
|
||||
name: flowName,
|
||||
title: "Values",
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: "emit",
|
||||
type: "python",
|
||||
provides: [
|
||||
{ name: "shape", dtype: "record" },
|
||||
{ name: "label", dtype: "str" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
// Saving writes a draft and moves the version on, so publish what is
|
||||
// actually there rather than what it was a moment ago.
|
||||
const draft = await (await api(page, `/flows/${flowName}?draft=true`)).json()
|
||||
await api(page, `/flows/${flowName}/publish`, {
|
||||
method: "POST",
|
||||
data: { version: draft.definition.version },
|
||||
})
|
||||
// Values a flow could plausibly hold: one structured, one longer than the
|
||||
// room a 400px panel has for it.
|
||||
await api(page, `/messages/${flowName}.shape`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
value: { title: "Recovered", body: "R2 = 0.98", severity: "info" },
|
||||
},
|
||||
})
|
||||
await api(page, `/messages/${flowName}.label`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
value: "ubuntu-gpu-node-01.lab.internal (numpy 2.5.2, cuda 12.4)",
|
||||
},
|
||||
})
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
await deleteAll(browser, [`/flows/${flowName}`])
|
||||
})
|
||||
|
||||
test("a dictionary shows its shape, and its contents on request", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/flows/${flowName}`)
|
||||
const node = page.locator(".react-flow__node").first()
|
||||
await node.waitFor({ timeout: 20000 })
|
||||
await node.click()
|
||||
|
||||
const panel = page.locator('aside[role="complementary"]').first()
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
// The shape, not the values.
|
||||
const summary = page.getByTestId("value-preview-toggle")
|
||||
await expect(summary).toContainText("record · 3 fields")
|
||||
await expect(panel).not.toContainText("Recovered")
|
||||
|
||||
// A value cannot push the panel open, however long it is.
|
||||
expect((await panel.boundingBox())?.width).toBe(PANEL_WIDTH)
|
||||
|
||||
// The contents are one click away.
|
||||
await summary.click()
|
||||
await expect(panel).toContainText("Recovered")
|
||||
expect((await panel.boundingBox())?.width).toBe(PANEL_WIDTH)
|
||||
})
|
||||
Reference in New Issue
Block a user