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