refactor(ui): share the brushed timeline and the state messages

The histogram strip only knew about runs through its tooltip copy, and
the loading and state messages only lived under Lens. Both move to
shared/, the timeline takes the item noun from its caller, and the
sweep keyframe drops its Lens name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-04 16:31:28 -07:00
parent 3d049cd4a8
commit 1325389624
10 changed files with 34 additions and 28 deletions

View file

@ -461,7 +461,7 @@
visibility: hidden; visibility: hidden;
} }
@keyframes lens-sweep { @keyframes timeline-sweep {
from { from {
left: -6rem; left: -6rem;
} }

View file

@ -2,8 +2,8 @@
import { ArrowUpRight, SearchX, TriangleAlert } from "lucide-react"; import { ArrowUpRight, SearchX, TriangleAlert } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { LoadingState } from "../ui/LoadingState"; import { LoadingState } from "@/components/shared/LoadingState";
import { StateMessage } from "../ui/StateMessage"; import { StateMessage } from "@/components/shared/StateMessage";
import { ApiError } from "@/lib/http/client"; import { ApiError } from "@/lib/http/client";

View file

@ -10,7 +10,7 @@ import { Tabs } from "@/components/ui/tabs";
import { cn } from "@/lib/cva.config"; import { cn } from "@/lib/cva.config";
import { useTracesApi } from "../../api"; import { useTracesApi } from "../../api";
import { LoadingState } from "../../../ui/LoadingState"; import { LoadingState } from "@/components/shared/LoadingState";
import { classifyTraceReadFailure, traceReadRetry, traceReadRetryDelay } from "../../list/traceReadFailure"; import { classifyTraceReadFailure, traceReadRetry, traceReadRetryDelay } from "../../list/traceReadFailure";
import { type RunSelection, traceKey } from "../../routing"; import { type RunSelection, traceKey } from "../../routing";
import type { Trace } from "../../types"; import type { Trace } from "../../types";

View file

@ -84,7 +84,7 @@ const renderWindowed = (timeControls?: TimeControls) =>
); );
const bucketRunCounts = () => const bucketRunCounts = () =>
screen.getAllByTestId("timeline-bucket").map((bucket) => Number(bucket.getAttribute("data-runs"))); screen.getAllByTestId("timeline-bucket").map((bucket) => Number(bucket.getAttribute("data-total")));
describe("AgentTracesSection", () => { describe("AgentTracesSection", () => {
afterEach(() => { afterEach(() => {
@ -523,10 +523,10 @@ describe("AgentTracesSection", () => {
it("plots every loaded run on the timeline", async () => { it("plots every loaded run on the timeline", async () => {
serve(runs); serve(runs);
renderWindowed(); renderWindowed();
expect(screen.getByTestId("traces-timeline")).toHaveAttribute("aria-busy", "true"); expect(screen.getByTestId("timeline")).toHaveAttribute("aria-busy", "true");
await screen.findAllByTestId("agent-trace-row"); await screen.findAllByTestId("agent-trace-row");
await waitFor(() => expect(screen.getByTestId("traces-timeline")).toHaveAttribute("aria-busy", "false")); await waitFor(() => expect(screen.getByTestId("timeline")).toHaveAttribute("aria-busy", "false"));
const counts = bucketRunCounts(); const counts = bucketRunCounts();
expect(counts).toHaveLength(60); expect(counts).toHaveLength(60);
expect(counts.reduce((a, b) => a + b, 0)).toBe(runs.length); expect(counts.reduce((a, b) => a + b, 0)).toBe(runs.length);
@ -562,7 +562,7 @@ describe("AgentTracesSection", () => {
drag(screen.getByTestId("timeline-selection"), 0, 59); drag(screen.getByTestId("timeline-selection"), 0, 59);
await waitFor(() => expect(rowCount()).toBe(0)); await waitFor(() => expect(rowCount()).toBe(0));
fireEvent.keyDown(screen.getByTestId("traces-timeline"), { key: "Escape" }); fireEvent.keyDown(screen.getByTestId("timeline"), { key: "Escape" });
expect(screen.queryByTestId("timeline-selection")).not.toBeInTheDocument(); expect(screen.queryByTestId("timeline-selection")).not.toBeInTheDocument();
await waitFor(() => expect(rowCount()).toBe(runs.length)); await waitFor(() => expect(rowCount()).toBe(runs.length));
}); });

View file

@ -15,13 +15,14 @@ import { useZoomRouting } from "@/components/shared/timeRange/routing";
import { type RelativeRange, timeWindow } from "@/components/shared/timeRange/timeRange"; import { type RelativeRange, timeWindow } from "@/components/shared/timeRange/timeRange";
import { TimeRangeControls } from "@/components/shared/timeRange/TimeRangeControls"; import { TimeRangeControls } from "@/components/shared/timeRange/TimeRangeControls";
import type { RelativeRangeState } from "@/components/shared/timeRange/useRelativeRange"; import type { RelativeRangeState } from "@/components/shared/timeRange/useRelativeRange";
import { TracesTimeline } from "./TracesTimeline"; import { Timeline } from "@/components/shared/timeline/Timeline";
import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard"; import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard";
import { useTracesLive } from "../api"; import { useTracesLive } from "../api";
import { type AgentTracesResult, useAgentTraces, useTraceAvailability } from "./useAgentTraces"; import { type AgentTracesResult, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
import { useTraceHistogram } from "./useTraceHistogram"; import { useTraceHistogram } from "./useTraceHistogram";
const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth"; const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth";
const RUN_NOUN = { singular: "run", plural: "runs" };
export type TimeControls = Pick<RelativeRangeState, "setHours" | "setLive">; export type TimeControls = Pick<RelativeRangeState, "setHours" | "setLive">;
@ -173,12 +174,13 @@ export function AgentTracesSection({
/> />
)} )}
</RunsToolbar> </RunsToolbar>
<TracesTimeline <Timeline
buckets={histogram.buckets} buckets={histogram.buckets}
loading={histogram.isLoading} loading={histogram.isLoading}
range={window} range={window}
selection={zoom} selection={zoom}
onSelect={setZoom} onSelect={setZoom}
noun={RUN_NOUN}
/> />
<AgentTracesTable <AgentTracesTable
traces={runs} traces={runs}

View file

@ -3,10 +3,10 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
import { useTracesApi } from "../api"; import { useTracesApi } from "../api";
import type { TraceHistogram } from "../types"; import type { TraceHistogram } from "../types";
import { BUCKETS, type Bucket } from "./TracesTimeline"; import { BUCKETS, type TimeBucket } from "@/components/shared/timeline/Timeline";
/** The dot field draws one series entry per successful run. */ /** The dot field draws one series entry per successful run. */
export const toBuckets = (histogram: TraceHistogram): Bucket[] => export const toBuckets = (histogram: TraceHistogram): TimeBucket[] =>
histogram.buckets.map((bucket) => ({ histogram.buckets.map((bucket) => ({
startMs: bucket.start_ms, startMs: bucket.start_ms,
endMs: bucket.end_ms, endMs: bucket.end_ms,
@ -15,7 +15,7 @@ export const toBuckets = (histogram: TraceHistogram): Bucket[] =>
series: bucket.agents.flatMap(({ agent, runs }) => Array<string>(runs).fill(agent)), series: bucket.agents.flatMap(({ agent, runs }) => Array<string>(runs).fill(agent)),
})); }));
const emptyBuckets = (range: TimeWindow): Bucket[] => { const emptyBuckets = (range: TimeWindow): TimeBucket[] => {
const width = (range.endMs - range.startMs) / BUCKETS; const width = (range.endMs - range.startMs) / BUCKETS;
return Array.from({ length: BUCKETS }, (_, i) => ({ return Array.from({ length: BUCKETS }, (_, i) => ({
startMs: range.startMs + i * width, startMs: range.startMs + i * width,
@ -27,7 +27,7 @@ const emptyBuckets = (range: TimeWindow): Bucket[] => {
}; };
export interface TraceHistogramResult { export interface TraceHistogramResult {
buckets: Bucket[]; buckets: TimeBucket[];
/** True until the first histogram for this scope arrives; a range change keeps the previous one instead. */ /** True until the first histogram for this scope arrives; a range change keeps the previous one instead. */
isLoading: boolean; isLoading: boolean;
} }

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { bandForWindow, type Bucket, dragUpdate, formatSpan } from "./TracesTimeline"; import { bandForWindow, dragUpdate, formatSpan } from "./Timeline";
const HOUR = 3600 * 1000; const HOUR = 3600 * 1000;
const START = Date.UTC(2026, 8, 30, 0, 0, 0); const START = Date.UTC(2026, 8, 30, 0, 0, 0);

View file

@ -19,11 +19,14 @@ const SKELETON_COLUMNS = skeletonColumns(BUCKETS);
import type { TimeWindow } from "@/components/shared/timeRange/timeRange"; import type { TimeWindow } from "@/components/shared/timeRange/timeRange";
export interface Bucket extends DotColumn { export interface TimeBucket extends DotColumn {
startMs: number; startMs: number;
endMs: number; endMs: number;
} }
/** What one counted item is called in the tooltip: "run", "request". */
export type ItemNoun = { readonly singular: string; readonly plural: string };
/** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */ /** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */
export function formatSpan(ms: number): string { export function formatSpan(ms: number): string {
const totalMinutes = Math.max(0, Math.round(ms / MINUTE_MS)); const totalMinutes = Math.max(0, Math.round(ms / MINUTE_MS));
@ -46,17 +49,18 @@ const tickShift = (t: number): string => {
return "translateX(-50%)"; return "translateX(-50%)";
}; };
interface TracesTimelineProps { interface TimelineProps {
buckets: readonly Bucket[]; buckets: readonly TimeBucket[];
range: TimeWindow; range: TimeWindow;
selection: TimeWindow | null; selection: TimeWindow | null;
onSelect: (selection: TimeWindow | null) => void; onSelect: (selection: TimeWindow | null) => void;
noun: ItemNoun;
/** Paint a muted placeholder profile until the first histogram arrives. */ /** Paint a muted placeholder profile until the first histogram arrives. */
loading?: boolean; loading?: boolean;
} }
function BucketBar({ bucket }: { bucket: Bucket }) { function BucketBar({ bucket }: { bucket: TimeBucket }) {
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.total} />; return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-total={bucket.total} />;
} }
function NowEdge() { function NowEdge() {
@ -64,7 +68,7 @@ function NowEdge() {
<> <>
<div <div
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-24 bg-gradient-to-r from-transparent via-[#3b5bfd]/[0.08] to-transparent motion-safe:animate-[lens-sweep_6s_linear_infinite] motion-reduce:hidden" className="pointer-events-none absolute inset-y-0 w-24 bg-gradient-to-r from-transparent via-[#3b5bfd]/[0.08] to-transparent motion-safe:animate-[timeline-sweep_6s_linear_infinite] motion-reduce:hidden"
/> />
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0"> <div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0">
<span className="absolute inset-y-0 right-0 w-px bg-[#3b5bfd]/50" /> <span className="absolute inset-y-0 right-0 w-px bg-[#3b5bfd]/50" />
@ -74,7 +78,7 @@ function NowEdge() {
); );
} }
function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) { function BucketTooltip({ bucket, index, noun }: { bucket: TimeBucket; index: number; noun: ItemNoun }) {
return ( return (
<div <div
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md" className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
@ -85,7 +89,7 @@ function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
{moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")} {moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
</div> </div>
<div> <div>
{bucket.total} {bucket.total === 1 ? "run" : "runs"} {bucket.total} {bucket.total === 1 ? noun.singular : noun.plural}
{bucket.failed > 0 && `, ${bucket.failed} failed`} {bucket.failed > 0 && `, ${bucket.failed} failed`}
</div> </div>
<div className="text-info">drag to zoom</div> <div className="text-info">drag to zoom</div>
@ -119,7 +123,7 @@ export function dragUpdate(drag: DragState, at: number, buckets = BUCKETS): Band
} }
/** Bucket band covered by a selected window, or null when nothing is selected. */ /** Bucket band covered by a selected window, or null when nothing is selected. */
export function bandForWindow(buckets: readonly Bucket[], selection: TimeWindow | null): Band | null { export function bandForWindow(buckets: readonly TimeBucket[], selection: TimeWindow | null): Band | null {
if (selection === null) return null; if (selection === null) return null;
const inside = buckets.flatMap((b, i) => (b.startMs >= selection.startMs && b.endMs <= selection.endMs ? [i] : [])); const inside = buckets.flatMap((b, i) => (b.startMs >= selection.startMs && b.endMs <= selection.endMs ? [i] : []));
return inside.length > 0 ? { lo: inside[0], hi: inside[inside.length - 1] } : null; return inside.length > 0 ? { lo: inside[0], hi: inside[inside.length - 1] } : null;
@ -208,8 +212,8 @@ function TickAxis({ range }: { range: TimeWindow }) {
); );
} }
/** Histogram of runs over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */ /** Histogram over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
export function TracesTimeline({ buckets, range, selection, onSelect, loading = false }: TracesTimelineProps) { export function Timeline({ buckets, range, selection, onSelect, noun, loading = false }: TimelineProps) {
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const [drag, setDrag] = useState<DragState | null>(null); const [drag, setDrag] = useState<DragState | null>(null);
const [draft, setDraft] = useState<Band | null>(null); const [draft, setDraft] = useState<Band | null>(null);
@ -268,7 +272,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect, loading =
return ( return (
<div <div
className="relative shrink-0 border-b border-border bg-card px-3 pt-2 pb-1 outline-none select-none" className="relative shrink-0 border-b border-border bg-card px-3 pt-2 pb-1 outline-none select-none"
data-testid="traces-timeline" data-testid="timeline"
aria-busy={loading} aria-busy={loading}
tabIndex={0} tabIndex={0}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -312,7 +316,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect, loading =
<div className="mt-1"> <div className="mt-1">
<TickAxis range={range} /> <TickAxis range={range} />
</div> </div>
{showTooltip && <BucketTooltip bucket={buckets[hover]} index={hover} />} {showTooltip && <BucketTooltip bucket={buckets[hover]} index={hover} noun={noun} />}
{selection && ( {selection && (
<button <button
type="button" type="button"