From ee9b27cd2584ae09fb630798b3e749a09bb9dd22 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 31 Oct 2026 15:38:19 -0700 Subject: [PATCH] feat(ui): rebuild the command palette on shadcn Command Replace the hand-rolled combobox in the Cmd/Ctrl+K palette with shadcn's base-vega Command and CommandDialog (cmdk) and bind the shortcut with react-hotkeys-hook. cmdk now owns arrow keys, wraparound, Home/End and IME composition, Base UI keeps the dialog mounted for its closing animation and returns focus to the trigger, and mod+k is ignored while focus is inside another dialog Results keep their fixed Keys, Actions, Pages order with shouldFilter disabled. The highlight resets to the first row whenever debounced key results load, so Enter after typing still opens the top key cmdk 1.1.1 leaves aria-activedescendant empty or stale after it picks a row on its own (dip/cmdk#413), so a MutationObserver keeps it on the row that is actually aria-selected until upstream fixes it --- .../tests/navigation/commandPalette.spec.ts | 54 ++ ui/litellm-dashboard/package-lock.json | 475 +++++++++++++++++- ui/litellm-dashboard/package.json | 1 + ui/litellm-dashboard/src/app/globals.css | 17 +- .../CommandPalette.integration.test.tsx | 159 +++++- .../CommandPalette/CommandPalette.tsx | 355 ++++++------- .../CommandPalette/CommandPaletteProvider.tsx | 24 +- .../CommandPaletteResultRow.tsx | 50 -- .../CommandPalette/CommandPaletteTrigger.tsx | 12 +- .../src/components/ui/command.tsx | 153 ++++++ ui/litellm-dashboard/tests/setupTests.ts | 3 + 11 files changed, 1018 insertions(+), 285 deletions(-) create mode 100644 tests/e2e/ui/tests/navigation/commandPalette.spec.ts delete mode 100644 ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteResultRow.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/command.tsx diff --git a/tests/e2e/ui/tests/navigation/commandPalette.spec.ts b/tests/e2e/ui/tests/navigation/commandPalette.spec.ts new file mode 100644 index 00000000000..5e80afb8b18 --- /dev/null +++ b/tests/e2e/ui/tests/navigation/commandPalette.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, type Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { Page } from "../../fixtures/pages"; +import { dismissFeedbackPopup, navigateToPage } from "../../helpers/navigation"; +import { createVirtualKey, deleteVirtualKey, uniqueSuffix } from "../../helpers/traffic"; + +const paletteSearch = (page: PlaywrightPage) => page.getByRole("combobox", { name: "Search" }); +const headerTrigger = (page: PlaywrightPage) => page.getByRole("button", { name: "Search keys…", exact: true }); + +test.describe("Command palette", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test.beforeEach(async ({ page }) => { + await navigateToPage(page, Page.ApiKeys); + await dismissFeedbackPopup(page); + await expect(headerTrigger(page)).toBeVisible(); + }); + + test("opens a key found by its alias", async ({ page }) => { + const alias = `e2e-palette-key-${uniqueSuffix()}`; + const created = await createVirtualKey(page.request, { key_alias: alias }); + try { + await page.keyboard.press("ControlOrMeta+K"); + await paletteSearch(page).fill(alias); + await expect(page.getByRole("option", { name: new RegExp(`^${alias}`) })).toBeVisible(); + await page.keyboard.press("Enter"); + + await expect(page).toHaveURL(new RegExp(`[?&]key=${created.token}`)); + await expect(paletteSearch(page)).toBeHidden(); + } finally { + await deleteVirtualKey(page.request, created.token); + } + }); + + test("does not open over the Create New Key dialog", async ({ page }) => { + await page.getByRole("button", { name: /Create New Key/i }).click(); + const keyName = page.getByLabel(/Key Name/); + await keyName.click(); + + await page.keyboard.press("ControlOrMeta+K"); + await page.keyboard.type("still-in-the-dialog"); + + await expect(keyName).toHaveValue("still-in-the-dialog"); + await expect(paletteSearch(page)).toBeHidden(); + }); + + test("opens from the header trigger and closes with Escape", async ({ page }) => { + await headerTrigger(page).click(); + await expect(paletteSearch(page)).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(paletteSearch(page)).toBeHidden(); + }); +}); diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 54cd9087c4e..d506d114d8c 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -23,6 +23,7 @@ "@types/papaparse": "5.5.2", "class-variance-authority": "0.7.1", "clsx": "^2.1.1", + "cmdk": "1.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", "i18next": "26.5.11", @@ -2656,6 +2657,323 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz", + "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", + "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz", + "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -3939,7 +4257,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4757,6 +5075,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -5308,6 +5638,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5746,6 +6092,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -6959,6 +7311,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -10769,6 +11130,53 @@ } } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-resizable-panels": { "version": "4.14.1", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.14.1.tgz", @@ -10779,6 +11187,28 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-syntax-highlighter": { "version": "15.6.6", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", @@ -12408,6 +12838,49 @@ "dev": true, "license": "MIT" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 0707496a6bc..9fc0242f040 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -40,6 +40,7 @@ "@types/papaparse": "5.5.2", "class-variance-authority": "0.7.1", "clsx": "^2.1.1", + "cmdk": "1.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", "i18next": "26.5.11", diff --git a/ui/litellm-dashboard/src/app/globals.css b/ui/litellm-dashboard/src/app/globals.css index cf04c34b18a..b80aba5b9e1 100644 --- a/ui/litellm-dashboard/src/app/globals.css +++ b/ui/litellm-dashboard/src/app/globals.css @@ -435,10 +435,12 @@ /* Same plugin, resting state: it also gives every bare input a border, a white fill, 0.5rem/0.75rem padding and 1rem text. shadcn primitives style themselves and only break where a base class omits - those, which today is `combobox-chip-input` alone (audited against every input-rendering file in - components/ui). These values restore Tailwind's own preflight, and `[data-slot]` outweighs the - plugin's zero-specificity `input:where(:not([type]))`. Delete along with the plugin. */ - [data-slot="combobox-chip-input"] { + those, which today is `combobox-chip-input` and `command-input` (audited against every + input-rendering file in components/ui). These values restore Tailwind's own preflight, and + `[data-slot]` outweighs the plugin's zero-specificity `input:where(:not([type]))`. Delete along + with the plugin. */ + [data-slot="combobox-chip-input"], + [data-slot="command-input"] { border-width: 0; background-color: transparent; padding: 0; @@ -449,7 +451,12 @@ /* Same plugin's white fill, on the hand-rolled controls outside components/ui that the audit above did not cover. Tracks --background so light mode keeps the white it already painted. Delete along with the plugin. */ - :is(input, textarea, select):not([type="checkbox"], [type="radio"], [data-slot="combobox-chip-input"]) { + :is(input, textarea, select):not( + [type="checkbox"], + [type="radio"], + [data-slot="combobox-chip-input"], + [data-slot="command-input"] + ) { background-color: var(--color-background); } diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.integration.test.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.integration.test.tsx index 8fb908e5d07..fd8f6626247 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.integration.test.tsx @@ -3,6 +3,7 @@ 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 { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { TerminologyProvider } from "@/contexts/TerminologyContext"; import { writeStorage } from "@/lib/storage"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; @@ -44,7 +45,8 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ 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 LOGS_KEY = keyFixture("key-logs-1", "logs-writer", "sk-...logW"); +const KEY_FIXTURES = [HIGH_VOLUME_KEY, PROD_KEY, STAGING_KEY, LOGS_KEY]; const UI_SETTINGS_RESPONSE = { server_root_path: "", proxy_base_url: null, @@ -227,7 +229,7 @@ describe("CommandPalette integration", () => { fireEvent.keyDown(document, { key: "k", ctrlKey: true }); const input = await screen.findByRole("combobox", { name: "Search" }); - expect(input).toHaveFocus(); + await waitFor(() => expect(input).toHaveFocus()); fireEvent.change(input, { target: { value: "stale" } }); fireEvent.keyDown(document, { key: "k", ctrlKey: true }); @@ -245,7 +247,7 @@ describe("CommandPalette integration", () => { 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); + expect(screen.getByText("Virtual Keys")).toBeVisible(); await user.type(input, "prod"); await waitFor(() => { @@ -288,7 +290,7 @@ describe("CommandPalette integration", () => { 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); + act(() => vi.advanceTimersByTime(DEBOUNCE_WAIT_MS / 2)); expect(screen.getByText("Searching…")).toBeVisible(); expect(screen.queryByRole("option", { name: /high-volume-prod/ })).not.toBeInTheDocument(); @@ -359,10 +361,11 @@ describe("CommandPalette integration", () => { 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(await screen.findByRole("option", { name: /Filter the keys table for “no-match”/ })).toBeVisible(); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.queryByRole("group", { name: "Keys" })).not.toBeInTheDocument(); expect(screen.queryByRole("group", { name: "Pages" })).not.toBeInTheDocument(); + expect(screen.queryByText(/No results for/)).not.toBeInTheDocument(); }); it("shows one global no-results message when the query has no matches", async () => { @@ -375,8 +378,7 @@ describe("CommandPalette integration", () => { 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(); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); }); it("opens the virtual-key table with the current search applied", async () => { @@ -458,6 +460,21 @@ describe("CommandPalette integration", () => { expect(await screen.findByText("Recent keys")).toBeVisible(); }); + it("keeps typing in the search box after clicking an action with the mouse", async () => { + const user = userEvent.setup(); + mocks.pathname = "/teams"; + renderPalette(); + + await user.keyboard("{Control>}k{/Control}"); + await user.click(await screen.findByRole("option", { name: /Search virtual keys/ })); + await user.keyboard("staging"); + + const input = screen.getByRole("combobox", { name: "Search" }); + expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…"); + expect(input).toHaveValue("staging"); + expect(await screen.findByRole("option", { name: /staging-agent/ })).toBeVisible(); + }); + it("switches from an empty keys scope to global search on Backspace", async () => { renderPalette(); fireEvent.keyDown(document, { key: "k", ctrlKey: true }); @@ -467,4 +484,128 @@ describe("CommandPalette integration", () => { fireEvent.keyDown(input, { key: "Backspace" }); expect(input).toHaveAttribute("placeholder", "Search pages and actions…"); }); + + it("ignores Cmd+K while another dialog has focus and still toggles the open palette closed", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + + Create key + + Create key + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Create key" })); + const aliasInput = await screen.findByRole("textbox", { name: "Key alias" }); + await user.click(aliasInput); + await user.keyboard("{Control>}k{/Control}"); + + expect(screen.queryByRole("combobox", { name: "Search" })).not.toBeInTheDocument(); + expect(aliasInput).toHaveFocus(); + + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "Create key" })).not.toBeInTheDocument(); + + await user.keyboard("{Control>}k{/Control}"); + const paletteInput = await screen.findByRole("combobox", { name: "Search" }); + await waitFor(() => expect(paletteInput).toHaveFocus()); + + await user.keyboard("{Control>}k{/Control}"); + expect(screen.queryByRole("combobox", { name: "Search" })).not.toBeInTheDocument(); + }); + + it("orders Keys, then Actions, then Pages for a query that matches all three", async () => { + const user = userEvent.setup(); + renderPalette(); + + await user.keyboard("{Control>}k{/Control}"); + await user.type(await screen.findByRole("combobox", { name: "Search" }), "logs"); + await screen.findByRole("option", { name: /logs-writer/ }); + + const rows = screen.getAllByRole("option"); + expect(rows.length).toBeGreaterThanOrEqual(3); + expect(rows[0]).toHaveTextContent("logs-writer"); + expect(rows[0]).toHaveTextContent("Key"); + expect(rows[1]).toHaveTextContent("Filter the keys table for “logs”"); + expect(rows.slice(2).every((row) => row.textContent?.endsWith("Page"))).toBe(true); + expect(screen.getByRole("option", { name: /^Logs/ })).toBeVisible(); + }); + + it("points assistive tech at the highlighted row as the palette opens, filters and moves", async () => { + const user = userEvent.setup(); + renderPalette(); + + await user.keyboard("{Control>}k{/Control}"); + const input = await screen.findByRole("combobox", { name: "Search" }); + const expectHighlighted = (name: RegExp) => { + const row = screen.getByRole("option", { name }); + expect(row).toHaveAttribute("aria-selected", "true"); + expect(input).toHaveAttribute("aria-activedescendant", row.id); + }; + + await waitFor(() => expectHighlighted(/high-volume-prod/)); + await user.type(input, "prod"); + await waitFor(() => expectHighlighted(/high-volume-prod/)); + await user.keyboard("{ArrowDown}"); + expectHighlighted(/prod-backend/); + }); + + it("moves the highlight back to the top row when the query changes after arrowing down", async () => { + const user = userEvent.setup(); + renderPalette(); + + await user.keyboard("{Control>}k{/Control}"); + const input = await screen.findByRole("combobox", { name: "Search" }); + await user.type(input, "pro"); + const prodBackend = await screen.findByRole("option", { name: /prod-backend/ }); + await user.keyboard("{ArrowDown}"); + expect(prodBackend).toHaveAttribute("aria-selected", "true"); + + await user.type(input, "d"); + await screen.findByRole("option", { name: /prod-backend/ }); + expect(screen.getByRole("option", { name: /high-volume-prod/ })).toHaveAttribute("aria-selected", "true"); + await user.keyboard("{Enter}"); + expect(mocks.push).toHaveBeenCalledWith(keyDetailHref(HIGH_VOLUME_KEY.token)); + }); + + it("stays in key search when Backspace deletes typed text", async () => { + const user = userEvent.setup(); + renderPalette(); + + await user.keyboard("{Control>}k{/Control}"); + const input = await screen.findByRole("combobox", { name: "Search" }); + await user.type(input, "pro{Backspace}"); + + expect(input).toHaveValue("pr"); + expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…"); + }); + + it("shows the key search error instead of results when keys fail to load", async () => { + vi.mocked(useKeys).mockImplementation( + () => + ({ data: undefined, isFetching: false, isError: true, isPlaceholderData: false }) as ReturnType, + ); + renderPalette(); + + await userEvent.setup().keyboard("{Control>}k{/Control}"); + + expect(await screen.findByText("Unable to search keys")).toBeVisible(); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("opens with Cmd+K on macOS and leaves Ctrl+K to the browser there", async () => { + vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"); + renderPalette(); + + expect(fireEvent.keyDown(document, { key: "k", ctrlKey: true })).toBe(true); + expect(screen.queryByRole("combobox", { name: "Search" })).not.toBeInTheDocument(); + + expect(fireEvent.keyDown(document, { key: "k", metaKey: true })).toBe(false); + expect(await screen.findByRole("combobox", { name: "Search" })).toBeVisible(); + }); }); diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.tsx index 38bb987b7c4..8b3d2a02837 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.tsx +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.tsx @@ -1,51 +1,98 @@ "use client"; -import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; +import { Command as CommandPrimitive } from "cmdk"; 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 { Kbd } from "@/components/ui/kbd"; +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandShortcut, +} from "@/components/ui/command"; +import { InputGroupAddon } from "@/components/ui/input-group"; import { useTerminology } from "@/contexts/TerminologyContext"; 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 } - ); +type PaletteEntry = { + id: string; + title: string; + subtitle?: ReactNode; + icon: ReactNode; + typeLabel: "Key" | "Page" | "Action"; +} & ( + | { kind: "key"; key: KeyResponse } + | { kind: "filter" } + | { kind: "search-keys" } + | { kind: "page"; page: PaletteNavItem } +); interface PaletteGroup { - label: string; + heading: string; entries: PaletteEntry[]; - emptyMessage?: string; - isLoading?: boolean; +} + +const emptyMessageFor = (scope: PaletteScope, query: string, hasKeyError: boolean): string => { + if (hasKeyError) return "Unable to search keys"; + if (scope === "keys" && !query) return "No recent keys"; + return `No results for “${query}”`; +}; + +// TODO(https://github.com/dip/cmdk/issues/413): remove once cmdk keeps aria-activedescendant on the selected row +function useActiveDescendantOnSelectedRow(inputRef: RefObject) { + useEffect(() => { + const input = inputRef.current; + const root = input?.closest("[cmdk-root]"); + if (!input || !root) return; + const sync = () => { + const rowId = root.querySelector('[cmdk-item][aria-selected="true"]')?.id ?? null; + if (input.getAttribute("aria-activedescendant") === rowId) return; + if (rowId) input.setAttribute("aria-activedescendant", rowId); + else input.removeAttribute("aria-activedescendant"); + }; + const observer = new MutationObserver(sync); + observer.observe(root, { + subtree: true, + childList: true, + attributeFilter: ["aria-selected", "aria-activedescendant"], + }); + sync(); + return () => observer.disconnect(); + }, [inputRef]); } export function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) { - if (!open) return null; - return ; + return ( + + + + ); } -function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void }) { +function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }) { const pathname = usePathname(); const router = useRouter(); + const inputRef = useRef(null); + useActiveDescendantOnSelectedRow(inputRef); const [query, setQuery] = useState(""); - const [activeSelection, setActiveSelection] = useState<{ - id: string; - query: string; - scope: PaletteScope; - } | null>(null); const [scope, setScope] = useState(() => paletteScopeForRoute(routeSegmentForPathname(pathname))); const [debouncedQuery] = useDebouncedValue(query, { wait: DEBOUNCE_WAIT_MS }); const trimmedQuery = query.trim(); @@ -66,6 +113,7 @@ function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void }) const hasPlaceholderKeyResults = keyResults.isPlaceholderData; const isInitialKeyLoad = keyResults.isFetching && !keyResults.data; const isLoadingKeys = scope === "keys" && (isPendingQuery || hasPlaceholderKeyResults || isInitialKeyLoad); + const hasKeyError = scope === "keys" && keyResults.isError && keyResults.data === undefined; const visibleGroups = useVisibleMenuGroups(); const term = useTerminology(); const navItems = useMemo(() => flattenNavItems(visibleGroups, term), [visibleGroups, term]); @@ -74,94 +122,70 @@ function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void }) [navItems, query, scope, trimmedQuery], ); const groups = useMemo(() => { + if (hasKeyError) return []; + const pages: PaletteEntry[] = navMatches.map((page) => ({ id: `page:${page.route}`, kind: "page", title: page.label, subtitle: page.section, - icon: page.icon ?? , + icon: page.icon ?? , typeLabel: "Page", page, })); + const pagesGroups = pages.length > 0 ? [{ heading: "Pages", entries: pages }] : []; 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: , - typeLabel: "Action" as const, - }, - ], - }, - ] - : []), - ...(pages.length > 0 ? [{ label: "Pages", entries: pages }] : []), - ]; + const showSearchKeysAction = !trimmedQuery || "search virtual keys".includes(trimmedQuery.toLowerCase()); + const searchKeysAction: PaletteEntry = { + id: "action:search-keys", + kind: "search-keys", + title: "Search virtual keys", + subtitle: "Find a key by alias or ID", + icon: , + typeLabel: "Action", + }; + return [...(showSearchKeysAction ? [{ heading: "Actions", entries: [searchKeysAction] }] : []), ...pagesGroups]; } 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 = ( - <> - {key.key_name} - {details.length > 0 && · {details.join(" · ")}} - - ); return { id: `key:${key.token}`, kind: "key", title: key.key_alias || "Unnamed key", - subtitle, - icon: , + subtitle: ( + <> + {key.key_name} + {details.length > 0 && · {details.join(" · ")}} + + ), + icon: , typeLabel: "Key", key, }; }); - const keysGroup: PaletteGroup = { - label: trimmedQuery ? "Keys" : "Recent keys", - entries: keyEntries, - emptyMessage: trimmedQuery ? `No keys match “${trimmedQuery}”` : "No recent keys", - isLoading: isLoadingKeys, - }; + const keysGroups = + keyEntries.length > 0 ? [{ heading: trimmedQuery ? "Keys" : "Recent keys", entries: keyEntries }] : []; - if (!trimmedQuery) return [keysGroup]; + if (!trimmedQuery) return keysGroups; - const filterEntry: PaletteEntry = { + const filterAction: PaletteEntry = { id: "action:filter-keys", kind: "filter", title: `Filter the keys table for “${trimmedQuery}”`, subtitle: "Open Virtual Keys with this search applied", - icon: , + icon: , typeLabel: "Action", }; + return [...keysGroups, { heading: "Actions", entries: [filterAction] }, ...pagesGroups]; + }, [hasKeyError, isLoadingKeys, keyResults.data?.keys, navMatches, scope, teamAliases, trimmedQuery]); - 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 resultSet = [scope, query, isLoadingKeys].join("\n"); + const [highlight, setHighlight] = useState<{ value: string; resultSet: string } | null>(null); + const highlightedValue = highlight?.resultSet === resultSet ? highlight.value : groups[0]?.entries[0]?.id ?? ""; const activate = (entry: PaletteEntry) => { switch (entry.kind) { @@ -184,136 +208,63 @@ function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void }) } }; - const handleInputKeyDown = (event: ReactKeyboardEvent) => { - 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; + const leaveKeysScopeOnBackspace = (event: KeyboardEvent) => { + if (event.key !== "Backspace" || scope !== "keys" || query !== "") 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 ( -
- - Searching… -
- ); - } - if (group.entries.length === 0) - return
{group.emptyMessage}
; - return group.entries.map((entry, entryIndex) => { - const currentIndex = firstOptionIndex + entryIndex; - return ( - activate(entry)} - onHover={() => setActiveSelection({ id: entry.id, query, scope })} - /> - ); - }); + setScope("global"); }; return ( - - - Command palette -
- - {scope === "keys" && ( - - + setHighlight({ value, resultSet })} + onKeyDown={leaveKeysScopeOnBackspace} + > +
+ {scope === "keys" && ( + + + Virtual Keys + + + )} + +
+ event.preventDefault()}> + {isLoadingKeys && !hasKeyError ? ( + + + + Searching… - )} - { - setQuery(event.currentTarget.value); - }} - onKeyDown={handleInputKeyDown} - /> -
-
- {hasKeyError && ( -
- Unable to search keys -
- )} - {!hasKeyError && scope === "global" && trimmedQuery && groups.length === 0 && ( -
No results for “{trimmedQuery}”
- )} - {!hasKeyError && - groups.map((group, groupIndex) => { - const firstOptionIndex = groups - .slice(0, groupIndex) - .reduce((count, previousGroup) => count + previousGroup.entries.length, 0); - return ( -
-
- {group.label} -
- {renderGroupContents(group, firstOptionIndex)} -
- ); - })} -
-
-
- LiteLLM - {scope === "keys" ? "Virtual Keys" : "All pages"} -
-
- - ↵ Open - - - ↑↓ Navigate - - - esc Close - -
-
-
-
+ + ) : ( + {emptyMessageFor(scope, trimmedQuery, hasKeyError)} + )} + {groups.map((group) => ( + + {group.entries.map((entry) => ( + activate(entry)}> + {entry.icon} + + {entry.title} + {entry.subtitle && {entry.subtitle}} + + {entry.typeLabel} + + ))} + + ))} + + ); } diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteProvider.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteProvider.tsx index b2ec2625c45..10478870773 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteProvider.tsx +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteProvider.tsx @@ -1,7 +1,8 @@ "use client"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; import { usePathname } from "next/navigation"; +import { useHotkeys } from "react-hotkeys-hook"; import { useStoredValue } from "@/lib/storage"; import { routeSegmentForPathname } from "@/utils/uiHref"; import { CommandPalette } from "./CommandPalette"; @@ -16,6 +17,9 @@ interface CommandPaletteContextValue { const CommandPaletteContext = createContext(null); +const isInsideDialog = (event: KeyboardEvent): boolean => + event.target instanceof Element && event.target.closest("[role='dialog'], [role='alertdialog']") !== null; + export function useCommandPalette(): CommandPaletteContextValue { const context = useContext(CommandPaletteContext); if (!context) throw new Error("useCommandPalette must be used within CommandPaletteProvider"); @@ -43,17 +47,13 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) { 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]); + const hotkeyOptions = { + enableOnFormTags: true, + preventDefault: true, + useKey: true, + ignoreEventWhen: (event: KeyboardEvent) => !open && isInsideDialog(event), + }; + useHotkeys("mod+k", toggle, hotkeyOptions); return ( diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteResultRow.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteResultRow.tsx deleted file mode 100644 index b4d8e2b3e74..00000000000 --- a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteResultRow.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"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 ( - - ); -} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx index 8165abdb180..c89652bb1c5 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx @@ -32,18 +32,18 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean }) } return ( - + {shortcut} + ); } diff --git a/ui/litellm-dashboard/src/components/ui/command.tsx b/ui/litellm-dashboard/src/components/ui/command.tsx new file mode 100644 index 00000000000..d33f0287aac --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +"use client"; + +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { cn } from "@/lib/cva.config"; + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"; +import { SearchIcon, CheckIcon } from "lucide-react"; + +function Command({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = false, + ...props +}: Omit, "children"> & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; + children: React.ReactNode; +}) { + return ( + + + {title} + {description} + + + {children} + + + ); +} + +function CommandInput({ className, ...props }: React.ComponentProps) { + return ( +
+ + + + + + +
+ ); +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ className, children, ...props }: React.ComponentProps) { + return ( + + {children} + + + ); +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index 2b6b4c7484c..dbceb657fd1 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -257,6 +257,9 @@ if (typeof HTMLElement !== "undefined") { }, }); } +if (typeof Element !== "undefined" && typeof Element.prototype.scrollIntoView !== "function") { + Element.prototype.scrollIntoView = () => {}; +} if (typeof document !== "undefined" && !("elementFromPoint" in document)) { Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => null }); }