mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
6a2d3edce3
commit
6b8dcec64e
18 changed files with 1364 additions and 94 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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…");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
};
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
59
ui/litellm-dashboard/src/components/CommandPalette/utils.ts
Normal file
59
ui/litellm-dashboard/src/components/CommandPalette/utils.ts
Normal 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";
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />}>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue