From 8e32d4568cfb07c5de0e22a319c76d03ff9ccd35 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Sat, 3 Oct 2026 00:00:10 -0700 Subject: [PATCH] feat(ui): move LiteAdmin into the header with a docked side panel (#44293) The floating bottom-right LiteAdmin button covered page controls such as the Logs pagination buttons, and Playground had to hide it entirely. Render the trigger as a pill in the header tools ahead of Docs and open LiteAdmin as a panel docked beside the content column, which narrows the page instead of covering it. Add a Cmd/Ctrl+J toggle and drop the Playground override. The Logs and trace drawers treated Cmd+J as a plain J and advanced the selection, so they now share RunDrawer's rule that letter shortcuts yield to modified presses and typing. Co-authored-by: Claude Opus 5.5 --- .../src/app/(dashboard)/layout.test.tsx | 27 +-- .../src/app/(dashboard)/layout.tsx | 26 +-- .../src/components/DashboardHeader.test.tsx | 12 +- .../src/components/DashboardHeader.tsx | 2 + .../liteadmin/LiteAdmin.integration.test.tsx | 77 ++++++++- .../src/components/liteadmin/LiteAdmin.tsx | 159 ++++++++++++------ .../useKeyboardNavigation.test.tsx | 22 +++ .../LogDetailsDrawer/useKeyboardNavigation.ts | 14 +- .../view_logs/TraceView/RunDrawer.tsx | 8 +- .../view_logs/TraceView/TraceDrawer.test.tsx | 2 + .../view_logs/TraceView/TraceDrawer.tsx | 3 +- .../components/view_logs/letterShortcut.ts | 9 + 12 files changed, 251 insertions(+), 110 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/letterShortcut.ts 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: () => , + 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: () => })); 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 ( + + ); +} + +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 ( - +
- + ); } 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}