mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
62fb808d4b
commit
d26c52a6ba
6 changed files with 334 additions and 208 deletions
22
ui/litellm-dashboard/package-lock.json
generated
22
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
245
ui/litellm-dashboard/src/components/shared/SidePanel.tsx
Normal file
245
ui/litellm-dashboard/src/components/shared/SidePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue