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:
tin-berri 2026-10-03 00:00:10 -07:00 • committed by GitHub
parent 0c86d6bfc9
commit 8e32d4568c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 251 additions and 110 deletions

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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,
);
});
});

View file

@ -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 />}

View file

@ -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" }));

View file

@ -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}>

View file

@ -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]);
});
});

View file

@ -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;
}

View file

@ -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);

View file

@ -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");

View file

@ -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) {

View file

@ -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);