From d26c52a6bada8c4f34b25616a7c2a0f3a4a0f357 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 3 Oct 2026 18:08:08 -0700 Subject: [PATCH] feat(ui): extract trace drawer into a shared SidePanel that closes on outside press Co-Authored-By: Claude Opus 5.5 --- ui/litellm-dashboard/package-lock.json | 22 ++ ui/litellm-dashboard/package.json | 1 + .../src/components/shared/SidePanel.tsx | 245 ++++++++++++++++++ .../view_logs/TraceView/AgentTracesTable.tsx | 2 + .../view_logs/TraceView/RunDrawer.test.tsx | 51 +++- .../view_logs/TraceView/RunDrawer.tsx | 221 ++-------------- 6 files changed, 334 insertions(+), 208 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/SidePanel.tsx diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index af723f22957..1de2201d0f6 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -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", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 894f6f230ca..e6f3e41fcd1 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -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" }, diff --git a/ui/litellm-dashboard/src/components/shared/SidePanel.tsx b/ui/litellm-dashboard/src/components/shared/SidePanel.tsx new file mode 100644 index 00000000000..58b950acd82 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/SidePanel.tsx @@ -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(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(item: T | null, itemKey: (item: T) => string) { + const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + const [lastShown, setLastShown] = useState(item); + const [exitedKey, setExitedKey] = useState(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) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(true); + }; + const onPointerMove = (event: React.PointerEvent) => { + if (dragging) onResize(window.innerWidth - event.clientX); + }; + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") onResize(width + KEY_STEP); + else if (event.key === "ArrowRight") onResize(width - KEY_STEP); + else return; + event.preventDefault(); + event.stopPropagation(); + }; + + return ( +
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" + > + +
+ ); +} + +function HeaderButton({ + label, + disabled = false, + onClick, + children, +}: { + label: string; + disabled?: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +export interface SidePanelProps { + 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({ + item, + itemKey, + noun, + label, + testId, + index, + total, + onStep, + onClose, + fullScreen, + onFullScreenChange, + children, +}: SidePanelProps) { + const [width, setWidth] = useDrawerWidth(); + const panelRef = useRef(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, (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 ( + + ); +} + +/** Steps back one level inside a panel that swaps nested views in place. */ +export function PanelBackLink({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesTable.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesTable.tsx index a14ac151e05..e4c28f434b8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesTable.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesTable.tsx @@ -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({ onOpenTrace(run)} aria-selected={selectedKey === (run.trace_ref || run.trace_id)} className={cn( diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.test.tsx index e888b3d607c..853ecbc0cae 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.test.tsx @@ -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) => 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( + <> + + + + + + + +
row
+
dialog
+ + , + ); + 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")]; diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.tsx index 9e963b35730..b6ba4b9005a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.tsx @@ -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) => { - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - setDragging(true); - }; - const onPointerMove = (event: React.PointerEvent) => { - if (dragging) onResize(viewportWidth() - event.clientX); - }; - const onKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "ArrowLeft") onResize(width + KEY_STEP); - else if (event.key === "ArrowRight") onResize(width - KEY_STEP); - else return; - event.preventDefault(); - event.stopPropagation(); - }; - - return ( -
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" - > - -
- ); -} - -function HeaderButton({ - label, - disabled = false, - onClick, - children, -}: { - label: string; - disabled?: boolean; - onClick: () => void; - children: React.ReactNode; -}) { - return ( - - ); -} - -function FullScreenButton({ fullScreen, onToggle }: { fullScreen: boolean; onToggle: () => void }) { - return ( - - {fullScreen ? : } - - ); -} interface RunDrawerProps { trace: TraceRef | null; @@ -159,15 +28,6 @@ export function RunDrawer({ fullScreen, onFullScreenChange, }: RunDrawerProps) { - const [width, setWidth] = useDrawerWidth(); - const [lastShown, setLastShown] = useState(trace); - const [exitedKey, setExitedKey] = useState(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 ( - + )} + ); }