refactor(ui): extract shared timeline and time-range controls (#44584)

Move the timeline renderer to shared/timeline/Timeline taking buckets,
a selected window, and callbacks as props, and TimeRangeControls to
shared/timeline. Lens keeps bucketRuns as the adapter that converts
loaded traces into buckets, preserving behavior without the histogram
endpoint.

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-05 16:11:52 +00:00 • committed by GitHub
parent fdc0e7dc2b
commit 21881c5711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 402 additions and 362 deletions

View file

@ -19,8 +19,9 @@ import {
} from "../routing";
import type { TraceSummary } from "../types";
import { RunView } from "../detail/run/RunView";
import { TimeRangeControls } from "./TimeRangeControls";
import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
import { TimeRangeControls } from "@/components/shared/timeline/TimeRangeControls";
import { TracesTimeline } from "./TracesTimeline";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard";
import { useTracesLive } from "../api";
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { bandForWindow, bucketRuns, dragUpdate, formatSpan } from "./TracesTimeline";
import { bucketRuns } from "./TracesTimeline";
import type { TraceSummary } from "../types";
const HOUR = 3600 * 1000;
@ -41,43 +41,3 @@ describe("bucketRuns", () => {
expect(buckets[5]).toMatchObject({ total: 1, failed: 0 });
});
});
describe("formatSpan", () => {
it("prints the largest two units, dropping zero parts", () => {
expect(formatSpan(45 * 60 * 1000)).toBe("45m");
expect(formatSpan(6 * HOUR + 12 * 60 * 1000)).toBe("6h 12m");
expect(formatSpan(24 * HOUR)).toBe("1d");
expect(formatSpan(152 * 24 * HOUR + 23 * HOUR)).toBe("152d 23h");
expect(formatSpan(-5)).toBe("0m");
});
});
describe("dragUpdate", () => {
const band = { lo: 10, hi: 14 };
it("selects between the press point and the pointer, in either direction", () => {
expect(dragUpdate({ mode: "select", origin: 20, band: { lo: 20, hi: 20 } }, 25)).toEqual({ lo: 20, hi: 25 });
expect(dragUpdate({ mode: "select", origin: 20, band: { lo: 20, hi: 20 } }, 12)).toEqual({ lo: 12, hi: 20 });
});
it("resizes one edge without letting it cross the other", () => {
expect(dragUpdate({ mode: "resize-lo", origin: 10, band }, 4)).toEqual({ lo: 4, hi: 14 });
expect(dragUpdate({ mode: "resize-lo", origin: 10, band }, 30)).toEqual({ lo: 14, hi: 14 });
expect(dragUpdate({ mode: "resize-hi", origin: 14, band }, 40)).toEqual({ lo: 10, hi: 40 });
expect(dragUpdate({ mode: "resize-hi", origin: 14, band }, 2)).toEqual({ lo: 10, hi: 10 });
});
it("pans the band keeping its width, clamped to the strip", () => {
expect(dragUpdate({ mode: "move", origin: 12, band }, 20)).toEqual({ lo: 18, hi: 22 });
expect(dragUpdate({ mode: "move", origin: 12, band }, -50)).toEqual({ lo: 0, hi: 4 });
expect(dragUpdate({ mode: "move", origin: 12, band }, 500, 60)).toEqual({ lo: 55, hi: 59 });
});
});
describe("bandForWindow", () => {
it("maps a selected window back to the buckets it covers", () => {
const buckets = bucketRuns([], range, 10);
expect(bandForWindow(buckets, { startMs: START + 2 * HOUR, endMs: START + 5 * HOUR })).toEqual({ lo: 2, hi: 4 });
expect(bandForWindow(buckets, null)).toBeNull();
});
});

View file

@ -1,36 +1,15 @@
"use client";
import { X } from "lucide-react";
import moment from "moment";
import { useMemo, useRef, useState, type RefObject } from "react";
import { useResizeObserver } from "usehooks-ts";
import { useMemo } from "react";
import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotField";
import type { DotBand, DotColumn } from "@/components/shared/dotField/dots";
import { cn } from "@/lib/cva.config";
import { Timeline, TIMELINE_BUCKETS, type Bucket, type TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TraceSummary } from "../types";
import { traceAgentNames } from "../utils";
const BUCKETS = 60;
const TICKS = 6;
const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const EDGE_FORMAT = "MMM DD, HH:mm";
export interface TimeWindow {
startMs: number;
endMs: number;
}
export interface Bucket extends DotColumn {
startMs: number;
endMs: number;
}
/** Run counts per equal-width time bucket across the window; runs outside it are dropped. */
export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = BUCKETS): Bucket[] {
export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buckets = TIMELINE_BUCKETS): Bucket[] {
const width = (range.endMs - range.startMs) / buckets;
const placed = runs.map((run) => ({
index: Math.floor((moment(run.start_time).valueOf() - range.startMs) / width),
@ -49,28 +28,6 @@ export function bucketRuns(runs: readonly TraceSummary[], range: TimeWindow, buc
});
}
/** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */
export function formatSpan(ms: number): string {
const totalMinutes = Math.max(0, Math.round(ms / MINUTE_MS));
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
return `${minutes}m`;
}
const tickFormat = (range: TimeWindow): string => (range.endMs - range.startMs > 2 * DAY_MS ? EDGE_FORMAT : "HH:mm");
const pct = (value: number): string => `${value * 100}%`;
/** Keep the first / last tick label inside the strip; center the rest on their tick. */
const tickShift = (t: number): string => {
if (t === 0) return "translateX(0)";
if (t === 1) return "translateX(-100%)";
return "translateX(-50%)";
};
interface TracesTimelineProps {
runs: readonly TraceSummary[];
range: TimeWindow;
@ -78,273 +35,8 @@ interface TracesTimelineProps {
onSelect: (selection: TimeWindow | null) => void;
}
function BucketBar({ bucket }: { bucket: Bucket }) {
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.total} />;
}
function NowEdge() {
return (
<>
<div
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"
/>
<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 -top-0.5 right-0 size-1.5 translate-x-1/2 rounded-full bg-[#3b5bfd] motion-safe:animate-pulse" />
</div>
</>
);
}
function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
return (
<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"
style={{ left: pct(Math.min(0.8, index / BUCKETS)) }}
role="tooltip"
>
<div>
{moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
</div>
<div>
{bucket.total} {bucket.total === 1 ? "run" : "runs"}
{bucket.failed > 0 && `, ${bucket.failed} failed`}
</div>
<div className="text-info">drag to zoom</div>
</div>
);
}
const MIN_DURATION_LABEL_PX = 120;
export type Band = DotBand;
export type DragMode = "select" | "move" | "resize-lo" | "resize-hi";
export interface DragState {
mode: DragMode;
origin: number;
band: Band;
}
/** The band a drag produces when the pointer is over bucket `at`; always within [0, buckets - 1]. */
export function dragUpdate(drag: DragState, at: number, buckets = BUCKETS): Band {
const last = buckets - 1;
const clamp = (i: number) => Math.min(last, Math.max(0, i));
const { band, origin } = drag;
if (drag.mode === "select") return { lo: Math.min(origin, clamp(at)), hi: Math.max(origin, clamp(at)) };
if (drag.mode === "resize-lo") return { lo: Math.min(clamp(at), band.hi), hi: band.hi };
if (drag.mode === "resize-hi") return { lo: band.lo, hi: Math.max(clamp(at), band.lo) };
const width = band.hi - band.lo;
const lo = Math.min(last - width, Math.max(0, band.lo + (at - origin)));
return { lo, hi: lo + width };
}
/** Bucket band covered by a selected window, or null when nothing is selected. */
export function bandForWindow(buckets: readonly Bucket[], selection: TimeWindow | null): Band | null {
if (selection === null) return null;
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;
}
const edgeFormat = (range: TimeWindow): string => (range.endMs - range.startMs < DAY_MS ? "HH:mm" : EDGE_FORMAT);
/** The selected window drawn as a flat bracket: resize handles on each edge, times outside, span centered. */
function SelectionBracket({
band,
window,
format,
stripWidth,
onHandleDown,
}: {
band: Band;
window: TimeWindow;
format: string;
stripWidth: number;
onHandleDown: (mode: DragMode) => (e: React.PointerEvent) => void;
}) {
const leftFrac = band.lo / BUCKETS;
const rightFrac = (band.hi + 1) / BUCKETS;
const widthPx = (rightFrac - leftFrac) * stripWidth;
const handle = "absolute inset-y-0 w-[2px] cursor-ew-resize bg-[#0011b3] dark:bg-[#8b9bff]";
const tick =
"before:absolute before:top-0 before:h-[2px] before:w-2 before:bg-inherit after:absolute after:bottom-0 after:h-[2px] after:w-2 after:bg-inherit";
const edgeLabel = "pointer-events-none absolute -bottom-5 font-mono text-xs whitespace-nowrap text-info tabular-nums";
return (
<>
<div
className="absolute inset-y-0 cursor-grab bg-[#0011b3]/[0.05] active:cursor-grabbing dark:bg-[#8b9bff]/[0.08]"
style={{ left: pct(leftFrac), width: pct(rightFrac - leftFrac) }}
data-testid="timeline-selection"
onPointerDown={onHandleDown("move")}
>
<span
className={cn(handle, tick, "-left-px before:left-0 after:left-0")}
data-testid="timeline-handle-lo"
onPointerDown={onHandleDown("resize-lo")}
/>
<span
className={cn(handle, tick, "-right-px before:right-0 after:right-0")}
data-testid="timeline-handle-hi"
onPointerDown={onHandleDown("resize-hi")}
/>
{widthPx >= MIN_DURATION_LABEL_PX && (
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-xs font-semibold text-info">
{formatSpan(window.endMs - window.startMs)}
</span>
)}
</div>
<span className={cn(edgeLabel, leftFrac < 0.12 ? "" : "-translate-x-full pr-1")} style={{ left: pct(leftFrac) }}>
{moment(window.startMs).format(format)}
</span>
<span className={cn(edgeLabel, rightFrac > 0.88 ? "-translate-x-full" : "pl-1")} style={{ left: pct(rightFrac) }}>
{moment(window.endMs).format(format)}
</span>
</>
);
}
function TickAxis({ range }: { range: TimeWindow }) {
const format = tickFormat(range);
const ticks = Array.from({ length: TICKS }, (_, i) => i / (TICKS - 1));
return (
<div className="relative mt-0.5 h-5">
{ticks.map((t) => (
<div
key={t}
className={cn(
"absolute top-0 flex flex-col",
t === 0 && "items-start",
t === 1 && "items-end",
t > 0 && t < 1 && "items-center",
)}
style={{ left: pct(t), transform: tickShift(t) }}
>
<span className="h-1 w-px bg-border" />
<span className="font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
</span>
</div>
))}
</div>
);
}
/** Histogram of runs over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
/** Lens adapter: buckets the currently loaded runs and hands them to the shared timeline. */
export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimelineProps) {
const buckets = useMemo(() => bucketRuns(runs, range), [runs, range]);
const [hover, setHover] = useState<number | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const [draft, setDraft] = useState<Band | null>(null);
const areaRef = useRef<HTMLDivElement>(null);
const { width: stripWidth = 0 } = useResizeObserver({
ref: areaRef as RefObject<HTMLDivElement>,
box: "border-box",
});
const committed = bandForWindow(buckets, selection);
const band = drag ? draft : committed;
const windowOf = (b: Band): TimeWindow => ({ startMs: buckets[b.lo].startMs, endMs: buckets[b.hi].endMs });
const indexAt = (clientX: number): number => {
const rect = areaRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return 0;
return Math.min(BUCKETS - 1, Math.max(0, Math.floor(((clientX - rect.left) / rect.width) * BUCKETS)));
};
const begin = (mode: DragMode, e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
areaRef.current?.setPointerCapture?.(e.pointerId);
const at = indexAt(e.clientX);
const start = mode === "select" || committed === null ? { lo: at, hi: at } : committed;
setDrag({ mode: committed === null ? "select" : mode, origin: at, band: start });
setDraft(start);
};
const onHandleDown = (mode: DragMode) => (e: React.PointerEvent) => begin(mode, e);
const onMove = (e: React.PointerEvent) => {
const at = indexAt(e.clientX);
setHover(at);
if (drag) setDraft(dragUpdate(drag, at));
};
const onUp = (e: React.PointerEvent) => {
areaRef.current?.releasePointerCapture?.(e.pointerId);
if (!drag || !draft) return;
const finished = draft;
const wasSelect = drag.mode === "select";
setDrag(null);
setDraft(null);
if (wasSelect && finished.lo === finished.hi) {
onSelect(null);
return;
}
onSelect(windowOf(finished));
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "Escape" || selection === null) return;
e.preventDefault();
onSelect(null);
};
const labelFormat = edgeFormat(range);
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"
tabIndex={0}
onKeyDown={onKeyDown}
>
<DotFieldRoot
ref={areaRef}
columns={buckets}
band={band}
hover={hover}
role="presentation"
data-testid="timeline-area"
className="flex cursor-crosshair touch-none items-end"
onPointerDown={(e) => begin("select", e)}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerLeave={() => setHover(null)}
onDoubleClick={() => onSelect(null)}
>
{hover !== null && !drag && (
<div
className="pointer-events-none absolute inset-y-0 w-px bg-[#0011b3]/50 dark:bg-[#8b9bff]/50"
style={{ left: pct((hover + 0.5) / BUCKETS) }}
data-testid="timeline-cursor"
/>
)}
<DotFieldCanvas />
<NowEdge />
{buckets.map((b) => (
<BucketBar key={b.startMs} bucket={b} />
))}
{band && (
<SelectionBracket
band={band}
window={windowOf(band)}
format={labelFormat}
stripWidth={stripWidth}
onHandleDown={onHandleDown}
/>
)}
</DotFieldRoot>
<div className="mt-1">
<TickAxis range={range} />
</div>
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
{selection && (
<button
type="button"
onClick={() => onSelect(null)}
aria-label="Clear time zoom"
className="absolute top-1 right-3 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
);
return <Timeline buckets={buckets} selection={selection} onSelect={onSelect} />;
}

View file

@ -3,7 +3,7 @@
import { useMemo } from "react";
import type { TraceSummary } from "../../types";
import type { TimeWindow } from "../TracesTimeline";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { SearchBox } from "@/components/shared/search/SearchBox";
import { itemValues } from "@/components/shared/search/valueSource";

View file

@ -1,7 +1,7 @@
"use client";
import type { TraceSummary } from "../../types";
import type { TimeWindow } from "../TracesTimeline";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { RunSearch } from "./RunSearch";

View file

@ -1,5 +1,5 @@
import { getProxyBaseUrl } from "@/components/networking";
import type { TimeWindow } from "../TracesTimeline";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import { valueMatcher } from "@/components/shared/search/language";
import type { SearchFilter, SearchQuery } from "@/components/shared/search/searchQuery";

View file

@ -9,8 +9,8 @@ import {
} from "nuqs";
import { useCallback, useState } from "react";
import { RANGE_PRESETS } from "./list/TimeRangeControls";
import type { TimeWindow } from "./list/TracesTimeline";
import { RANGE_PRESETS } from "@/components/shared/timeline/TimeRangeControls";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TraceSummary } from "./types";
export const TRACE_VIEWS = ["steps", "conversation"] as const;

View file

@ -12,7 +12,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import type { TimeWindow } from "./TracesTimeline";
import type { TimeWindow } from "./Timeline";
export const RANGE_PRESETS = [
{ hours: 1, label: "Last hour" },

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { bandForWindow, dragUpdate, formatSpan, type Bucket } from "./Timeline";
const HOUR = 3600 * 1000;
const START = Date.UTC(2026, 8, 30, 0, 0, 0);
const emptyBuckets = (count: number): Bucket[] =>
Array.from({ length: count }, (_, i) => ({
startMs: START + i * HOUR,
endMs: START + (i + 1) * HOUR,
total: 0,
failed: 0,
series: [],
}));
describe("formatSpan", () => {
it("prints the largest two units, dropping zero parts", () => {
expect(formatSpan(45 * 60 * 1000)).toBe("45m");
expect(formatSpan(6 * HOUR + 12 * 60 * 1000)).toBe("6h 12m");
expect(formatSpan(24 * HOUR)).toBe("1d");
expect(formatSpan(152 * 24 * HOUR + 23 * HOUR)).toBe("152d 23h");
expect(formatSpan(-5)).toBe("0m");
});
});
describe("dragUpdate", () => {
const band = { lo: 10, hi: 14 };
it("selects between the press point and the pointer, in either direction", () => {
expect(dragUpdate({ mode: "select", origin: 20, band: { lo: 20, hi: 20 } }, 25)).toEqual({ lo: 20, hi: 25 });
expect(dragUpdate({ mode: "select", origin: 20, band: { lo: 20, hi: 20 } }, 12)).toEqual({ lo: 12, hi: 20 });
});
it("resizes one edge without letting it cross the other", () => {
expect(dragUpdate({ mode: "resize-lo", origin: 10, band }, 4)).toEqual({ lo: 4, hi: 14 });
expect(dragUpdate({ mode: "resize-lo", origin: 10, band }, 30)).toEqual({ lo: 14, hi: 14 });
expect(dragUpdate({ mode: "resize-hi", origin: 14, band }, 40)).toEqual({ lo: 10, hi: 40 });
expect(dragUpdate({ mode: "resize-hi", origin: 14, band }, 2)).toEqual({ lo: 10, hi: 10 });
});
it("pans the band keeping its width, clamped to the strip", () => {
expect(dragUpdate({ mode: "move", origin: 12, band }, 20)).toEqual({ lo: 18, hi: 22 });
expect(dragUpdate({ mode: "move", origin: 12, band }, -50)).toEqual({ lo: 0, hi: 4 });
expect(dragUpdate({ mode: "move", origin: 12, band }, 500, 60)).toEqual({ lo: 55, hi: 59 });
});
});
describe("bandForWindow", () => {
it("maps a selected window back to the buckets it covers", () => {
const buckets = emptyBuckets(10);
expect(bandForWindow(buckets, { startMs: START + 2 * HOUR, endMs: START + 5 * HOUR })).toEqual({ lo: 2, hi: 4 });
expect(bandForWindow(buckets, null)).toBeNull();
});
});

View file

@ -0,0 +1,332 @@
"use client";
import { X } from "lucide-react";
import moment from "moment";
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 { cn } from "@/lib/cva.config";
export const TIMELINE_BUCKETS = 60;
const TICKS = 6;
const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const EDGE_FORMAT = "MMM DD, HH:mm";
export interface TimeWindow {
startMs: number;
endMs: number;
}
export interface Bucket extends DotColumn {
startMs: number;
endMs: number;
}
/** Compact window length, Logfire-style: "45m", "6h 12m", "7d", "152d 23h". */
export function formatSpan(ms: number): string {
const totalMinutes = Math.max(0, Math.round(ms / MINUTE_MS));
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
return `${minutes}m`;
}
const tickFormat = (range: TimeWindow): string => (range.endMs - range.startMs > 2 * DAY_MS ? EDGE_FORMAT : "HH:mm");
const pct = (value: number): string => `${value * 100}%`;
/** Keep the first / last tick label inside the strip; center the rest on their tick. */
const tickShift = (t: number): string => {
if (t === 0) return "translateX(0)";
if (t === 1) return "translateX(-100%)";
return "translateX(-50%)";
};
interface TimelineProps {
buckets: readonly Bucket[];
selection: TimeWindow | null;
onSelect: (selection: TimeWindow | null) => void;
}
function BucketBar({ bucket }: { bucket: Bucket }) {
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.total} />;
}
function NowEdge() {
return (
<>
<div
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"
/>
<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 -top-0.5 right-0 size-1.5 translate-x-1/2 rounded-full bg-[#3b5bfd] motion-safe:animate-pulse" />
</div>
</>
);
}
function BucketTooltip({ bucket, index, bucketCount }: { bucket: Bucket; index: number; bucketCount: number }) {
return (
<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"
style={{ left: pct(Math.min(0.8, index / bucketCount)) }}
role="tooltip"
>
<div>
{moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
</div>
<div>
{bucket.total} {bucket.total === 1 ? "run" : "runs"}
{bucket.failed > 0 && `, ${bucket.failed} failed`}
</div>
<div className="text-info">drag to zoom</div>
</div>
);
}
const MIN_DURATION_LABEL_PX = 120;
export type Band = DotBand;
export type DragMode = "select" | "move" | "resize-lo" | "resize-hi";
export interface DragState {
mode: DragMode;
origin: number;
band: Band;
}
/** The band a drag produces when the pointer is over bucket `at`; always within [0, buckets - 1]. */
export function dragUpdate(drag: DragState, at: number, buckets = TIMELINE_BUCKETS): Band {
const last = buckets - 1;
const clamp = (i: number) => Math.min(last, Math.max(0, i));
const { band, origin } = drag;
if (drag.mode === "select") return { lo: Math.min(origin, clamp(at)), hi: Math.max(origin, clamp(at)) };
if (drag.mode === "resize-lo") return { lo: Math.min(clamp(at), band.hi), hi: band.hi };
if (drag.mode === "resize-hi") return { lo: band.lo, hi: Math.max(clamp(at), band.lo) };
const width = band.hi - band.lo;
const lo = Math.min(last - width, Math.max(0, band.lo + (at - origin)));
return { lo, hi: lo + width };
}
/** Bucket band covered by a selected window, or null when nothing is selected. */
export function bandForWindow(buckets: readonly Bucket[], selection: TimeWindow | null): Band | null {
if (selection === null) return null;
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;
}
const edgeFormat = (range: TimeWindow): string => (range.endMs - range.startMs < DAY_MS ? "HH:mm" : EDGE_FORMAT);
/** The selected window drawn as a flat bracket: resize handles on each edge, times outside, span centered. */
function SelectionBracket({
band,
window,
format,
stripWidth,
bucketCount,
onHandleDown,
}: {
band: Band;
window: TimeWindow;
format: string;
stripWidth: number;
bucketCount: number;
onHandleDown: (mode: DragMode) => (e: React.PointerEvent) => void;
}) {
const leftFrac = band.lo / bucketCount;
const rightFrac = (band.hi + 1) / bucketCount;
const widthPx = (rightFrac - leftFrac) * stripWidth;
const handle = "absolute inset-y-0 w-[2px] cursor-ew-resize bg-[#0011b3] dark:bg-[#8b9bff]";
const tick =
"before:absolute before:top-0 before:h-[2px] before:w-2 before:bg-inherit after:absolute after:bottom-0 after:h-[2px] after:w-2 after:bg-inherit";
const edgeLabel = "pointer-events-none absolute -bottom-5 font-mono text-xs whitespace-nowrap text-info tabular-nums";
return (
<>
<div
className="absolute inset-y-0 cursor-grab bg-[#0011b3]/[0.05] active:cursor-grabbing dark:bg-[#8b9bff]/[0.08]"
style={{ left: pct(leftFrac), width: pct(rightFrac - leftFrac) }}
data-testid="timeline-selection"
onPointerDown={onHandleDown("move")}
>
<span
className={cn(handle, tick, "-left-px before:left-0 after:left-0")}
data-testid="timeline-handle-lo"
onPointerDown={onHandleDown("resize-lo")}
/>
<span
className={cn(handle, tick, "-right-px before:right-0 after:right-0")}
data-testid="timeline-handle-hi"
onPointerDown={onHandleDown("resize-hi")}
/>
{widthPx >= MIN_DURATION_LABEL_PX && (
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-xs font-semibold text-info">
{formatSpan(window.endMs - window.startMs)}
</span>
)}
</div>
<span className={cn(edgeLabel, leftFrac < 0.12 ? "" : "-translate-x-full pr-1")} style={{ left: pct(leftFrac) }}>
{moment(window.startMs).format(format)}
</span>
<span className={cn(edgeLabel, rightFrac > 0.88 ? "-translate-x-full" : "pl-1")} style={{ left: pct(rightFrac) }}>
{moment(window.endMs).format(format)}
</span>
</>
);
}
function TickAxis({ range }: { range: TimeWindow }) {
const format = tickFormat(range);
const ticks = Array.from({ length: TICKS }, (_, i) => i / (TICKS - 1));
return (
<div className="relative mt-0.5 h-5">
{ticks.map((t) => (
<div
key={t}
className={cn(
"absolute top-0 flex flex-col",
t === 0 && "items-start",
t === 1 && "items-end",
t > 0 && t < 1 && "items-center",
)}
style={{ left: pct(t), transform: tickShift(t) }}
>
<span className="h-1 w-px bg-border" />
<span className="font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
</span>
</div>
))}
</div>
);
}
/** Histogram over the window covered by `buckets`. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
export function Timeline({ buckets, selection, onSelect }: TimelineProps) {
const bucketCount = buckets.length;
const [hover, setHover] = useState<number | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const [draft, setDraft] = useState<Band | null>(null);
const areaRef = useRef<HTMLDivElement>(null);
const { width: stripWidth = 0 } = useResizeObserver({
ref: areaRef as RefObject<HTMLDivElement>,
box: "border-box",
});
const committed = bandForWindow(buckets, selection);
const band = drag ? draft : committed;
const windowOf = (b: Band): TimeWindow => ({ startMs: buckets[b.lo].startMs, endMs: buckets[b.hi].endMs });
const indexAt = (clientX: number): number => {
const rect = areaRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return 0;
return Math.min(bucketCount - 1, Math.max(0, Math.floor(((clientX - rect.left) / rect.width) * bucketCount)));
};
const begin = (mode: DragMode, e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
areaRef.current?.setPointerCapture?.(e.pointerId);
const at = indexAt(e.clientX);
const start = mode === "select" || committed === null ? { lo: at, hi: at } : committed;
setDrag({ mode: committed === null ? "select" : mode, origin: at, band: start });
setDraft(start);
};
const onHandleDown = (mode: DragMode) => (e: React.PointerEvent) => begin(mode, e);
const onMove = (e: React.PointerEvent) => {
const at = indexAt(e.clientX);
setHover(at);
if (drag) setDraft(dragUpdate(drag, at, bucketCount));
};
const onUp = (e: React.PointerEvent) => {
areaRef.current?.releasePointerCapture?.(e.pointerId);
if (!drag || !draft) return;
const finished = draft;
const wasSelect = drag.mode === "select";
setDrag(null);
setDraft(null);
if (wasSelect && finished.lo === finished.hi) {
onSelect(null);
return;
}
onSelect(windowOf(finished));
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "Escape" || selection === null) return;
e.preventDefault();
onSelect(null);
};
if (bucketCount === 0) return null;
const range: TimeWindow = { startMs: buckets[0].startMs, endMs: buckets[bucketCount - 1].endMs };
const labelFormat = edgeFormat(range);
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"
tabIndex={0}
onKeyDown={onKeyDown}
>
<DotFieldRoot
ref={areaRef}
columns={buckets}
band={band}
hover={hover}
role="presentation"
data-testid="timeline-area"
className="flex cursor-crosshair touch-none items-end"
onPointerDown={(e) => begin("select", e)}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerLeave={() => setHover(null)}
onDoubleClick={() => onSelect(null)}
>
{hover !== null && !drag && (
<div
className="pointer-events-none absolute inset-y-0 w-px bg-[#0011b3]/50 dark:bg-[#8b9bff]/50"
style={{ left: pct((hover + 0.5) / bucketCount) }}
data-testid="timeline-cursor"
/>
)}
<DotFieldCanvas />
<NowEdge />
{buckets.map((b) => (
<BucketBar key={b.startMs} bucket={b} />
))}
{band && (
<SelectionBracket
band={band}
window={windowOf(band)}
format={labelFormat}
stripWidth={stripWidth}
bucketCount={bucketCount}
onHandleDown={onHandleDown}
/>
)}
</DotFieldRoot>
<div className="mt-1">
<TickAxis range={range} />
</div>
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} bucketCount={bucketCount} />}
{selection && (
<button
type="button"
onClick={() => onSelect(null)}
aria-label="Clear time zoom"
className="absolute top-1 right-3 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
);
}