mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
0c86d6bfc9
commit
8e32d4568c
12 changed files with 251 additions and 110 deletions
|
|
@ -15,7 +15,7 @@ vi.mock("next/navigation", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/liteadmin/LiteAdmin", () => ({
|
||||
default: () => <button>LiteAdmin</button>,
|
||||
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 = () => (
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<div data-testid="page-content" />
|
||||
</Layout>
|
||||
</AuthProvider>
|
||||
);
|
||||
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 = () => (
|
||||
<AuthProvider>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={toggleSidebar} />
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<DashboardHeader />
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<NoRedisWarningBanner accessToken={accessToken} />
|
||||
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<UpgradeBanner accessToken={accessToken} />
|
||||
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||
{!isPlayground && <LiteAdmin />}
|
||||
</div>
|
||||
<LiteAdminFrame>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<DashboardHeader />
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<NoRedisWarningBanner accessToken={accessToken} />
|
||||
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<UpgradeBanner accessToken={accessToken} />
|
||||
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</LiteAdminFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => <button>LiteAdmin</button> }));
|
||||
|
||||
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(<DashboardHeader />);
|
||||
|
||||
const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "LiteAdmin" });
|
||||
expect(liteAdmin.compareDocumentPosition(screen.getByRole("link", { name: "Docs" }))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<ToolbarSeparator />
|
||||
</>
|
||||
)}
|
||||
<LiteAdmin />
|
||||
<DocsLink />
|
||||
<BlogDropdown />
|
||||
{!hideCommunityLinks && <CommunityEngagementButtons />}
|
||||
|
|
|
|||
|
|
@ -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 <output>{authLoading ? "Session loading" : "Session ready"}</output>;
|
||||
}
|
||||
|
||||
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 }>) {
|
|||
<AuthProvider>
|
||||
<SessionReady />
|
||||
{Menu && <Menu onLogout={() => undefined} />}
|
||||
<LiteAdmin />
|
||||
<LiteAdminFrame>
|
||||
<Page />
|
||||
<LiteAdmin />
|
||||
</LiteAdminFrame>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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<LiteAdminSession, "inferenceBaseUrl">;
|
||||
type LiteAdminState = { open: boolean; toggle: () => void };
|
||||
|
||||
export default function LiteAdmin() {
|
||||
const LiteAdminContext = createContext<LiteAdminState | null>(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 (
|
||||
<ConfiguredLiteAdmin
|
||||
key={JSON.stringify([auth.userId, session.token, session.accessToken, session.managementBaseUrl])}
|
||||
session={session}
|
||||
/>
|
||||
<LiteAdminContext.Provider value={configured ? { open, toggle } : null}>
|
||||
{children}
|
||||
{configured && (
|
||||
<DockedPanel key={configured.key} session={configured.session} open={open} close={() => setOpen(false)} />
|
||||
)}
|
||||
</LiteAdminContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfiguredLiteAdmin({ session }: { session: ManagementSession }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
export default function LiteAdminTrigger() {
|
||||
const state = useContext(LiteAdminContext);
|
||||
if (!state) return null;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="LiteAdmin"
|
||||
aria-keyshortcuts="Meta+J Control+J"
|
||||
aria-expanded={state.open}
|
||||
onClick={state.toggle}
|
||||
className="rounded-full bg-info/10 text-info hover:bg-info/15 hover:text-info/80 aria-expanded:bg-info/15 aria-expanded:text-info"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
<span className="hidden lg:inline">LiteAdmin</span>
|
||||
<kbd className="hidden rounded-full border border-info/30 px-1.5 font-sans text-xs lg:inline">⌘J</kbd>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger render={<Button className="fixed right-5 bottom-5 z-floating rounded-full shadow-lg" />}>
|
||||
<Sparkles className="size-4" />
|
||||
LiteAdmin
|
||||
</PopoverTrigger>
|
||||
<Destination
|
||||
key={target?.baseUrl ?? "unavailable"}
|
||||
session={session}
|
||||
target={target}
|
||||
loading={settings.isPending}
|
||||
retry={() => void settings.refetch()}
|
||||
open={open}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
</Popover>
|
||||
<Destination
|
||||
key={target?.baseUrl ?? "unavailable"}
|
||||
session={session}
|
||||
target={target}
|
||||
loading={settings.isPending}
|
||||
retry={() => void settings.refetch()}
|
||||
open={open}
|
||||
close={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +132,7 @@ function Destination({
|
|||
return <LiteAdminChat session={{ ...session, inferenceBaseUrl: target.baseUrl }} open={open} close={close} />;
|
||||
}
|
||||
return (
|
||||
<PopoverContent side="top" align="end" sideOffset={12} className={PANEL_CLASS}>
|
||||
<Panel open={open}>
|
||||
<PanelHeader close={close} />
|
||||
<div className="p-4">
|
||||
{loading && <Skeleton className="h-24" aria-label="Loading gateway settings" />}
|
||||
|
|
@ -128,7 +162,41 @@ function Destination({
|
|||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const panel = ref.current;
|
||||
if (!open || !panel) return;
|
||||
(initialFocus?.current ?? panel.querySelector<HTMLElement>("textarea:enabled") ?? panel).focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}, [open, initialFocus]);
|
||||
return (
|
||||
<aside
|
||||
ref={ref}
|
||||
aria-label="LiteAdmin"
|
||||
tabIndex={-1}
|
||||
hidden={!open}
|
||||
className={cn(
|
||||
"w-[min(26rem,40vw)] min-w-80 flex-none flex-col overflow-hidden border-l bg-background outline-none",
|
||||
open && "flex",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -156,13 +224,7 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op
|
|||
setInput("");
|
||||
};
|
||||
return (
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className={PANEL_CLASS}
|
||||
initialFocus={chat.phase === "review" ? reviewRef : true}
|
||||
>
|
||||
<Panel open={open} initialFocus={chat.phase === "review" ? reviewRef : undefined}>
|
||||
<PanelHeader close={close}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -227,17 +289,20 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op
|
|||
/>
|
||||
<p className="text-xs text-muted-foreground">Use a model you trust with your gateway data.</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ close, children }: { close: () => void; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 border-b p-4">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>LiteAdmin</PopoverTitle>
|
||||
<PopoverDescription>Ask about your gateway. Review changes in chat.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<h2 className="flex items-center gap-1.5 font-medium">
|
||||
<Sparkles className="size-4 text-info" />
|
||||
LiteAdmin
|
||||
</h2>
|
||||
<p className="text-muted-foreground">Ask about your gateway. Review changes in chat.</p>
|
||||
</div>
|
||||
<div className="flex shrink-0">
|
||||
{children}
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Close LiteAdmin" onClick={close}>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Add table
Reference in a new issue