feat(ui): skeleton rows and dot field while Lens runs load

Replace the centered spinner on the Lens traces list with column-shaped
skeleton rows, and paint a muted placeholder profile in the timeline dot
field until the first histogram arrives. InspectorTable gains a reusable
SkeletonRow driven by column meta, and the load-more tail reuses it.
This commit is contained in:
Yujong Lee 2026-10-04 16:15:44 -07:00
parent a0a275e486
commit 35f638b567
9 changed files with 150 additions and 37 deletions

View file

@ -581,9 +581,10 @@ describe("AgentTracesSection", () => {
it("plots every loaded run on the timeline", async () => {
serve(runs);
renderWindowed();
expect(screen.getByTestId("traces-timeline")).toHaveAttribute("aria-busy", "true");
await screen.findAllByTestId("agent-trace-row");
expect(screen.getByTestId("traces-timeline")).toBeInTheDocument();
await waitFor(() => expect(screen.getByTestId("traces-timeline")).toHaveAttribute("aria-busy", "false"));
const counts = bucketRunCounts();
expect(counts).toHaveLength(60);
expect(counts.reduce((a, b) => a + b, 0)).toBe(runs.length);

View file

@ -109,7 +109,7 @@ export function AgentTracesSection({
() => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }),
[startTime, endTime, isCustomDate, endMs],
);
const buckets = useTraceHistogram(accessToken, range, query, isActive);
const histogram = useTraceHistogram(accessToken, range, query, isActive);
const runs = traces.traces;
const runRefs = useMemo(() => runs.map(traceRefOf), [runs]);
@ -191,7 +191,13 @@ export function AgentTracesSection({
/>
)}
</RunsToolbar>
<TracesTimeline buckets={buckets} range={range} selection={zoom} onSelect={setZoom} />
<TracesTimeline
buckets={histogram.buckets}
loading={histogram.isLoading}
range={range}
selection={zoom}
onSelect={setZoom}
/>
<AgentTracesTable
traces={runs}
isLoading={traces.isLoading || (checkHistory && history.isLoading)}

View file

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
@ -52,7 +52,7 @@ describe("AgentTracesTable empty state", () => {
});
describe("AgentTracesTable loading state", () => {
it("announces the first page load instead of an empty table", () => {
it("fills the first page load with skeleton rows instead of an empty table", () => {
render(
inList(
<AgentTracesTable
@ -66,7 +66,11 @@ describe("AgentTracesTable loading state", () => {
/>,
),
);
expect(screen.getByRole("status", { name: "Loading runs…" })).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent("Loading runs…");
const placeholders = screen.getAllByTestId("runs-placeholder");
expect(placeholders.length).toBeGreaterThanOrEqual(8);
const columnCount = screen.getAllByRole("columnheader").length;
expect(within(placeholders[0]).getAllByRole("cell", { hidden: true })).toHaveLength(columnCount);
expect(screen.queryByText(/No runs/)).not.toBeInTheDocument();
});
});

View file

@ -7,12 +7,12 @@ import { useInView } from "react-intersection-observer";
import { InspectorTable, useInspectorTable } from "@/components/shared/InspectorTable";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp";
import { SpanIcon } from "../ui/SpanIcon";
import { StatusMark } from "../ui/StatusMark";
import { FrameworkLogo, traceFramework } from "../ui/TraceFramework";
import { LoadingState } from "../../ui/LoadingState";
import type { TraceSummary } from "../types";
import { traceRefOf } from "../routing";
import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils";
@ -40,6 +40,7 @@ const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
const PREFETCH_MARGIN = "0px 0px 480px 0px";
const PLACEHOLDER_ROWS = [0, 1, 2];
const SKELETON_ROWS = Array.from({ length: 12 }, (_, i) => i);
const ROW_HEIGHT = 36;
const MUTED_NUM = "font-mono text-muted-foreground";
const NUM = "font-mono text-foreground";
@ -93,9 +94,25 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
{formatRunTimestamp(row.original.start_time)}
</span>
),
meta: { className: "font-mono tabular-nums text-muted-foreground" },
meta: {
className: "font-mono tabular-nums text-muted-foreground",
renderSkeleton: () => <Skeleton className="h-3 w-24" />,
},
},
{
id: "agent",
size: 160,
header: "Agent",
cell: ({ row }) => <AgentCell run={row.original} />,
meta: {
renderSkeleton: () => (
<div className="flex items-center gap-1.5">
<Skeleton className="size-3.5 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
),
},
},
{ id: "agent", size: 160, header: "Agent", cell: ({ row }) => <AgentCell run={row.original} /> },
{ id: "input", header: "Input", cell: ({ row }) => <InputCell run={row.original} /> },
{
id: "agents",
@ -142,24 +159,12 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
size: 32,
header: "",
cell: () => <ChevronRight className="size-3 text-muted-foreground/60" />,
meta: { className: "px-0" },
meta: { className: "px-0", renderSkeleton: () => null },
},
];
function PlaceholderRow({ rowRef }: { rowRef?: (node: Element | null) => void }) {
return (
<tr ref={rowRef} aria-hidden data-testid="runs-placeholder" className="h-9 border-b border-border/60">
<td className="px-3">
<div className="h-2.5 w-28 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
</td>
<td className="px-3">
<div className="h-2.5 w-24 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
</td>
<td className="px-3" colSpan={7}>
<div className="h-2.5 w-2/5 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
</td>
</tr>
);
function PlaceholderRow({ index, rowRef }: { index: number; rowRef?: (node: Element | null) => void }) {
return <InspectorTable.SkeletonRow ref={rowRef} index={index} data-testid="runs-placeholder" className="h-9" />;
}
function LoadMoreRows({ isFetching, onLoadMore }: { isFetching: boolean; onLoadMore: () => void }) {
@ -168,7 +173,9 @@ function LoadMoreRows({ isFetching, onLoadMore }: { isFetching: boolean; onLoadM
useEffect(() => {
if (nearTail && !isFetching) onLoadMore();
}, [nearTail, isFetching, onLoadMore]);
return PLACEHOLDER_ROWS.map((row) => <PlaceholderRow key={row} rowRef={row === 0 ? tailRef : undefined} />);
return PLACEHOLDER_ROWS.map((row) => (
<PlaceholderRow key={row} index={row} rowRef={row === 0 ? tailRef : undefined} />
));
}
function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetUpTracing: () => void }) {
@ -216,12 +223,17 @@ export function AgentTracesTable({
};
const table = useReactTable(tableOptions);
return (
<InspectorTable.Root table={table} data-testid="runs-table" className="flex flex-col">
<InspectorTable.Root table={table} data-testid="runs-table">
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
<InspectorTable.Header />
<InspectorTable.Body<TraceSummary>
rowHeight={() => ROW_HEIGHT}
after={autoContinue && <LoadMoreRows isFetching={isFetching} onLoadMore={onLoadMore} />}
after={
<>
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)}
{autoContinue && <LoadMoreRows isFetching={isFetching} onLoadMore={onLoadMore} />}
</>
}
>
{(row) => (
<InspectorTable.Row
@ -233,7 +245,11 @@ export function AgentTracesTable({
)}
</InspectorTable.Body>
</InspectorTable.Grid>
{isLoading && <LoadingState title="Loading runs…" description="Fetching agent runs for this time range." />}
{isLoading && (
<p role="status" className="sr-only">
Loading runs…
</p>
)}
{error && (
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
<span>

View file

@ -6,7 +6,7 @@ import { useRef, useState, type RefObject } from "react";
import { useResizeObserver } from "usehooks-ts";
import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotField";
import type { DotBand, DotColumn } from "@/components/shared/dotField/dots";
import { type DotBand, type DotColumn, skeletonColumns } from "@/components/shared/dotField/dots";
import { cn } from "@/lib/cva.config";
export const BUCKETS = 60;
@ -15,6 +15,7 @@ const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const EDGE_FORMAT = "MMM DD, HH:mm";
const SKELETON_COLUMNS = skeletonColumns(BUCKETS);
import type { TimeRange as TimeWindow } from "../api";
@ -52,6 +53,8 @@ interface TracesTimelineProps {
range: TimeWindow;
selection: TimeWindow | null;
onSelect: (selection: TimeWindow | null) => void;
/** Paint a muted placeholder profile until the first histogram arrives. */
loading?: boolean;
}
function BucketBar({ bucket }: { bucket: Bucket }) {
@ -208,7 +211,7 @@ function TickAxis({ range }: { range: TimeWindow }) {
}
/** Histogram of runs over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTimelineProps) {
export function TracesTimeline({ buckets, range, selection, onSelect, loading = false }: TracesTimelineProps) {
const [hover, setHover] = useState<number | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const [draft, setDraft] = useState<Band | null>(null);
@ -262,17 +265,19 @@ export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTi
};
const labelFormat = edgeFormat(range);
const showTooltip = hover !== null && !drag && !loading;
return (
<div
className="relative shrink-0 border-b border-border bg-card px-3 pt-2 pb-1 outline-none select-none"
data-testid="traces-timeline"
aria-busy={loading}
tabIndex={0}
onKeyDown={onKeyDown}
>
<DotFieldRoot
ref={areaRef}
columns={buckets}
columns={loading ? SKELETON_COLUMNS : buckets}
band={band}
hover={hover}
role="presentation"
@ -291,7 +296,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTi
data-testid="timeline-cursor"
/>
)}
<DotFieldCanvas />
<DotFieldCanvas className={loading ? "animate-pulse motion-reduce:animate-none" : undefined} />
<NowEdge />
{buckets.map((b) => (
<BucketBar key={b.startMs} bucket={b} />
@ -309,7 +314,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTi
<div className="mt-1">
<TickAxis range={range} />
</div>
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
{showTooltip && <BucketTooltip bucket={buckets[hover]} index={hover} />}
{selection && (
<button
type="button"

View file

@ -26,8 +26,19 @@ const emptyBuckets = (range: TimeRange): Bucket[] => {
}));
};
export interface TraceHistogramResult {
buckets: Bucket[];
/** True until the first histogram for this scope arrives; a range change keeps the previous one instead. */
isLoading: boolean;
}
/** Matching runs per bucket across the whole range, counted by the server so every run is plotted, not just loaded ones. */
export function useTraceHistogram(accessToken: string, range: TimeRange, q: string, enabled: boolean): Bucket[] {
export function useTraceHistogram(
accessToken: string,
range: TimeRange,
q: string,
enabled: boolean,
): TraceHistogramResult {
const traces = useTracesApi(accessToken);
const histogram = useQuery({
queryKey: ["agentTraceHistogram", traces.scope, range.startMs, range.endMs, q],
@ -36,5 +47,5 @@ export function useTraceHistogram(accessToken: string, range: TimeRange, q: stri
placeholderData: keepPreviousData,
select: toBuckets,
});
return histogram.data ?? emptyBuckets(range);
return { buckets: histogram.data ?? emptyBuckets(range), isLoading: histogram.isLoading };
}

View file

@ -8,6 +8,7 @@ import { ChevronRight } from "lucide-react";
import { createContext, Fragment, useContext, useState, type ComponentProps, type ReactNode } from "react";
import { Inspector } from "@/components/shared/Inspector";
import { Skeleton } from "@/components/ui/skeleton";
import { TableBody, TableCell, TableHead, TableHeader } from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
@ -137,6 +138,37 @@ function Row<T>({ row, item, className, ...props }: RowProps<T>) {
);
}
const SKELETON_WIDTHS = ["w-[58%]", "w-[44%]", "w-[70%]", "w-[50%]", "w-[64%]", "w-[48%]"] as const;
type SkeletonRowProps = ComponentProps<"tr"> & { readonly index: number };
/** One placeholder row shaped by the visible columns: `meta.renderSkeleton` wins, numeric cells right-align. */
function SkeletonRow({ index, className, ...props }: SkeletonRowProps) {
const { table } = useInspectorTable();
return (
<tr aria-hidden data-slot="table-skeleton-row" className={cn("border-b border-border/60", className)} {...props}>
{table.getVisibleLeafColumns().map((column, position) => {
const meta = column.columnDef.meta;
return (
<TableCell key={column.id} className={cn("px-3 py-0", meta?.numeric && NUMERIC, meta?.className)}>
{meta?.renderSkeleton ? (
meta.renderSkeleton()
) : (
<Skeleton
className={cn(
"h-3",
SKELETON_WIDTHS[(index + position) % SKELETON_WIDTHS.length],
meta?.numeric && "ml-auto",
)}
/>
)}
</TableCell>
);
})}
</tr>
);
}
interface IndentProps<T> {
readonly row: TanStackRow<T>;
readonly toggleLabel?: (expanded: boolean) => string;
@ -176,4 +208,4 @@ function Indent<T>({ row, toggleLabel = (expanded) => (expanded ? "Collapse" : "
);
}
export const InspectorTable = { Root, Grid, Header, Body, Row, Indent } as const;
export const InspectorTable = { Root, Grid, Header, Body, Row, SkeletonRow, Indent } as const;

View file

@ -1,6 +1,15 @@
import { describe, expect, it } from "vitest";
import { DOT_COLS, DOT_ROWS, NEUTRAL_COLOR, SERIES_COLORS, columnDots, litDots, seriesColor } from "./dots";
import {
DOT_COLS,
DOT_ROWS,
NEUTRAL_COLOR,
SERIES_COLORS,
columnDots,
litDots,
seriesColor,
skeletonColumns,
} from "./dots";
const capacity = DOT_ROWS * DOT_COLS;
const column = (total: number, failed = 0, series: string[] = []) => ({ total, failed, series });
@ -66,3 +75,20 @@ describe("seriesColor", () => {
expect(SERIES_COLORS).not.toContain(seriesColor(""));
});
});
describe("skeletonColumns", () => {
it("fills every column with muted, varied, failure-free placeholders", () => {
const columns = skeletonColumns(60);
expect(columns).toHaveLength(60);
expect(columns.every((column) => column.total > 0 && column.failed === 0)).toBe(true);
expect(new Set(columns.map((column) => column.total)).size).toBeGreaterThan(3);
const max = Math.max(...columns.map((column) => column.total));
const dots = columnDots(columns[0], max, 0);
expect(dots.filter((dot) => dot.kind === "series").every((dot) => dot.color === NEUTRAL_COLOR)).toBe(true);
expect(columns.every((column) => (column.opacity ?? 1) < 1)).toBe(true);
});
it("is deterministic so the placeholder does not jump between renders", () => {
expect(skeletonColumns(60)).toEqual(skeletonColumns(60));
});
});

View file

@ -24,6 +24,18 @@ export interface DotBand {
const hash = (seed: number, value: number): number => Math.imul(seed ^ Math.imul(value, 0x9e3779b1), 0x85ebca6b) >>> 0;
const SKELETON_OPACITY = 0.35;
/** Muted placeholder columns to paint while the real ones load: a gentle swell with per-column jitter. */
export function skeletonColumns(count: number): readonly DotColumn[] {
return Array.from({ length: count }, (_, i) => ({
total: 1 + Math.round(3 * (1 + Math.sin(i / 3.5))) + (hash(29, i) % 3),
failed: 0,
series: [],
opacity: SKELETON_OPACITY,
}));
}
export function seriesColor(name: string): string {
if (!name) return NEUTRAL_COLOR;
const code = Array.from(name).reduce((total, char) => hash(total, char.charCodeAt(0)), 7);