diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index cc497677a1a..7cdf7aa0489 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -15,7 +15,7 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/components/liteadmin/LiteAdmin", () => ({ - default: () => LiteAdmin, + LiteAdminFrame: ({ children }: { children: React.ReactNode }) => children, })); vi.mock("@/components/DashboardHeader", () => ({ @@ -89,31 +89,6 @@ describe("(dashboard) Layout", () => { vi.mocked(usePathname).mockReturnValue("/ui/guardrails"); }); - it.each(["/ui/playground", "/ui/playground/"])( - "hides LiteAdmin on %s and restores it after leaving Playground", - async (pathname) => { - const dashboard = () => ( - - - - - - ); - const { rerender } = render(dashboard()); - pendingUiConfig.resolve(); - expect(await screen.findByRole("button", { name: "LiteAdmin" })).toBeInTheDocument(); - - vi.mocked(usePathname).mockReturnValue(pathname); - rerender(dashboard()); - expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument(); - expect(screen.getByTestId("page-content")).toBeInTheDocument(); - - vi.mocked(usePathname).mockReturnValue("/ui/api-keys"); - rerender(dashboard()); - expect(screen.getByRole("button", { name: "LiteAdmin" })).toBeInTheDocument(); - }, - ); - it("collapses the sidebar on Logs for a full-screen view and expands it again after leaving", async () => { const dashboard = () => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 9d8718cb84a..d705089cee8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -13,7 +13,7 @@ import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner"; import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner"; import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner"; import { UserBanner } from "@/components/UserBanner"; -import LiteAdmin from "@/components/liteadmin/LiteAdmin"; +import { LiteAdminFrame } from "@/components/liteadmin/LiteAdmin"; import { UpgradeBanner } from "@/components/UpgradeBanner"; import { routeSegmentForPathname, uiHref } from "@/utils/uiHref"; import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext"; @@ -105,7 +105,6 @@ function DashboardShell({ children }: { children: React.ReactNode }) { const { accessToken } = useAuth(); const { mode } = usePluginMode(); const routeSegment = routeSegmentForPathname(usePathname()); - const isPlayground = routeSegment === "playground"; const isFullBleed = FULL_BLEED_SEGMENTS.has(routeSegment); // A manual toggle holds only for the route it was made on; full-bleed routes default to collapsed. const [sidebarOverride, setSidebarOverride] = useState<{ segment: string; collapsed: boolean } | null>(null); @@ -141,17 +140,18 @@ function DashboardShell({ children }: { children: React.ReactNode }) { return ( - - - - - - - - - {children} - {!isPlayground && } - + + + + + + + + + + {children} + + ); } diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index b4b9950cdd0..751a0192b8a 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { DashboardHeader } from "./DashboardHeader"; import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass"; @@ -27,6 +27,7 @@ vi.mock("@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButto })); vi.mock("@/components/Navbar/NotificationsBell/NotificationsBell", () => ({ NotificationsBell: () => null })); vi.mock("@/components/Navbar/WorkerDropdown/WorkerDropdown", () => ({ default: () => null })); +vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () => LiteAdmin })); describe("DashboardHeader breadcrumb", () => { afterEach(() => { @@ -89,4 +90,13 @@ describe("DashboardHeader breadcrumb", () => { expect(separators[0].className).not.toMatch(/self-stretch/); expect(separators[0].className).toContain("data-vertical:self-center"); }); + + it("places LiteAdmin in the header tools ahead of Docs", () => { + render(); + + const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "LiteAdmin" }); + expect(liteAdmin.compareDocumentPosition(screen.getByRole("link", { name: "Docs" }))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); }); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index 57d734d05b3..e4107669336 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -14,6 +14,7 @@ import { DocsLink } from "@/components/Navbar/DocsLink/DocsLink"; import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell"; import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; +import LiteAdmin from "@/components/liteadmin/LiteAdmin"; import ThemeToggle from "@/components/ThemeToggle/ThemeToggle"; import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown"; import { useWorker } from "@/hooks/useWorker"; @@ -59,6 +60,7 @@ export function DashboardHeader() { > )} + {!hideCommunityLinks && } diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx index b8f10964744..c7ca64e3123 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx @@ -6,10 +6,10 @@ import { setGlobalLitellmHeaderName, switchToWorkerUrl } from "@/components/netw import { Toaster } from "@/components/ui/sonner"; import { toast } from "@/lib/toast"; import userEvent from "@testing-library/user-event"; -import type { ComponentType } from "react"; +import { useEffect, type ComponentType } from "react"; import SidebarAccountMenu from "@/components/SidebarAccountMenu/SidebarAccountMenu"; import UserDropdown from "@/components/Navbar/UserDropdown/UserDropdown"; -import LiteAdmin from "./LiteAdmin"; +import LiteAdmin, { LiteAdminFrame } from "./LiteAdmin"; import { MAX_INPUT_LENGTH } from "./agent"; const { transport } = vi.hoisted(() => { @@ -83,6 +83,15 @@ function SessionReady() { return {authLoading ? "Session loading" : "Session ready"}; } +let pageMounts = 0; + +function Page() { + useEffect(() => { + pageMounts += 1; + }, []); + return null; +} + function renderWidget(Menu?: ComponentType<{ onLogout: () => void }>) { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const tree = () => ( @@ -91,7 +100,10 @@ function renderWidget(Menu?: ComponentType<{ onLogout: () => void }>) { {Menu && undefined} />} - + + + + ); @@ -167,6 +179,7 @@ function send(text: string) { } beforeEach(() => { + pageMounts = 0; transport.mockReset(); localStorage.clear(); sessionStorage.clear(); @@ -254,6 +267,62 @@ describe("LiteAdmin in the gateway", () => { expect(screen.queryByRole("switch", { name: "Toggle hide LiteAdmin" })).not.toBeInTheDocument(); }); + it("keeps the page mounted and comes back closed after Hide LiteAdmin is turned off", async () => { + gateway([]); + pageMounts = 0; + const user = userEvent.setup(); + renderWidget(SidebarAccountMenu); + await screen.findByRole("button", { name: "LiteAdmin" }); + await user.click(screen.getByRole("button", { name: /account menu/i })); + const toggle = await screen.findByRole("switch", { name: "Toggle hide LiteAdmin" }); + fireEvent.keyDown(document, { key: "j", metaKey: true }); + expect(await screen.findByRole("complementary", { name: "LiteAdmin" })).toBeInTheDocument(); + await user.click(toggle); + expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument(); + await user.click(toggle); + expect(await screen.findByRole("button", { name: "LiteAdmin" })).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument(); + expect(pageMounts).toBe(1); + }); + + it("moves focus into the panel on open: the panel while loading, the message box, then a pending review", async () => { + gateway([toolReply("key_create", keyArguments)]); + renderWidget(); + fireEvent.click(await screen.findByRole("button", { name: "LiteAdmin" })); + expect(screen.getByRole("complementary", { name: "LiteAdmin" })).toHaveFocus(); + await selectModel(); + const reopen = () => { + fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" })); + fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" })); + }; + reopen(); + expect(await screen.findByPlaceholderText("Ask LiteAdmin…")).toHaveFocus(); + send("Create a key for the team"); + const review = await screen.findByRole("region", { name: "Create a virtual key" }); + reopen(); + await waitFor(() => expect(review).toHaveFocus()); + }); + + it("toggles with Cmd+J or Ctrl+J and ignores the shortcut while hidden", async () => { + gateway([]); + const user = userEvent.setup(); + renderWidget(SidebarAccountMenu); + await screen.findByRole("button", { name: "LiteAdmin" }); + fireEvent.keyDown(document, { key: "j", metaKey: true, shiftKey: true }); + expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument(); + fireEvent.keyDown(document, { key: "j", metaKey: true }); + expect(await screen.findByRole("complementary", { name: "LiteAdmin" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "LiteAdmin" })).toHaveAttribute("aria-expanded", "true"); + fireEvent.keyDown(document, { key: "j", ctrlKey: true }); + expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /account menu/i })); + await user.click(await screen.findByRole("switch", { name: "Toggle hide LiteAdmin" })); + expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument(); + fireEvent.keyDown(document, { key: "j", metaKey: true }); + expect(screen.queryByPlaceholderText("Ask LiteAdmin…")).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "LiteAdmin" })).not.toBeInTheDocument(); + }); + it.each(["proxy_admin_viewer", "internal_user", "internal_user_viewer", "org_admin"])( "does not expose operations to %s", async (role) => { @@ -305,7 +374,7 @@ describe("LiteAdmin in the gateway", () => { await openWidget(); send("Create a key for the team"); const review = await screen.findByRole("region", { name: "Create a virtual key" }); - expect(screen.getAllByRole("dialog")).toHaveLength(1); + expect(screen.getAllByRole("complementary", { name: "LiteAdmin" })).toHaveLength(1); expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0); expect(within(review).getByText("Widget key")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" })); diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx index 8092eb2f5e6..8bbf030ac8f 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, type ReactNode } from "react"; +import { createContext, useContext, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { useQuery } from "@tanstack/react-query"; import { RotateCcw, Sparkles, X } from "lucide-react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -15,25 +15,19 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { FieldError } from "@/components/ui/field"; -import { - Popover, - PopoverContent, - PopoverDescription, - PopoverHeader, - PopoverTitle, - PopoverTrigger, -} from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/cva.config"; import { isProxyAdminRole } from "@/utils/roles"; import { MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent"; import { LiteAdminConversation } from "./LiteAdminConversation"; import { useLiteAdmin, type LiteAdminSession } from "./useLiteAdmin"; -const PANEL_CLASS = - "flex h-[min(42rem,calc(100dvh-6rem))] w-[min(30rem,calc(100vw-2rem))] min-w-0 flex-col gap-0 overflow-hidden rounded-xl p-0"; type ManagementSession = Omit; +type LiteAdminState = { open: boolean; toggle: () => void }; -export default function LiteAdmin() { +const LiteAdminContext = createContext(null); + +function useLiteAdminSession() { const auth = useAuthorized(); const [disabled] = useDisableLiteAdmin(auth.userId); const sessionReady = !auth.isLoading && auth.isAuthorized; @@ -41,16 +35,62 @@ export default function LiteAdmin() { const allowed = sessionReady && writableAdmin && !disabled; if (!allowed || !auth.token || !auth.accessToken) return null; const session = { token: auth.token, accessToken: auth.accessToken, managementBaseUrl: getProxyBaseUrl() }; + return { session, key: JSON.stringify([auth.userId, session.token, session.accessToken, session.managementBaseUrl]) }; +} + +/** Wraps the dashboard content column and docks the LiteAdmin panel beside it, so opening it narrows the page instead of covering it. */ +export function LiteAdminFrame({ children }: { children: ReactNode }) { + const configured = useLiteAdminSession(); + const sessionKey = configured?.key ?? null; + const [open, setOpen] = useState(false); + const [openedSession, setOpenedSession] = useState(sessionKey); + if (openedSession !== sessionKey) { + setOpenedSession(sessionKey); + setOpen(false); + } + const toggle = () => setOpen((current) => !current); + useEffect(() => { + if (sessionKey === null) return; + const onKeyDown = (event: KeyboardEvent) => { + const modifier = event.metaKey || event.ctrlKey; + const extraModifier = event.shiftKey || event.altKey; + if (event.key.toLowerCase() !== "j" || !modifier || extraModifier) return; + event.preventDefault(); + toggle(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [sessionKey]); return ( - + + {children} + {configured && ( + setOpen(false)} /> + )} + ); } -function ConfiguredLiteAdmin({ session }: { session: ManagementSession }) { - const [open, setOpen] = useState(false); +export default function LiteAdminTrigger() { + const state = useContext(LiteAdminContext); + if (!state) return null; + return ( + + + LiteAdmin + ⌘J + + ); +} + +function DockedPanel({ session, open, close }: { session: ManagementSession; open: boolean; close: () => void }) { const settings = useProxySettingsQuery(session.accessToken); const candidate = settings.data?.LITELLM_UI_API_DOC_BASE_URL?.trim() || @@ -60,21 +100,15 @@ function ConfiguredLiteAdmin({ session }: { session: ManagementSession }) { ? resolveInferenceTarget(candidate, session.managementBaseUrl, window.location.href) : null; return ( - - }> - - LiteAdmin - - void settings.refetch()} - open={open} - close={() => setOpen(false)} - /> - + void settings.refetch()} + open={open} + close={close} + /> ); } @@ -98,7 +132,7 @@ function Destination({ return ; } return ( - + {loading && } @@ -128,7 +162,41 @@ function Destination({ )} - + + ); +} + +/** On open, focus `initialFocus` when given, else the composer when it is usable, else the panel itself. */ +function Panel({ + open, + initialFocus, + children, +}: { + open: boolean; + initialFocus?: RefObject; + children: ReactNode; +}) { + const ref = useRef(null); + useEffect(() => { + const panel = ref.current; + if (!open || !panel) return; + (initialFocus?.current ?? panel.querySelector("textarea:enabled") ?? panel).focus({ + preventScroll: true, + }); + }, [open, initialFocus]); + return ( + ); } @@ -156,13 +224,7 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op setInput(""); }; return ( - + Use a model you trust with your gateway data. - + ); } function PanelHeader({ close, children }: { close: () => void; children?: ReactNode }) { return ( - - LiteAdmin - Ask about your gateway. Review changes in chat. - + + + + LiteAdmin + + Ask about your gateway. Review changes in chat. + {children} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.test.tsx new file mode 100644 index 00000000000..f20d35583dc --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.test.tsx @@ -0,0 +1,22 @@ +import { fireEvent, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { LogEntry } from "../columns"; +import { useKeyboardNavigation } from "./useKeyboardNavigation"; + +const logs = [{ request_id: "first" }, { request_id: "second" }] as LogEntry[]; + +describe("useKeyboardNavigation", () => { + it("moves with plain J / K and ignores modified presses such as Cmd+J", () => { + const onSelectLog = vi.fn(); + const props = { isOpen: true, currentLog: logs[0], allLogs: logs, onClose: vi.fn(), onSelectLog }; + renderHook(() => useKeyboardNavigation(props)); + + fireEvent.keyDown(window, { key: "j", metaKey: true }); + fireEvent.keyDown(window, { key: "j", ctrlKey: true }); + fireEvent.keyDown(window, { key: "j", altKey: true }); + expect(onSelectLog).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "j" }); + expect(onSelectLog).toHaveBeenCalledExactlyOnceWith(logs[1]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts index ba1bc4bc722..a8a0d44db7d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { LogEntry } from "../columns"; +import { ignoresLetterShortcut } from "../letterShortcut"; import { KEY_ESCAPE, KEY_J_LOWER, KEY_J_UPPER, KEY_K_LOWER, KEY_K_UPPER } from "./constants"; interface UseKeyboardNavigationProps { @@ -28,10 +29,7 @@ export function useKeyboardNavigation({ }: UseKeyboardNavigationProps) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Don't trigger if user is typing in an input - if (isUserTyping(e.target)) { - return; - } + if (ignoresLetterShortcut(e)) return; if (!isOpen) return; @@ -77,11 +75,3 @@ export function useKeyboardNavigation({ selectPreviousLog, }; } - -/** - * Checks if the user is currently typing in an input field. - * Used to prevent keyboard shortcuts from interfering with text input. - */ -function isUserTyping(target: EventTarget | null): boolean { - return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; -} 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 905f3a9cf23..147a55e994f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/RunDrawer.tsx @@ -7,6 +7,7 @@ import { cn } from "@/lib/cva.config"; import { RunView } from "./TraceDrawer"; import type { TraceSummary } from "./traceTypes"; +import { ignoresLetterShortcut } from "../letterShortcut"; const WIDTH_KEY = "litellm.agentTraces.drawerWidth"; const MIN_WIDTH = 700; @@ -43,10 +44,6 @@ const storeWidth = (width: number): void => { } }; -const isTypingTarget = (target: EventTarget | null): boolean => - target instanceof HTMLElement && - target.matches("input, textarea, select, [contenteditable='true'], [role='combobox']"); - function useDrawerWidth() { const [width, setWidth] = useState(() => clampDrawerWidth(readStoredWidth() ?? viewportWidth() * DEFAULT_FRACTION, viewportWidth()), @@ -165,8 +162,7 @@ export function RunDrawer({ trace, runs, accessToken, onSelect }: RunDrawerProps useEffect(() => { if (trace === null) return; const onKeyDown = (event: KeyboardEvent) => { - const modified = event.metaKey || event.ctrlKey || event.altKey; - if (modified || isTypingTarget(event.target)) return; + if (ignoresLetterShortcut(event)) return; if (event.key === "Escape") { event.preventDefault(); onSelect(null); diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.test.tsx index 50de602fa31..df3cff9f02b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.test.tsx @@ -127,6 +127,8 @@ describe("RunView", () => { const pane = await screen.findByTestId("detail-pane"); const root = rootSpanId(research); expect(pane).toHaveAttribute("data-row-id", root); + await user.keyboard("{Meta>}j{/Meta}{Control>}j{/Control}"); + expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root); await user.keyboard("j"); expect(screen.getByTestId("detail-pane").getAttribute("data-row-id")).not.toBe(root); await user.keyboard("k"); diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx index 435ea51e8d2..4fec3322cf3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx @@ -30,6 +30,7 @@ import { traceAgentNames, traceDisplayName, } from "./traceUtils"; +import { ignoresLetterShortcut } from "../letterShortcut"; /** What "Copy for agent" puts on the clipboard: a one-liner Claude Code / Codex can run. */ export const agentHandoffText = (traceId: string, spanId?: string | null, traceRef?: string): string => { @@ -212,7 +213,7 @@ function RunBody({ trace, accessToken, initialSpanId, embedded }: RunBodyProps) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if ((event.target as HTMLElement | null)?.matches("input, textarea, [role='combobox']")) return; + if (ignoresLetterShortcut(event)) return; const index = rows.findIndex((row) => row.id === selectedRow?.id); const row = rows[index]; if (event.key === "Escape" && detailOpen) { diff --git a/ui/litellm-dashboard/src/components/view_logs/letterShortcut.ts b/ui/litellm-dashboard/src/components/view_logs/letterShortcut.ts new file mode 100644 index 00000000000..0ddef471f74 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/letterShortcut.ts @@ -0,0 +1,9 @@ +const isTypingTarget = (target: EventTarget | null): boolean => + target instanceof HTMLElement && + target.matches("input, textarea, select, [contenteditable='true'], [role='combobox']"); + +const isModified = (event: KeyboardEvent): boolean => event.metaKey || event.ctrlKey || event.altKey; + +/** Single-key drawer shortcuts (J, K, Esc) yield to typing and to modified presses such as Cmd+J. */ +export const ignoresLetterShortcut = (event: KeyboardEvent): boolean => + isModified(event) || isTypingTarget(event.target);
Use a model you trust with your gateway data.
Ask about your gateway. Review changes in chat.