feat(ui): extract trace drawer into a shared SidePanel that closes on outside press

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-03 18:08:08 -07:00
parent 62fb808d4b
commit d26c52a6ba
6 changed files with 334 additions and 208 deletions

View file

@ -44,6 +44,7 @@
"remark-gfm": "4.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
"uuid": "14.0.0",
"zod": "4.6.5"
},
@ -8450,6 +8451,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -12117,6 +12124,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",

View file

@ -61,6 +61,7 @@
"remark-gfm": "4.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
"uuid": "14.0.0",
"zod": "4.6.5"
},

View file

@ -0,0 +1,245 @@
"use client";
import { ArrowLeft, ChevronDown, ChevronsRight, ChevronUp, Maximize2, Minimize2, X } from "lucide-react";
import { useCallback, useRef, useState, type ReactNode, type RefObject } from "react";
import { useEventListener, useLocalStorage, useMediaQuery, useOnClickOutside, useWindowSize } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
import { ignoresLetterShortcut } from "@/components/view_logs/letterShortcut";
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
const MIN_WIDTH = 700;
const MIN_LEFT_GAP = 100;
const DEFAULT_FRACTION = 0.56;
const KEY_STEP = 32;
/** At least 700px when the screen allows, never wider than the screen minus a 100px strip of list (or the full screen). */
export const clampDrawerWidth = (width: number, viewport: number): number => {
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
};
/** Rows that open or swap the panel's item; pressing one must not also close it. */
export const PANEL_TRIGGER = { "data-side-panel-trigger": "" } as const;
const KEEPS_PANEL_OPEN =
"[data-side-panel-trigger], [role='dialog'], [role='menu'], [role='listbox'], [data-radix-popper-content-wrapper]";
/** A dialog opened from inside the panel (a finding, a span) owns its own keys. */
const insideDialog = (event: KeyboardEvent): boolean =>
event.target instanceof Element && event.target.closest("[role='dialog']") !== null;
function useDrawerWidth() {
const { width: viewport } = useWindowSize();
const [stored, setStored] = useLocalStorage<number | null>(WIDTH_KEY, null, {
serializer: String,
deserializer: (raw) => (Number.isFinite(Number(raw)) ? Number(raw) : null),
});
const width = clampDrawerWidth(stored ?? viewport * DEFAULT_FRACTION, viewport);
const update = useCallback((next: number) => setStored(clampDrawerWidth(next, window.innerWidth)), [setStored]);
return [width, update] as const;
}
/** Keeps the last item rendered while it plays its exit animation, then drops it. */
function useExitPresence<T>(item: T | null, itemKey: (item: T) => string) {
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const [lastShown, setLastShown] = useState<T | null>(item);
const [exitedKey, setExitedKey] = useState<string | null>(null);
if (item !== null && item !== lastShown) setLastShown(item);
if (item !== null && exitedKey !== null) setExitedKey(null);
const shown = item ?? lastShown;
const closing = item === null && shown !== null;
const exited = closing && (reduceMotion || exitedKey === itemKey(shown));
const onExited = () => {
if (closing) setExitedKey(itemKey(shown));
};
return { shown: exited ? null : shown, closing, onExited };
}
function ResizeHandle({ noun, width, onResize }: { noun: string; width: number; onResize: (width: number) => void }) {
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragging) onResize(window.innerWidth - event.clientX);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
else return;
event.preventDefault();
event.stopPropagation();
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${noun} panel`}
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onKeyDown={onKeyDown}
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
>
<span
className={cn(
"h-full w-[0.67px] bg-border transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
dragging && "w-0.5 bg-trace-brand",
)}
/>
</div>
);
}
function HeaderButton({
label,
disabled = false,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className="grid size-7 place-items-center rounded-[4px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
>
{children}
</button>
);
}
export interface SidePanelProps<T> {
item: T | null;
itemKey: (item: T) => string;
/** Lower-case name of what the panel shows, used in its control labels ("trace", "investigation"). */
noun: string;
label: string;
testId?: string;
/** Position of the open item in the list behind the panel, for J / K stepping. */
index: number;
total: number;
onStep: (delta: number) => void;
onClose: () => void;
fullScreen: boolean;
onFullScreenChange: (fullScreen: boolean) => void;
children: (item: T) => ReactNode;
}
/** Right-side drawer over a list: resizable, swaps items in place, closes on a press outside it and its trigger rows. */
export function SidePanel<T>({
item,
itemKey,
noun,
label,
testId,
index,
total,
onStep,
onClose,
fullScreen,
onFullScreenChange,
children,
}: SidePanelProps<T>) {
const [width, setWidth] = useDrawerWidth();
const panelRef = useRef<HTMLElement>(null);
const { shown, closing, onExited } = useExitPresence(item, itemKey);
useEventListener("keydown", (event) => {
if (item === null || ignoresLetterShortcut(event) || insideDialog(event)) return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
} else if (event.key === "j") {
event.preventDefault();
onStep(1);
} else if (event.key === "k") {
event.preventDefault();
onStep(-1);
}
});
useOnClickOutside(panelRef as RefObject<HTMLElement>, (event) => {
if (item === null || (event instanceof MouseEvent && event.button !== 0)) return;
if (event.target instanceof Element && event.target.closest(KEEPS_PANEL_OPEN) !== null) return;
onClose();
});
if (shown === null) return null;
return (
<aside
ref={panelRef}
aria-label={label}
data-testid={testId}
style={{ width: fullScreen ? "100%" : width }}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget) onExited();
}}
className={cn(
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
)}
>
{!fullScreen && <ResizeHandle noun={noun} width={width} onResize={setWidth} />}
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
<HeaderButton label="Close (Esc)" onClick={onClose}>
<ChevronsRight className="size-4" />
</HeaderButton>
<span className="mx-1 h-4 w-px bg-border" />
<HeaderButton label={`Next ${noun} (J)`} disabled={index < 0 || index >= total - 1} onClick={() => onStep(1)}>
<ChevronDown className="size-4" />
</HeaderButton>
<HeaderButton label={`Previous ${noun} (K)`} disabled={index <= 0} onClick={() => onStep(-1)}>
<ChevronUp className="size-4" />
</HeaderButton>
{index >= 0 && (
<span className="ml-1 font-mono text-[11px] text-muted-foreground tabular-nums">
{index + 1} / {total}
</span>
)}
<div className="ml-auto flex items-center gap-1">
<HeaderButton
label={fullScreen ? "Exit full screen" : "Enter full screen"}
onClick={() => onFullScreenChange(!fullScreen)}
>
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</HeaderButton>
<HeaderButton label={`Close ${noun} (Esc)`} onClick={onClose}>
<X className="size-4" />
</HeaderButton>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">{children(shown)}</div>
</aside>
);
}
/** Steps back one level inside a panel that swaps nested views in place. */
export function PanelBackLink({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="-ml-1 inline-flex max-w-full items-center gap-1 rounded px-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}

View file

@ -4,6 +4,7 @@ import { ArrowDown, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { PANEL_TRIGGER } from "@/components/shared/SidePanel";
import { Button } from "@/components/ui/button";
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp";
import { cn } from "@/lib/cva.config";
@ -123,6 +124,7 @@ export function AgentTracesTable({
<tr
key={run.trace_ref || run.trace_id}
data-testid="agent-trace-row"
{...PANEL_TRIGGER}
onClick={() => onOpenTrace(run)}
aria-selected={selectedKey === (run.trace_ref || run.trace_id)}
className={cn(

View file

@ -2,7 +2,8 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { clampDrawerWidth, RunDrawer } from "./RunDrawer";
import { clampDrawerWidth, PANEL_TRIGGER } from "@/components/shared/SidePanel";
import { RunDrawer } from "./RunDrawer";
import { type RunSelection, traceRefOf, useOpenTraceRouting } from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
@ -62,11 +63,15 @@ const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
const mockReducedMotion = (reduce: boolean) =>
vi
.spyOn(window, "matchMedia")
.mockImplementation(
(query: string) => ({ matches: reduce && query.includes("reduce"), media: query }) as MediaQueryList,
);
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: reduce && query.includes("reduce"),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
describe("clampDrawerWidth", () => {
it("keeps at least 700px and a 100px strip of list on wide screens", () => {
@ -156,6 +161,40 @@ describe("RunDrawer", () => {
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
it("closes on a press outside the panel but not inside it, on a trigger row, or in a dialog", () => {
const runs = [run("a")];
const onSelect = vi.fn();
renderWithProviders(
<>
<button type="button">outside</button>
<table>
<tbody>
<tr {...PANEL_TRIGGER}>
<td>row</td>
</tr>
</tbody>
</table>
<div role="dialog">dialog</div>
<RunDrawer
trace={traceRefOf(runs[0])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={onSelect}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>
</>,
);
fireEvent.mouseDown(screen.getByTestId("run-view"));
fireEvent.mouseDown(screen.getByText("row"));
fireEvent.mouseDown(screen.getByText("dialog"));
fireEvent.mouseDown(screen.getByText("outside"), { button: 2 });
expect(onSelect).not.toHaveBeenCalled();
fireEvent.mouseDown(screen.getByText("outside"));
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
it("unmounts right away on close when the user prefers reduced motion", () => {
mockReducedMotion(true);
const runs = [run("a")];

View file

@ -1,143 +1,12 @@
"use client";
import { ChevronDown, ChevronsRight, ChevronUp, Maximize2, Minimize2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback } from "react";
import { cn } from "@/lib/cva.config";
import { SidePanel } from "@/components/shared/SidePanel";
import { RunView } from "./TraceDrawer";
import { type RunSelection, type TraceRef, traceKey, traceRefOf } from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
import { ignoresLetterShortcut } from "../letterShortcut";
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
const MIN_WIDTH = 700;
const MIN_LEFT_GAP = 100;
const DEFAULT_FRACTION = 0.56;
const KEY_STEP = 32;
const viewportWidth = (): number => (typeof window === "undefined" ? 1440 : window.innerWidth);
/** At least 700px when the screen allows, never wider than the screen minus a 100px strip of list (or the full screen). */
export const clampDrawerWidth = (width: number, viewport: number): number => {
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
};
const prefersReducedMotion = (): boolean =>
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
const readStoredWidth = (): number | null => {
try {
const raw = window.localStorage.getItem(WIDTH_KEY);
const parsed = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
};
const storeWidth = (width: number): void => {
try {
window.localStorage.setItem(WIDTH_KEY, String(width));
} catch {
return;
}
};
function useDrawerWidth() {
const [width, setWidth] = useState(() =>
clampDrawerWidth(readStoredWidth() ?? viewportWidth() * DEFAULT_FRACTION, viewportWidth()),
);
const update = useCallback((next: number) => {
const clamped = clampDrawerWidth(next, viewportWidth());
setWidth(clamped);
storeWidth(clamped);
}, []);
useEffect(() => {
const onResize = () => setWidth((w) => clampDrawerWidth(w, viewportWidth()));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return [width, update] as const;
}
function ResizeHandle({ width, onResize }: { width: number; onResize: (width: number) => void }) {
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragging) onResize(viewportWidth() - event.clientX);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
else return;
event.preventDefault();
event.stopPropagation();
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize trace panel"
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onKeyDown={onKeyDown}
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
>
<span
className={cn(
"h-full w-[0.67px] bg-border transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
dragging && "w-0.5 bg-trace-brand",
)}
/>
</div>
);
}
function HeaderButton({
label,
disabled = false,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className="grid size-7 place-items-center rounded-[4px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
>
{children}
</button>
);
}
function FullScreenButton({ fullScreen, onToggle }: { fullScreen: boolean; onToggle: () => void }) {
return (
<HeaderButton label={fullScreen ? "Exit full screen" : "Enter full screen"} onClick={onToggle}>
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</HeaderButton>
);
}
interface RunDrawerProps {
trace: TraceRef | null;
@ -159,15 +28,6 @@ export function RunDrawer({
fullScreen,
onFullScreenChange,
}: RunDrawerProps) {
const [width, setWidth] = useDrawerWidth();
const [lastShown, setLastShown] = useState<TraceRef | null>(trace);
const [exitedKey, setExitedKey] = useState<string | null>(null);
if (trace !== null && trace !== lastShown) setLastShown(trace);
const shown = trace ?? lastShown;
const closing = trace === null && shown !== null;
if (trace !== null && exitedKey !== null) setExitedKey(null);
if (closing && exitedKey !== traceKey(shown) && prefersReducedMotion()) setExitedKey(traceKey(shown));
const index = trace === null ? -1 : runs.findIndex((run) => traceKey(traceRefOf(run)) === traceKey(trace));
const step = useCallback(
(delta: number) => {
@ -176,75 +36,32 @@ export function RunDrawer({
},
[runs, index, onSelect],
);
useEffect(() => {
if (trace === null) return;
const onKeyDown = (event: KeyboardEvent) => {
if (ignoresLetterShortcut(event)) return;
if (event.key === "Escape") {
event.preventDefault();
onSelect(null);
} else if (event.key === "j") {
event.preventDefault();
step(1);
} else if (event.key === "k") {
event.preventDefault();
step(-1);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [trace, step, onSelect]);
if (shown === null || (closing && exitedKey === traceKey(shown))) return null;
const close = useCallback(() => onSelect(null), [onSelect]);
return (
<aside
aria-label="Trace details"
data-testid="run-drawer"
style={{ width: fullScreen ? "100%" : width }}
onAnimationEnd={(event) => {
if (closing && event.target === event.currentTarget) setExitedKey(traceKey(shown));
}}
className={cn(
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
)}
<SidePanel
item={trace}
itemKey={traceKey}
noun="trace"
label="Trace details"
testId="run-drawer"
index={index}
total={runs.length}
onStep={step}
onClose={close}
fullScreen={fullScreen}
onFullScreenChange={onFullScreenChange}
>
{!fullScreen && <ResizeHandle width={width} onResize={setWidth} />}
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
<HeaderButton label="Close (Esc)" onClick={() => onSelect(null)}>
<ChevronsRight className="size-4" />
</HeaderButton>
<span className="mx-1 h-4 w-px bg-border" />
<HeaderButton label="Next trace (J)" disabled={index < 0 || index >= runs.length - 1} onClick={() => step(1)}>
<ChevronDown className="size-4" />
</HeaderButton>
<HeaderButton label="Previous trace (K)" disabled={index <= 0} onClick={() => step(-1)}>
<ChevronUp className="size-4" />
</HeaderButton>
{index >= 0 && (
<span className="ml-1 font-mono text-[11px] text-muted-foreground tabular-nums">
{index + 1} / {runs.length}
</span>
)}
<div className="ml-auto flex items-center gap-1">
<FullScreenButton fullScreen={fullScreen} onToggle={() => onFullScreenChange(!fullScreen)} />
<HeaderButton label="Close trace (Esc)" onClick={() => onSelect(null)}>
<X className="size-4" />
</HeaderButton>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{(shown) => (
<RunView
key={traceKey(shown)}
traceId={shown.traceId}
traceRef={shown.traceRef}
selection={selection}
accessToken={accessToken}
onBack={() => onSelect(null)}
onBack={close}
embedded
/>
</div>
</aside>
)}
</SidePanel>
);
}