feat(ui): add Cmd+K command palette with key search (#45456)

* feat(ui): add Cmd+K command palette with header search box

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(ui): refine command palette selection and results

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* feat(ui): add subtle Cmd+K discovery hint

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(ui): address command palette review findings

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ishaan-berri 2026-10-08 16:34:33 -07:00 • committed by GitHub
parent 6a2d3edce3
commit 6b8dcec64e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1364 additions and 94 deletions

View file

@ -110,13 +110,14 @@ export const useKeys = (
page: number,
pageSize: number,
options: KeyListCallOptions = {},
queryOptions: { enabled?: boolean } = {},
): UseQueryResult<KeysResponse> => {
const { accessToken } = useAuthorized();
return useQuery<KeysResponse>({
queryKey: keyKeys.list({ page, limit: pageSize, ...options }),
queryFn: async () => await keyListCall(accessToken!, page, pageSize, options),
enabled: Boolean(accessToken),
enabled: Boolean(accessToken) && (queryOptions.enabled ?? true),
staleTime: 30000, // 30 seconds
placeholderData: keepPreviousData,
});

View file

@ -118,13 +118,14 @@ export const useTeamsTable = (
};
export const teamKeys = createQueryKeys("teams");
export const useTeams = (): UseQueryResult<Team[]> => {
export const useTeams = (queryOptions: { enabled?: boolean } = {}): UseQueryResult<Team[]> => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<Team[]>({
const teamsQueryOptions = {
queryKey: teamKeys.list({}),
queryFn: async () => await fetchTeams(accessToken!, userId, userRole, null),
enabled: Boolean(accessToken),
});
enabled: Boolean(accessToken) && queryOptions.enabled !== false,
};
return useQuery<Team[]>(teamsQueryOptions);
};
const ALL_TEAMS_PAGE_SIZE = 100;

View file

@ -23,6 +23,7 @@ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/s
import { Button } from "@/components/ui/button";
import { Menu } from "lucide-react";
import { useMediaQuery } from "usehooks-ts";
import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider";
const pluginApiClient = createApiClient({ getBaseUrl: () => getProxyBaseUrl() ?? "" });
@ -168,26 +169,28 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
</SheetContent>
<LiteAdminFrame>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader
navigationTrigger={
<SheetTrigger
render={
<Button variant="ghost" size="icon" className="size-11 md:hidden" aria-label="Open navigation" />
}
>
<Menu />
</SheetTrigger>
}
/>
<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>
<CommandPaletteProvider>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader
navigationTrigger={
<SheetTrigger
render={
<Button variant="ghost" size="icon" className="size-11 md:hidden" aria-label="Open navigation" />
}
>
<Menu />
</SheetTrigger>
}
/>
<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>
</CommandPaletteProvider>
</LiteAdminFrame>
</div>
</Sheet>

View file

@ -0,0 +1,442 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import type { KeyResponse } from "@/components/key_team_helpers/key_list";
import { writeStorage } from "@/lib/storage";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { keyDetailHref } from "@/utils/entityLinks";
import { uiHref } from "@/utils/uiHref";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import { COMMAND_PALETTE_HINT_KEY } from "./CommandPaletteHint";
import { CommandPaletteProvider } from "./CommandPaletteProvider";
import { CommandPaletteTrigger } from "./CommandPaletteTrigger";
const mocks = vi.hoisted(() => ({
pathname: "/api-keys",
push: vi.fn<(href: string) => void>(),
useKeys: vi.fn(),
teamAlias: "platform-team",
isPlaceholderData: false,
}));
vi.mock("next/navigation", () => ({
usePathname: () => mocks.pathname,
useRouter: () => ({ push: mocks.push }),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: mocks.useKeys,
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => ({
data: [{ team_id: "team-1", team_alias: mocks.teamAlias, members_with_roles: [] }],
}),
}));
vi.mock("@/app/(dashboard)/hooks/useIsOrgAdmin", () => ({
default: () => false,
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => ({ data: { values: {} } }),
}));
const HIGH_VOLUME_KEY = keyFixture("key-prod-0", "high-volume-prod", "sk-...4zoA", "team-1");
const PROD_KEY = keyFixture("key-prod-1", "prod-backend", "sk-...AHeA", "team-1");
const STAGING_KEY = keyFixture("key-staging-1", "staging-agent", "sk-...stgB", "unknown-team");
const KEY_FIXTURES = [HIGH_VOLUME_KEY, PROD_KEY, STAGING_KEY];
const UI_SETTINGS_RESPONSE = {
server_root_path: "",
proxy_base_url: null,
admin_ui_disabled: false,
auto_redirect_to_sso: false,
sso_configured: false,
is_control_plane: false,
workers: [],
};
const PROD_KEY_LIST_OPTIONS = { search: "prod", sortBy: "created_at", sortOrder: "desc", expand: "user" };
const cachedKeyResults = new Map<string, KeyResponse[]>();
function keyFixture(token: string, alias: string, keyName: string, teamId: string | null = null): KeyResponse {
return {
token,
token_id: token,
key_name: keyName,
key_alias: alias,
spend: 2.5,
team_id: teamId,
team_alias: "",
} as KeyResponse;
}
function sessionCookie() {
const encode = (value: object) =>
btoa(JSON.stringify(value)).replaceAll("=", "").replaceAll("+", "-").replaceAll("/", "_");
const claims = {
key: "sk-session-test",
user_id: "test-admin",
user_role: "proxy_admin",
premium_user: true,
auth_header_name: "X-Gateway-Session",
exp: Date.now() / 1000 + 3600,
};
document.cookie = `token=${encode({ alg: "none" })}.${encode(claims)}.test; Path=/`;
}
function renderPalette() {
return renderWithProviders(
<CommandPaletteProvider>
<CommandPaletteTrigger />
</CommandPaletteProvider>,
);
}
beforeEach(() => {
testQueryClient.clear();
window.localStorage.clear();
cachedKeyResults.clear();
vi.clearAllMocks();
mocks.pathname = "/api-keys";
mocks.teamAlias = "platform-team";
mocks.isPlaceholderData = false;
sessionCookie();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) =>
String(input).includes("/v2/team/list")
? Response.json({
teams: [{ team_id: "team-1", team_alias: "platform-team" }],
total_pages: 1,
})
: Response.json(UI_SETTINGS_RESPONSE),
),
);
vi.mocked(useKeys).mockImplementation((_page, _pageSize, options) => {
const query = options.search?.toLowerCase() ?? "";
const keys =
cachedKeyResults.get(query) ??
KEY_FIXTURES.filter(
(key) => !query || key.key_alias.toLowerCase().includes(query) || key.key_name.toLowerCase().includes(query),
);
cachedKeyResults.set(query, keys);
return {
data: { keys, total_count: keys.length, current_page: 1, total_pages: 1 },
isFetching: false,
isError: false,
isPlaceholderData: mocks.isPlaceholderData,
} as ReturnType<typeof useKeys>;
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
testQueryClient.clear();
vi.unstubAllGlobals();
document.cookie = "token=; Max-Age=0; Path=/";
});
describe("CommandPalette integration", () => {
it("shows the discovery hint on the keys route", async () => {
renderPalette();
expect(await screen.findByText("to search keys")).toBeVisible();
});
it("opens the palette from the hint and persists that it was seen", async () => {
const user = userEvent.setup();
renderPalette();
await user.click(screen.getByRole("button", { name: /to search keys/ }));
expect(await screen.findByRole("combobox", { name: "Search" })).toBeVisible();
expect(screen.queryByText("to search keys")).not.toBeInTheDocument();
expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBe("true");
});
it("hides and persists the discovery hint when Ctrl+K opens the palette", async () => {
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
expect(await screen.findByRole("combobox", { name: "Search" })).toBeVisible();
expect(screen.queryByText("to search keys")).not.toBeInTheDocument();
expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBe("true");
});
it("dismisses the discovery hint without opening the palette", async () => {
const user = userEvent.setup();
renderPalette();
await user.click(screen.getByRole("button", { name: "Dismiss search hint" }));
expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument();
expect(screen.queryByText("to search keys")).not.toBeInTheDocument();
expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBe("true");
});
it("hides the discovery hint in memory when storage cannot persist its dismissal", async () => {
const user = userEvent.setup();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("Storage unavailable");
});
renderPalette();
await user.click(await screen.findByRole("button", { name: "Dismiss search hint" }));
expect(screen.queryByText("to search keys")).not.toBeInTheDocument();
expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBeNull();
});
it("keeps the discovery hint hidden after closing the palette when storage is unavailable", async () => {
const user = userEvent.setup();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("Storage unavailable");
});
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
expect(screen.queryByText("to search keys")).not.toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByText("to search keys")).not.toBeInTheDocument();
expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBeNull();
});
it("does not show the discovery hint when it has already been seen", () => {
writeStorage(COMMAND_PALETTE_HINT_KEY, true);
renderPalette();
expect(screen.queryByText("to search keys")).not.toBeInTheDocument();
});
it("shows global-search copy outside the keys routes", async () => {
mocks.pathname = "/logs";
renderPalette();
expect(await screen.findByText("to search or jump to a page")).toBeVisible();
});
it("opens and focuses with Ctrl+K, rejects extra modifiers, and toggles closed", async () => {
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true, shiftKey: true });
fireEvent.keyDown(document, { key: "k", ctrlKey: true, altKey: true });
expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
expect(input).toHaveFocus();
fireEvent.change(input, { target: { value: "stale" } });
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const reopenedInput = await screen.findByRole("combobox", { name: "Search" });
expect(reopenedInput).toHaveValue("");
});
it("searches keys, shows key details, and opens the selected key", async () => {
const user = userEvent.setup();
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…");
expect(screen.getAllByText("Virtual Keys")).toHaveLength(2);
await user.type(input, "prod");
await waitFor(() => {
expect(vi.mocked(useKeys)).toHaveBeenLastCalledWith(1, 8, expect.objectContaining(PROD_KEY_LIST_OPTIONS), {
enabled: true,
});
});
const firstKeyOption = await screen.findByRole("option", { name: /high-volume-prod/ });
const keyOption = await screen.findByRole("option", { name: /prod-backend/ });
expect(firstKeyOption).toHaveAttribute("aria-selected", "true");
expect(keyOption).toHaveTextContent("platform-team");
expect(keyOption).toHaveTextContent("$2.50");
expect(screen.queryByRole("option", { name: /staging-agent/ })).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(keyOption).toHaveAttribute("aria-selected", "true");
fireEvent.keyDown(input, { key: "Enter" });
expect(mocks.push).toHaveBeenCalledWith(keyDetailHref(PROD_KEY.token));
expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument();
});
it("opens the first matching key when Enter is pressed without navigating", async () => {
const user = userEvent.setup();
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
await user.type(input, "prod");
const firstKeyOption = await screen.findByRole("option", { name: /high-volume-prod/ });
expect(firstKeyOption).toHaveAttribute("aria-selected", "true");
fireEvent.keyDown(input, { key: "Enter" });
expect(mocks.push).toHaveBeenCalledWith(keyDetailHref(HIGH_VOLUME_KEY.token));
});
it("does not activate stale key results while the new query is debouncing", () => {
vi.useFakeTimers();
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = screen.getByRole("combobox", { name: "Search" });
fireEvent.change(input, { target: { value: "prod" } });
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS / 2);
expect(screen.getByText("Searching…")).toBeVisible();
expect(screen.queryByRole("option", { name: /high-volume-prod/ })).not.toBeInTheDocument();
expect(screen.getByRole("option", { name: /Filter the keys table/ })).toBeVisible();
fireEvent.keyDown(input, { key: "Enter" });
expect(mocks.push).toHaveBeenCalledWith(`${uiHref("api-keys")}?key_search=prod`);
expect(mocks.push).not.toHaveBeenCalledWith(keyDetailHref(HIGH_VOLUME_KEY.token));
expect(mocks.push).not.toHaveBeenCalledWith(keyDetailHref(PROD_KEY.token));
});
it("does not show placeholder key rows after the query debounce completes", () => {
vi.useFakeTimers();
mocks.isPlaceholderData = true;
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = screen.getByRole("combobox", { name: "Search" });
fireEvent.change(input, { target: { value: "prod" } });
act(() => {
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS);
});
expect(screen.getByText("Searching…")).toBeVisible();
expect(screen.queryByRole("option", { name: /high-volume-prod/ })).not.toBeInTheDocument();
});
it("keeps the same key selected when its subtitle changes", async () => {
const { rerender } = renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
const secondKeyOption = await screen.findByRole("option", { name: /prod-backend/ });
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(secondKeyOption).toHaveAttribute("aria-selected", "true");
mocks.teamAlias = "renamed-platform-team";
rerender(
<CommandPaletteProvider>
<CommandPaletteTrigger />
</CommandPaletteProvider>,
);
const updatedSecondKeyOption = await screen.findByRole("option", { name: /prod-backend/ });
expect(updatedSecondKeyOption).toHaveTextContent("renamed-platform-team");
expect(updatedSecondKeyOption).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("option", { name: /high-volume-prod/ })).toHaveAttribute("aria-selected", "false");
});
it("omits an unknown team ID from the key subtitle", async () => {
const user = userEvent.setup();
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
await user.type(input, "staging");
const stagingOption = await screen.findByRole("option", { name: /staging-agent/ });
expect(stagingOption).not.toHaveTextContent("unknown-team");
});
it("keeps only non-empty groups and the filter action when no keys match", async () => {
const user = userEvent.setup();
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
await user.type(input, "no-match");
expect(await screen.findByText("No keys match “no-match”")).toBeVisible();
expect(screen.getByRole("group", { name: "Keys" })).toHaveTextContent("No keys match “no-match”");
expect(screen.getByRole("option", { name: /Filter the keys table/ })).toBeVisible();
expect(screen.queryByRole("group", { name: "Pages" })).not.toBeInTheDocument();
});
it("shows one global no-results message when the query has no matches", async () => {
const user = userEvent.setup();
mocks.pathname = "/teams";
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
await user.type(input, "no-match");
expect(await screen.findByText("No results for “no-match”")).toBeVisible();
expect(screen.queryByRole("group")).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: /Search virtual keys/ })).not.toBeInTheDocument();
});
it("opens the virtual-key table with the current search applied", async () => {
const user = userEvent.setup();
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
await user.type(input, "prod");
await waitFor(() => {
expect(vi.mocked(useKeys)).toHaveBeenLastCalledWith(1, 8, expect.objectContaining({ search: "prod" }), {
enabled: true,
});
});
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(mocks.push).toHaveBeenCalledWith(`${uiHref("api-keys")}?key_search=prod`);
});
it("opens a matching global page from the teams route", async () => {
const user = userEvent.setup();
mocks.pathname = "/teams";
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
expect(input).toHaveAttribute("placeholder", "Search pages and actions…");
await user.type(input, "logs");
const logsOption = await screen.findByRole("option", { name: /^Logs/ });
expect(logsOption).toBeVisible();
fireEvent.keyDown(input, { key: "Enter" });
expect(mocks.push).toHaveBeenCalledWith(uiHref("logs"));
});
it("clears the global query when switching to key search", async () => {
const user = userEvent.setup();
mocks.pathname = "/teams";
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
await user.type(input, "keys");
expect(await screen.findByRole("option", { name: /Search virtual keys/ })).toBeVisible();
fireEvent.keyDown(input, { key: "Enter" });
const keysInput = await screen.findByRole("combobox", { name: "Search" });
expect(keysInput).toHaveValue("");
expect(await screen.findByText("Recent keys")).toBeVisible();
});
it("switches from an empty keys scope to global search on Backspace", async () => {
renderPalette();
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
const input = await screen.findByRole("combobox", { name: "Search" });
expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…");
fireEvent.keyDown(input, { key: "Backspace" });
expect(input).toHaveAttribute("placeholder", "Search pages and actions…");
});
});

View file

@ -0,0 +1,319 @@
"use client";
import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { FileText, KeyRound, LoaderCircle, Search } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { keyDetailHref } from "@/utils/entityLinks";
import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
import type { KeyResponse } from "@/components/key_team_helpers/key_list";
import { CommandPaletteResultRow, type CommandPaletteRowData } from "./CommandPaletteResultRow";
import { useVisibleMenuGroups } from "./useVisibleMenuGroups";
import { flattenNavItems, matchNavItems, paletteScopeForRoute, type PaletteNavItem, type PaletteScope } from "./utils";
type PaletteEntry = CommandPaletteRowData &
(
| { kind: "key"; key: KeyResponse }
| { kind: "filter" }
| { kind: "search-keys" }
| { kind: "page"; page: PaletteNavItem }
);
interface PaletteGroup {
label: string;
entries: PaletteEntry[];
emptyMessage?: string;
isLoading?: boolean;
}
const shortcutChipClass =
"rounded-sm border border-border border-b-2 bg-muted px-[3px] font-mono text-muted-foreground";
export function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
if (!open) return null;
return <CommandPaletteDialog setOpen={setOpen} />;
}
function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const pathname = usePathname();
const router = useRouter();
const [query, setQuery] = useState("");
const [activeSelection, setActiveSelection] = useState<{
id: string;
query: string;
scope: PaletteScope;
} | null>(null);
const [scope, setScope] = useState<PaletteScope>(() => paletteScopeForRoute(routeSegmentForPathname(pathname)));
const [debouncedQuery] = useDebouncedValue(query, { wait: DEBOUNCE_WAIT_MS });
const trimmedQuery = query.trim();
const debouncedSearch = debouncedQuery.trim();
const { data: teams } = useTeams({ enabled: false });
const teamAliases = useMemo(
() => new Map((teams ?? []).map((team) => [team.team_id, team.team_alias] as const)),
[teams],
);
const keyListOptions = {
search: debouncedSearch || undefined,
sortBy: "created_at",
sortOrder: "desc",
expand: "user",
};
const keyResults = useKeys(1, 8, keyListOptions, { enabled: scope === "keys" });
const isPendingQuery = trimmedQuery !== debouncedSearch;
const hasPlaceholderKeyResults = keyResults.isPlaceholderData;
const isInitialKeyLoad = keyResults.isFetching && !keyResults.data;
const isLoadingKeys = scope === "keys" && (isPendingQuery || hasPlaceholderKeyResults || isInitialKeyLoad);
const visibleGroups = useVisibleMenuGroups();
const navItems = useMemo(() => flattenNavItems(visibleGroups), [visibleGroups]);
const navMatches = useMemo(
() => (scope === "global" || trimmedQuery ? matchNavItems(navItems, query) : []),
[navItems, query, scope, trimmedQuery],
);
const groups = useMemo<PaletteGroup[]>(() => {
const pages: PaletteEntry[] = navMatches.map((page) => ({
id: `page:${page.route}`,
kind: "page",
title: page.label,
subtitle: page.section,
icon: page.icon ?? <FileText className="size-4" />,
typeLabel: "Page",
page,
}));
if (scope === "global") {
const searchKeysAction = !trimmedQuery || "search virtual keys".includes(trimmedQuery.toLowerCase());
return [
...(searchKeysAction
? [
{
label: "Actions",
entries: [
{
id: "action:search-keys",
kind: "search-keys" as const,
title: "Search virtual keys",
subtitle: "Find a key by alias or ID",
icon: <KeyRound className="size-4" />,
typeLabel: "Action" as const,
},
],
},
]
: []),
...(pages.length > 0 ? [{ label: "Pages", entries: pages }] : []),
];
}
const keyEntries: PaletteEntry[] = (isLoadingKeys ? [] : keyResults.data?.keys ?? []).map((key) => {
const teamAlias = key.team_alias || (key.team_id ? teamAliases.get(key.team_id) : undefined);
const spend = typeof key.spend === "number" && Number.isFinite(key.spend) ? `$${key.spend.toFixed(2)}` : null;
const details = [teamAlias, spend].filter((detail): detail is string => Boolean(detail));
const subtitle: ReactNode = (
<>
<span className="font-mono">{key.key_name}</span>
{details.length > 0 && <span> · {details.join(" · ")}</span>}
</>
);
return {
id: `key:${key.token}`,
kind: "key",
title: key.key_alias || "Unnamed key",
subtitle,
icon: <KeyRound className="size-4" />,
typeLabel: "Key",
key,
};
});
const keysGroup: PaletteGroup = {
label: trimmedQuery ? "Keys" : "Recent keys",
entries: keyEntries,
emptyMessage: trimmedQuery ? `No keys match “${trimmedQuery}”` : "No recent keys",
isLoading: isLoadingKeys,
};
if (!trimmedQuery) return [keysGroup];
const filterEntry: PaletteEntry = {
id: "action:filter-keys",
kind: "filter",
title: `Filter the keys table for “${trimmedQuery}”`,
subtitle: "Open Virtual Keys with this search applied",
icon: <Search className="size-4" />,
typeLabel: "Action",
};
return [
keysGroup,
{ label: "Actions", entries: [filterEntry] },
...(pages.length > 0 ? [{ label: "Pages", entries: pages }] : []),
];
}, [isLoadingKeys, keyResults.data?.keys, navMatches, scope, teamAliases, trimmedQuery]);
const selectableEntries = useMemo(() => groups.flatMap((group) => group.entries), [groups]);
const activeEntryId = activeSelection?.query === query && activeSelection.scope === scope ? activeSelection.id : null;
const selectedIndex = selectableEntries.findIndex((entry) => entry.id === activeEntryId);
const activeIndex = selectableEntries.length === 0 ? -1 : Math.max(0, selectedIndex);
const activeOptionId = activeIndex >= 0 ? `command-palette-option-${activeIndex}` : undefined;
const hasKeyError = scope === "keys" && keyResults.isError && keyResults.data === undefined;
useEffect(() => {
if (activeOptionId) document.getElementById(activeOptionId)?.scrollIntoView?.({ block: "nearest" });
}, [activeOptionId]);
const activate = (entry: PaletteEntry) => {
switch (entry.kind) {
case "key":
router.push(keyDetailHref(entry.key.token));
setOpen(false);
return;
case "filter":
router.push(`${uiHref("api-keys")}?key_search=${encodeURIComponent(trimmedQuery)}`);
setOpen(false);
return;
case "search-keys":
setQuery("");
setScope("keys");
return;
case "page":
router.push(uiHref(entry.page.route));
setOpen(false);
return;
}
};
const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Backspace" && scope === "keys" && query === "") {
event.preventDefault();
setScope("global");
return;
}
if (event.key === "Enter") {
event.preventDefault();
const activeEntry = selectableEntries[activeIndex];
if (activeEntry) activate(activeEntry);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
if (selectableEntries.length === 0) return;
const currentIndex = activeIndex < 0 ? 0 : activeIndex;
const nextIndex =
event.key === "ArrowDown"
? (currentIndex + 1) % selectableEntries.length
: (currentIndex - 1 + selectableEntries.length) % selectableEntries.length;
setActiveSelection({ id: selectableEntries[nextIndex].id, query, scope });
};
const renderGroupContents = (group: PaletteGroup, firstOptionIndex: number): ReactNode => {
if (group.isLoading) {
return (
<div className="flex h-20 items-center justify-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
Searching…
</div>
);
}
if (group.entries.length === 0)
return <div className="px-2 py-3 text-sm text-muted-foreground">{group.emptyMessage}</div>;
return group.entries.map((entry, entryIndex) => {
const currentIndex = firstOptionIndex + entryIndex;
return (
<CommandPaletteResultRow
key={entry.id}
item={entry}
optionId={`command-palette-option-${currentIndex}`}
active={activeIndex === currentIndex}
onActivate={() => activate(entry)}
onHover={() => setActiveSelection({ id: entry.id, query, scope })}
/>
);
});
};
return (
<Dialog open onOpenChange={setOpen}>
<DialogContent
showCloseButton={false}
overlayClassName="bg-black/35 backdrop-blur-sm"
className="top-[15vh] max-h-[70vh] translate-y-0 gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-2xl sm:max-w-[640px]"
>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<div className="flex h-14 items-center gap-3 border-b border-border px-4">
<Search className="size-4 shrink-0 text-muted-foreground" />
{scope === "keys" && (
<span className="flex shrink-0 items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
<KeyRound className="size-3.5" />
Virtual Keys
</span>
)}
<input
autoFocus
type="text"
role="combobox"
aria-label="Search"
aria-expanded="true"
aria-controls="command-palette-results"
aria-activedescendant={activeOptionId}
aria-autocomplete="list"
className="h-full min-w-0 flex-1 border-0 bg-transparent text-[15px] outline-none placeholder:text-muted-foreground focus:ring-0"
placeholder={scope === "keys" ? "Search keys by alias or ID…" : "Search pages and actions…"}
value={query}
onChange={(event) => {
setQuery(event.currentTarget.value);
}}
onKeyDown={handleInputKeyDown}
/>
</div>
<div id="command-palette-results" role="listbox" className="max-h-[400px] overflow-y-auto p-2">
{hasKeyError && (
<div className="flex h-20 items-center justify-center text-sm text-muted-foreground">
Unable to search keys
</div>
)}
{!hasKeyError && scope === "global" && trimmedQuery && groups.length === 0 && (
<div className="px-2 py-3 text-sm text-muted-foreground">No results for “{trimmedQuery}”</div>
)}
{!hasKeyError &&
groups.map((group, groupIndex) => {
const firstOptionIndex = groups
.slice(0, groupIndex)
.reduce((count, previousGroup) => count + previousGroup.entries.length, 0);
return (
<div key={group.label} role="group" aria-label={group.label}>
<div
role="presentation"
className="px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
>
{group.label}
</div>
{renderGroupContents(group, firstOptionIndex)}
</div>
);
})}
</div>
<div className="flex h-10 items-center justify-between border-t border-border bg-muted/30 px-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">LiteLLM</span>
<span>{scope === "keys" ? "Virtual Keys" : "All pages"}</span>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className={shortcutChipClass}>↵</kbd> Open
</span>
<span className="flex items-center gap-1">
<kbd className={shortcutChipClass}>↑↓</kbd> Navigate
</span>
<span className="flex items-center gap-1">
<kbd className={shortcutChipClass}>esc</kbd> Close
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { Search, X } from "lucide-react";
import { z } from "zod";
import { storageKey } from "@/lib/storage";
import type { PaletteScope } from "./utils";
import { useCommandPalette } from "./CommandPaletteProvider";
import { useClientMounted, useShortcutLabel } from "./useShortcutLabel";
export const COMMAND_PALETTE_HINT_KEY = storageKey("local", "litellmCommandPaletteHintSeen", z.boolean(), false);
interface CommandPaletteHintProps {
scope: PaletteScope;
onDismiss: () => void;
}
export function CommandPaletteHint({ scope, onDismiss }: CommandPaletteHintProps) {
const { setOpen } = useCommandPalette();
const shortcut = useShortcutLabel();
const mounted = useClientMounted();
if (!mounted) return null;
return (
<div className="fixed bottom-4 left-1/2 z-overlay hidden -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur animate-in fade-in slide-in-from-bottom-2 duration-300 md:flex">
<button type="button" className="flex items-center gap-1.5 hover:text-foreground" onClick={() => setOpen(true)}>
<Search className="size-3.5" aria-hidden="true" />
<span>Press</span>
<kbd className="rounded-sm border border-border border-b-2 bg-muted px-[3px] font-mono text-xs text-muted-foreground">
{shortcut}
</kbd>
<span>{scope === "keys" ? "to search keys" : "to search or jump to a page"}</span>
</button>
<button
type="button"
className="ml-0.5 flex size-5 items-center justify-center rounded-full hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Dismiss search hint"
onClick={onDismiss}
>
<X className="size-3" aria-hidden="true" />
</button>
</div>
);
}

View file

@ -0,0 +1,65 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { usePathname } from "next/navigation";
import { useStoredValue } from "@/lib/storage";
import { routeSegmentForPathname } from "@/utils/uiHref";
import { CommandPalette } from "./CommandPalette";
import { CommandPaletteHint, COMMAND_PALETTE_HINT_KEY } from "./CommandPaletteHint";
import { paletteScopeForRoute } from "./utils";
interface CommandPaletteContextValue {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
}
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null);
export function useCommandPalette(): CommandPaletteContextValue {
const context = useContext(CommandPaletteContext);
if (!context) throw new Error("useCommandPalette must be used within CommandPaletteProvider");
return context;
}
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const [open, setOpenState] = useState(false);
const [hintSeen, setHintSeen] = useStoredValue(COMMAND_PALETTE_HINT_KEY);
const [hintDismissedInMemory, setHintDismissedInMemory] = useState(false);
const pathname = usePathname();
const scope = paletteScopeForRoute(routeSegmentForPathname(pathname));
const markHintSeen = useCallback(() => {
setHintDismissedInMemory(true);
setHintSeen(true);
}, [setHintSeen]);
const setOpen = useCallback(
(nextOpen: boolean) => {
if (nextOpen) markHintSeen();
setOpenState(nextOpen);
},
[markHintSeen],
);
const toggle = useCallback(() => setOpen(!open), [open, setOpen]);
const value = useMemo(() => ({ open, setOpen, toggle }), [open, setOpen, toggle]);
const shouldShowHint = !hintSeen && !hintDismissedInMemory && !open;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && !event.shiftKey && !event.altKey) {
event.preventDefault();
toggle();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [toggle]);
return (
<CommandPaletteContext.Provider value={value}>
{children}
<CommandPalette open={open} setOpen={setOpen} />
{shouldShowHint && <CommandPaletteHint scope={scope} onDismiss={markHintSeen} />}
</CommandPaletteContext.Provider>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/cva.config";
export interface CommandPaletteRowData {
id: string;
title: string;
subtitle?: ReactNode;
icon: ReactNode;
typeLabel: "Key" | "Page" | "Action";
}
export function CommandPaletteResultRow({
item,
optionId,
active,
onActivate,
onHover,
}: {
item: CommandPaletteRowData;
optionId: string;
active: boolean;
onActivate: () => void;
onHover: () => void;
}) {
return (
<button
id={optionId}
type="button"
role="option"
aria-selected={active}
tabIndex={-1}
className={cn(
"flex h-11 w-full items-center gap-3 rounded-lg px-2 text-left transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-muted/70",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={onActivate}
onMouseMove={onHover}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted">{item.icon}</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-sm font-medium">{item.title}</span>
{item.subtitle && <span className="truncate text-xs text-muted-foreground">{item.subtitle}</span>}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{item.typeLabel}</span>
</button>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { Search } from "lucide-react";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import { routeSegmentForPathname } from "@/utils/uiHref";
import { paletteScopeForRoute } from "./utils";
import { useCommandPalette } from "./CommandPaletteProvider";
import { useShortcutLabel } from "./useShortcutLabel";
export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean }) {
const pathname = usePathname();
const { open, setOpen } = useCommandPalette();
const shortcut = useShortcutLabel();
const isKeysRoute = paletteScopeForRoute(routeSegmentForPathname(pathname)) === "keys";
const label = isKeysRoute ? "Search keys…" : "Search or jump to…";
if (mobile) {
return (
<Button
variant="ghost"
size="icon"
className="size-11 md:hidden"
aria-label="Search"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<Search />
</Button>
);
}
return (
<button
type="button"
className="hidden h-8 w-64 shrink-0 items-center justify-between rounded-lg border border-border bg-muted/40 px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted lg:w-80 md:flex"
aria-label={label}
aria-expanded={open}
onClick={() => setOpen(true)}
>
<span className="flex min-w-0 items-center gap-2">
<Search className="size-4 shrink-0" />
<span className="truncate">{label}</span>
</span>
<kbd className="ml-2 shrink-0 rounded-sm border border-border border-b-2 bg-muted px-[3px] font-mono text-xs text-muted-foreground">
{shortcut}
</kbd>
</button>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { useSyncExternalStore } from "react";
import { shortcutLabel } from "./utils";
const subscribeToShortcut = () => () => {};
const getClientShortcut = () =>
typeof navigator === "undefined" ? "Ctrl K" : shortcutLabel(navigator.platform, navigator.userAgent);
const getServerShortcut = () => "Ctrl K";
const subscribeToMount = () => () => {};
const getClientMounted = () => true;
const getServerMounted = () => false;
export function useShortcutLabel(): string {
return useSyncExternalStore(subscribeToShortcut, getClientShortcut, getServerShortcut);
}
export function useClientMounted(): boolean {
return useSyncExternalStore(subscribeToMount, getClientMounted, getServerMounted);
}

View file

@ -0,0 +1,34 @@
"use client";
import { useMemo } from "react";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { menuGroups, visibleMenuGroups, type MenuVisibilityContext } from "@/components/leftnav";
export const useVisibleMenuGroups = () => {
const { userId, userRole, isViewOnly } = useAuthorized();
const isOrgAdmin = useIsOrgAdmin();
const { data: teams } = useTeams({ enabled: false });
const { data: settings } = useUISettings();
const values = settings?.values;
const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]);
return useMemo(() => {
const context: MenuVisibilityContext = {
userRole,
isViewOnly,
isOrgAdmin,
isTeamAdmin,
enabledPagesInternalUsers: values?.enabled_ui_pages_internal_users ?? null,
enableProjectsUI: Boolean(values?.enable_projects_ui),
disableAgentsForInternalUsers: Boolean(values?.disable_agents_for_internal_users),
allowAgentsForTeamAdmins: Boolean(values?.allow_agents_for_team_admins),
disableVectorStoresForInternalUsers: Boolean(values?.disable_vector_stores_for_internal_users),
allowVectorStoresForTeamAdmins: Boolean(values?.allow_vector_stores_for_team_admins),
};
return visibleMenuGroups(menuGroups, context);
}, [isOrgAdmin, isTeamAdmin, isViewOnly, userRole, values]);
};

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { visibleMenuGroups, type MenuGroup, menuGroups } from "@/components/leftnav";
import { flattenNavItems, matchNavItems, paletteScopeForRoute, shortcutLabel, type PaletteNavItem } from "./utils";
describe("paletteScopeForRoute", () => {
it.each([
["api-keys", "keys"],
["", "keys"],
["teams", "global"],
] as const)("uses the expected palette scope for %s", (routeSegment, expected) => {
expect(paletteScopeForRoute(routeSegment)).toBe(expected);
});
});
describe("flattenNavItems", () => {
it("flattens visible leaves and skips external links", () => {
const groups: MenuGroup[] = [
{
groupLabel: "Tools",
items: [
{ key: "search-tools", page: "search-tools", label: "Search Tools" },
{ key: "docs", page: "docs", label: "Docs", external_url: "https://example.com" },
{ key: "tools", page: "tools", label: "Tools", children: [{ key: "logs", page: "logs", label: "Logs" }] },
],
},
];
expect(flattenNavItems(groups).map(({ label }) => label)).toEqual(["Search Tools", "Logs"]);
});
});
describe("visibleMenuGroups", () => {
const context = {
userRole: "Internal User",
isViewOnly: false,
isOrgAdmin: false,
isTeamAdmin: false,
};
it("applies the internal-user page allowlist", () => {
const visible = visibleMenuGroups(menuGroups, {
...context,
enabledPagesInternalUsers: ["api-keys"],
});
expect(flattenNavItems(visible).map(({ route }) => route)).toEqual(["api-keys"]);
});
it("hides Projects when its UI setting is disabled", () => {
const projectAdminContext = { ...context, userRole: "Admin", isTeamAdmin: true };
const visibleWithFlag = visibleMenuGroups(menuGroups, { ...projectAdminContext, enableProjectsUI: true });
const visibleWithoutFlag = visibleMenuGroups(menuGroups, { ...projectAdminContext, enableProjectsUI: false });
expect(flattenNavItems(visibleWithFlag).some(({ route }) => route === "projects")).toBe(true);
expect(flattenNavItems(visibleWithoutFlag).some(({ route }) => route === "projects")).toBe(false);
});
it("keeps admin pages visible despite the internal-user page allowlist", () => {
const visible = visibleMenuGroups(menuGroups, {
...context,
userRole: "Admin",
enabledPagesInternalUsers: ["api-keys"],
});
expect(flattenNavItems(visible).some(({ route }) => route === "users")).toBe(true);
expect(flattenNavItems(visible).some(({ route }) => route === "organizations")).toBe(true);
});
});
describe("matchNavItems", () => {
const items: PaletteNavItem[] = [
{ key: "section", label: "Teams", section: "Logs", route: "teams" },
{ key: "substring", label: "Audit Logs", section: "Access Control", route: "audit-logs" },
{ key: "prefix", label: "Logging", section: "Observability", route: "logging" },
];
it("ranks label prefixes above label substrings and section matches", () => {
expect(matchNavItems(items, "log").map(({ key }) => key)).toEqual(["prefix", "substring", "section"]);
});
it("returns every item in its original order for an empty query", () => {
expect(matchNavItems(items, " ")).toEqual(items);
});
});
describe("shortcutLabel", () => {
it("uses the command key on Mac and Ctrl elsewhere", () => {
expect(shortcutLabel("MacIntel", "Mozilla/5.0")).toBe("⌘K");
expect(shortcutLabel("Linux x86_64", "Mozilla/5.0 (Windows NT 10.0)")).toBe("Ctrl K");
});
});

View file

@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { labelText, routeOf, sectionText, type MenuGroup } from "@/components/leftnav";
export type PaletteScope = "keys" | "global";
export interface PaletteNavItem {
key: string;
label: string;
section: string;
route: string;
icon?: ReactNode;
}
export const paletteScopeForRoute = (routeSegment: string): PaletteScope =>
routeSegment === "" || routeSegment === "api-keys" ? "keys" : "global";
const matchRank = (item: PaletteNavItem, query: string): number => {
const label = item.label.toLowerCase();
if (label.startsWith(query)) return 0;
if (label.includes(query)) return 1;
if (item.section.toLowerCase().includes(query)) return 2;
return -1;
};
export const flattenNavItems = (groups: readonly MenuGroup[]): PaletteNavItem[] =>
groups.flatMap((group) => {
const flattenItems = (items: MenuGroup["items"]): PaletteNavItem[] =>
items.flatMap((item) => {
if (item.external_url) return [];
if (item.children) return flattenItems(item.children);
return [
{
key: item.key,
label: labelText(item),
section: sectionText(group.groupLabel),
route: routeOf(item),
icon: item.icon,
},
];
});
return flattenItems(group.items);
});
export const matchNavItems = (items: readonly PaletteNavItem[], query: string): PaletteNavItem[] => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return [...items];
return items
.map((item, index) => {
return { item, index, rank: matchRank(item, normalizedQuery) };
})
.filter(({ rank }) => rank >= 0)
.sort((left, right) => left.rank - right.rank || left.index - right.index)
.map(({ item }) => item);
};
export const shortcutLabel = (platform: string, userAgent: string): string =>
[platform, userAgent].some((value) => value.toLowerCase().includes("mac")) ? "⌘K" : "Ctrl K";

View file

@ -3,6 +3,7 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginModeProvider } from "@/contexts/PluginModeContext";
import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider";
import { DashboardHeader } from "./DashboardHeader";
vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" }));
@ -57,7 +58,9 @@ async function openTools() {
render(
<QueryClientProvider client={client}>
<PluginModeProvider accessToken="test-session">
<DashboardHeader />
<CommandPaletteProvider>
<DashboardHeader />
</CommandPaletteProvider>
</PluginModeProvider>
</QueryClientProvider>,
);

View file

@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { DashboardHeader } from "./DashboardHeader";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider";
const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
const state = {
@ -31,31 +32,43 @@ vi.mock("@/components/Navbar/NotificationsBell/NotificationsBell", () => ({ Noti
vi.mock("@/components/Navbar/WorkerDropdown/WorkerDropdown", () => ({ default: () => null }));
vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () => <button>LiteAdmin</button> }));
const renderDashboardHeader = () =>
render(
<CommandPaletteProvider>
<DashboardHeader />
</CommandPaletteProvider>,
);
describe("DashboardHeader breadcrumb", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
state.plugins = [];
state.enableChatUI = false;
state.pathname = "/ui/logs";
state.isDesktop = false;
localStorage.clear();
});
it("titles the breadcrumb from the current route, not from a sidebar page id", () => {
state.pathname = "/ui/models-and-endpoints";
render(<DashboardHeader />);
renderDashboardHeader();
expect(screen.getByText("Models + Endpoints")).toBeInTheDocument();
});
it("titles the dashboard root as Virtual Keys", () => {
state.pathname = "/ui/";
render(<DashboardHeader />);
renderDashboardHeader();
expect(screen.getByText("Virtual Keys")).toBeInTheDocument();
});
it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => {
state.enableChatUI = true;
render(<DashboardHeader />);
renderDashboardHeader();
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
@ -68,7 +81,7 @@ describe("DashboardHeader breadcrumb", () => {
});
it("keeps the AI Gateway selector at the root even when there is nothing to switch to (discovery)", () => {
render(<DashboardHeader />);
renderDashboardHeader();
expect(screen.getByRole("button", { name: /AI Gateway/i })).toBeInTheDocument();
expect(screen.getByText("Logs")).toBeInTheDocument();
@ -76,7 +89,7 @@ describe("DashboardHeader breadcrumb", () => {
});
it("styles Docs with the shared product-link class instead of a muted toolbar button", () => {
render(<DashboardHeader />);
renderDashboardHeader();
const docs = screen.getByRole("link", { name: "Docs" });
for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) {
@ -86,7 +99,7 @@ describe("DashboardHeader breadcrumb", () => {
});
it("renders the tools divider centered rather than stretched to the top of the row", () => {
const { container } = render(<DashboardHeader />);
const { container } = renderDashboardHeader();
const separators = container.querySelectorAll('[data-slot="separator"][data-orientation="vertical"]');
expect(separators).toHaveLength(1);
@ -95,7 +108,7 @@ describe("DashboardHeader breadcrumb", () => {
});
it("places LiteAdmin in the header tools ahead of Docs", () => {
render(<DashboardHeader />);
renderDashboardHeader();
const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "LiteAdmin" });
expect(liteAdmin.compareDocumentPosition(screen.getByRole("link", { name: "Docs" }))).toBe(
@ -104,7 +117,7 @@ describe("DashboardHeader breadcrumb", () => {
});
it("keeps the gateway selector and tools available from the compact header menu", async () => {
render(<DashboardHeader />);
renderDashboardHeader();
fireEvent.click(screen.getByRole("button", { name: "More options" }));
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
expect(within(tools).getByRole("button", { name: "AI Gateway" })).toBeInTheDocument();
@ -113,16 +126,24 @@ describe("DashboardHeader breadcrumb", () => {
});
it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => {
const { rerender } = render(<DashboardHeader />);
const { rerender } = renderDashboardHeader();
fireEvent.click(screen.getByRole("button", { name: "More options" }));
expect(await screen.findByRole("dialog", { name: "Gateway tools" })).toBeInTheDocument();
state.isDesktop = true;
rerender(<DashboardHeader />);
rerender(
<CommandPaletteProvider>
<DashboardHeader />
</CommandPaletteProvider>,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
state.isDesktop = false;
rerender(<DashboardHeader />);
rerender(
<CommandPaletteProvider>
<DashboardHeader />
</CommandPaletteProvider>,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
});
});

View file

@ -27,6 +27,7 @@ import { useMediaQuery } from "usehooks-ts";
import { Ellipsis } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPaletteTrigger";
// Top bar for the dashboard shell. Sits only over the content column (the brand
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
@ -62,6 +63,8 @@ export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: Rea
</BreadcrumbList>
</Breadcrumb>
<CommandPaletteTrigger />
<div className="flex flex-none items-center gap-1 max-md:hidden">
{showWorkerSwitch && (
<>
@ -78,6 +81,7 @@ export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: Rea
<NotificationsBell />
</div>
<div className="flex shrink-0 items-center gap-1 md:hidden">
<CommandPaletteTrigger mobile />
<NotificationsBell />
<Popover open={!isDesktop && mobileToolsOpen} onOpenChange={setMobileToolsOpen}>
<PopoverTrigger render={<Button variant="ghost" size="icon" className="size-11" aria-label="More options" />}>

View file

@ -103,7 +103,7 @@ interface SidebarProps {
allowVectorStoresForTeamAdmins?: boolean;
}
interface MenuItem {
export interface MenuItem {
key: string;
page: string;
route?: string;
@ -114,12 +114,92 @@ interface MenuItem {
external_url?: string;
}
interface MenuGroup {
export interface MenuGroup {
groupLabel: string;
items: MenuItem[];
roles?: string[];
}
export interface MenuVisibilityContext {
userRole: string;
isViewOnly: boolean;
isOrgAdmin: boolean;
isTeamAdmin: boolean;
enabledPagesInternalUsers?: string[] | null;
enableProjectsUI?: boolean;
disableAgentsForInternalUsers?: boolean;
allowAgentsForTeamAdmins?: boolean;
disableVectorStoresForInternalUsers?: boolean;
allowVectorStoresForTeamAdmins?: boolean;
}
const adminPageVisibility = (item: MenuItem, context: MenuVisibilityContext, isAdmin: boolean): boolean | null => {
if (item.key !== "organizations" && item.key !== "users") return null;
const hasRoleAccess = !item.roles || item.roles.includes(context.userRole) || context.isOrgAdmin;
if (!hasRoleAccess) return false;
if (!isAdmin && context.enabledPagesInternalUsers != null) {
return context.enabledPagesInternalUsers.includes(item.page);
}
return true;
};
const projectPageIsVisible = (item: MenuItem, context: MenuVisibilityContext): boolean => {
if (item.key !== "projects") return true;
if (!context.enableProjectsUI) return false;
return canViewProjectsPage({
userRole: context.userRole,
isOrgAdmin: context.isOrgAdmin,
isTeamAdmin: context.isTeamAdmin,
});
};
const pageIsDisabledForInternalUsers = (
item: MenuItem,
pageKey: "agents" | "vector-stores",
isAdmin: boolean,
context: MenuVisibilityContext,
): boolean => {
const isDisabled =
pageKey === "agents" ? context.disableAgentsForInternalUsers : context.disableVectorStoresForInternalUsers;
const allowedForTeamAdmins =
pageKey === "agents" ? context.allowAgentsForTeamAdmins : context.allowVectorStoresForTeamAdmins;
if (item.key !== pageKey || isAdmin || !isDisabled) return false;
return !(allowedForTeamAdmins && context.isTeamAdmin);
};
const pageIsEnabledForInternalUser = (item: MenuItem, context: MenuVisibilityContext): boolean => {
const enabledPages = context.enabledPagesInternalUsers;
if (enabledPages == null) return true;
if (item.children?.some((child) => enabledPages.includes(child.page))) return true;
return enabledPages.includes(item.page);
};
const menuItemIsVisible = (item: MenuItem, context: MenuVisibilityContext, isAdmin: boolean): boolean => {
if (item.children && item.children.length === 0) return false;
if (item.key === "llm-playground" && context.isViewOnly) return false;
const adminVisibility = adminPageVisibility(item, context, isAdmin);
if (adminVisibility !== null) return adminVisibility;
if (!projectPageIsVisible(item, context)) return false;
if (pageIsDisabledForInternalUsers(item, "agents", isAdmin, context)) return false;
if (pageIsDisabledForInternalUsers(item, "vector-stores", isAdmin, context)) return false;
if (item.roles && !item.roles.includes(context.userRole)) return false;
if (!isAdmin && context.enabledPagesInternalUsers != null) return pageIsEnabledForInternalUser(item, context);
return true;
};
export const visibleMenuGroups = (groups: readonly MenuGroup[], context: MenuVisibilityContext): MenuGroup[] => {
const isAdmin = isAdminRole(context.userRole);
const filterItems = (items: MenuItem[]): MenuItem[] =>
items
.map((item) => ({ ...item, children: item.children ? filterItems(item.children) : undefined }))
.filter((item) => menuItemIsVisible(item, context, isAdmin));
return groups
.filter((group) => !group.roles || group.roles.includes(context.userRole))
.map((group) => ({ groupLabel: group.groupLabel, items: filterItems(group.items) }))
.filter((group) => group.items.length > 0);
};
// Menu groups organized by category - defined outside component for export.
// Shape (key/page/label/roles/children) is consumed by page_utils.ts; only the
// icons changed to lucide as part of the sidebar redesign.
@ -390,7 +470,7 @@ const menuGroups: MenuGroup[] = [
const HOME_ROUTE = "api-keys";
const routeOf = (item: MenuItem): string => item.route ?? item.page;
export const routeOf = (item: MenuItem): string => item.route ?? item.page;
const routeForPathname = (pathname: string): string => routeSegmentForPathname(pathname) || HOME_ROUTE;
@ -422,6 +502,8 @@ const SECTION_DISPLAY: Record<string, string> = {
SETTINGS: "Settings",
};
export const sectionText = (groupLabel: string): string => SECTION_DISPLAY[groupLabel] ?? groupLabel;
const prettify = (key: string): string =>
key
.split(/[-_]/)
@ -435,7 +517,7 @@ export const getBreadcrumb = (pathname: string): { section: string | null; title
const route = routeForPathname(pathname);
for (const group of menuGroups) {
for (const item of group.items) {
const section = SECTION_DISPLAY[group.groupLabel] ?? group.groupLabel;
const section = sectionText(group.groupLabel);
if (routeOf(item) === route) return { section, title: labelText(item) };
const child = item.children?.find((c) => routeOf(c) === route);
if (child) return { section, title: labelText(child) };
@ -486,56 +568,32 @@ const Sidebar_: React.FC<SidebarProps> = ({
const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]);
const filterItemsByRole = (items: MenuItem[]): MenuItem[] => {
const isAdmin = isAdminRole(userRole);
return items
.map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined }))
.filter((item) => {
// A parent whose children were all filtered out renders as a leaf link
// to its own page id, which is not a real route. Drop it instead.
if (item.children && item.children.length === 0) return false;
if (item.key === "llm-playground" && isViewOnly) return false;
if (item.key === "organizations" || item.key === "users") {
const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin;
if (!hasRoleAccess) return false;
if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page);
return true;
}
if (
item.key === "projects" &&
!(enableProjectsUI && canViewProjectsPage({ userRole, isOrgAdmin, isTeamAdmin }))
)
return false;
if (
!isAdmin &&
item.key === "agents" &&
disableAgentsForInternalUsers &&
!(allowAgentsForTeamAdmins && isTeamAdmin)
)
return false;
if (
!isAdmin &&
item.key === "vector-stores" &&
disableVectorStoresForInternalUsers &&
!(allowVectorStoresForTeamAdmins && isTeamAdmin)
)
return false;
if (item.roles && !item.roles.includes(userRole)) return false;
if (!isAdmin && enabledPagesInternalUsers != null) {
if (item.children && item.children.length > 0) {
const hasVisibleChildren = item.children.some((child) => enabledPagesInternalUsers.includes(child.page));
if (hasVisibleChildren) return true;
}
return enabledPagesInternalUsers.includes(item.page);
}
return true;
});
};
const visibleGroups = menuGroups
.filter((group) => !group.roles || group.roles.includes(userRole))
.map((group) => ({ groupLabel: group.groupLabel, items: filterItemsByRole(group.items) }))
.filter((group) => group.items.length > 0);
const visibleGroups = useMemo(() => {
const context: MenuVisibilityContext = {
userRole,
isViewOnly,
isOrgAdmin,
isTeamAdmin,
enabledPagesInternalUsers,
enableProjectsUI,
disableAgentsForInternalUsers,
allowAgentsForTeamAdmins,
disableVectorStoresForInternalUsers,
allowVectorStoresForTeamAdmins,
};
return visibleMenuGroups(menuGroups, context);
}, [
allowAgentsForTeamAdmins,
allowVectorStoresForTeamAdmins,
disableAgentsForInternalUsers,
disableVectorStoresForInternalUsers,
enableProjectsUI,
enabledPagesInternalUsers,
isOrgAdmin,
isTeamAdmin,
isViewOnly,
userRole,
]);
const toggleGroup = (key: string) => {
if (collapsed) {

View file

@ -28,7 +28,10 @@ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props)
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-popup bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 isolate z-popup bg-black/10 duration-100",
!(typeof className === "string" && className.includes("backdrop-blur-")) &&
"supports-backdrop-filter:backdrop-blur-xs",
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
@ -38,15 +41,17 @@ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props)
function DialogContent({
className,
overlayClassName,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
overlayClassName?: string;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogOverlay className={overlayClassName} forceRender={Boolean(overlayClassName)} />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(