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
This commit is contained in:
Yuneng Jiang 2026-10-31 15:38:19 -07:00
parent da5ea59667
commit ee9b27cd25
No known key found for this signature in database
11 changed files with 1018 additions and 285 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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(
<CommandPaletteProvider>
<CommandPaletteTrigger />
<Dialog>
<DialogTrigger>Create key</DialogTrigger>
<DialogContent>
<DialogTitle>Create key</DialogTitle>
<input aria-label="Key alias" />
</DialogContent>
</Dialog>
</CommandPaletteProvider>,
);
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<typeof useKeys>,
);
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();
});
});

View file

@ -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<HTMLInputElement | null>) {
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 <CommandPaletteDialog setOpen={setOpen} />;
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Command palette"
description="Search for a page, an action or a virtual key"
>
<CommandPaletteContent setOpen={setOpen} />
</CommandDialog>
);
}
function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void }) {
function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }) {
const pathname = usePathname();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
useActiveDescendantOnSelectedRow(inputRef);
const [query, setQuery] = useState("");
const [activeSelection, setActiveSelection] = useState<{
id: string;
query: string;
scope: PaletteScope;
} | null>(null);
const [scope, setScope] = useState<PaletteScope>(() => paletteScopeForRoute(routeSegmentForPathname(pathname)));
const [debouncedQuery] = useDebouncedValue(query, { wait: DEBOUNCE_WAIT_MS });
const trimmedQuery = query.trim();
@ -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<PaletteGroup[]>(() => {
if (hasKeyError) return [];
const pages: PaletteEntry[] = navMatches.map((page) => ({
id: `page:${page.route}`,
kind: "page",
title: page.label,
subtitle: page.section,
icon: page.icon ?? <FileText className="size-4" />,
icon: page.icon ?? <FileText />,
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: <KeyRound className="size-4" />,
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: <KeyRound />,
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 = (
<>
<span className="font-mono">{key.key_name}</span>
{details.length > 0 && <span> · {details.join(" · ")}</span>}
</>
);
return {
id: `key:${key.token}`,
kind: "key",
title: key.key_alias || "Unnamed key",
subtitle,
icon: <KeyRound className="size-4" />,
subtitle: (
<>
<span className="font-mono">{key.key_name}</span>
{details.length > 0 && <span> · {details.join(" · ")}</span>}
</>
),
icon: <KeyRound />,
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: <Search className="size-4" />,
icon: <Search />,
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<HTMLInputElement>) => {
if (event.key === "Backspace" && scope === "keys" && query === "") {
event.preventDefault();
setScope("global");
return;
}
if (event.key === "Enter") {
event.preventDefault();
const activeEntry = selectableEntries[activeIndex];
if (activeEntry) activate(activeEntry);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const leaveKeysScopeOnBackspace = (event: KeyboardEvent<HTMLDivElement>) => {
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 (
<div className="flex h-20 items-center justify-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
Searching…
</div>
);
}
if (group.entries.length === 0)
return <div className="px-2 py-3 text-sm text-muted-foreground">{group.emptyMessage}</div>;
return group.entries.map((entry, entryIndex) => {
const currentIndex = firstOptionIndex + entryIndex;
return (
<CommandPaletteResultRow
key={entry.id}
item={entry}
optionId={`command-palette-option-${currentIndex}`}
active={activeIndex === currentIndex}
onActivate={() => activate(entry)}
onHover={() => setActiveSelection({ id: entry.id, query, scope })}
/>
);
});
setScope("global");
};
return (
<Dialog open onOpenChange={setOpen}>
<DialogContent
showCloseButton={false}
overlayClassName="bg-black/35 backdrop-blur-sm"
className="top-[15vh] max-h-[70vh] translate-y-0 gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-2xl sm:max-w-[640px]"
>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<div className="flex h-14 items-center gap-3 border-b border-border px-4">
<Search className="size-4 shrink-0 text-muted-foreground" />
{scope === "keys" && (
<span className="flex shrink-0 items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
<KeyRound className="size-3.5" />
<Command
label="Search"
shouldFilter={false}
loop
value={highlightedValue}
onValueChange={(value) => setHighlight({ value, resultSet })}
onKeyDown={leaveKeysScopeOnBackspace}
>
<div className="flex items-center *:data-[slot=command-input-wrapper]:flex-1">
{scope === "keys" && (
<InputGroupAddon>
<Badge variant="secondary">
<KeyRound />
Virtual Keys
</Badge>
</InputGroupAddon>
)}
<CommandInput
ref={inputRef}
placeholder={scope === "keys" ? "Search keys by alias or ID…" : "Search pages and actions…"}
value={query}
onValueChange={setQuery}
/>
</div>
<CommandList onMouseDown={(event) => event.preventDefault()}>
{isLoadingKeys && !hasKeyError ? (
<CommandPrimitive.Loading label="Searching…" className="py-6 text-center text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2">
<LoaderCircle className="size-4 animate-spin" />
Searching…
</span>
)}
<input
autoFocus
type="text"
role="combobox"
aria-label="Search"
aria-expanded="true"
aria-controls="command-palette-results"
aria-activedescendant={activeOptionId}
aria-autocomplete="list"
className="h-full min-w-0 flex-1 border-0 bg-transparent text-[15px] outline-none placeholder:text-muted-foreground focus:ring-0"
placeholder={scope === "keys" ? "Search keys by alias or ID…" : "Search pages and actions…"}
value={query}
onChange={(event) => {
setQuery(event.currentTarget.value);
}}
onKeyDown={handleInputKeyDown}
/>
</div>
<div id="command-palette-results" role="listbox" className="max-h-[400px] overflow-y-auto p-2">
{hasKeyError && (
<div className="flex h-20 items-center justify-center text-sm text-muted-foreground">
Unable to search keys
</div>
)}
{!hasKeyError && scope === "global" && trimmedQuery && groups.length === 0 && (
<div className="px-2 py-3 text-sm text-muted-foreground">No results for “{trimmedQuery}”</div>
)}
{!hasKeyError &&
groups.map((group, groupIndex) => {
const firstOptionIndex = groups
.slice(0, groupIndex)
.reduce((count, previousGroup) => count + previousGroup.entries.length, 0);
return (
<div key={group.label} role="group" aria-label={group.label}>
<div
role="presentation"
className="px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
>
{group.label}
</div>
{renderGroupContents(group, firstOptionIndex)}
</div>
);
})}
</div>
<div className="flex h-10 items-center justify-between border-t border-border bg-muted/30 px-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">LiteLLM</span>
<span>{scope === "keys" ? "Virtual Keys" : "All pages"}</span>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Kbd>↵</Kbd> Open
</span>
<span className="flex items-center gap-1">
<Kbd>↑↓</Kbd> Navigate
</span>
<span className="flex items-center gap-1">
<Kbd>esc</Kbd> Close
</span>
</div>
</div>
</DialogContent>
</Dialog>
</CommandPrimitive.Loading>
) : (
<CommandEmpty>{emptyMessageFor(scope, trimmedQuery, hasKeyError)}</CommandEmpty>
)}
{groups.map((group) => (
<CommandGroup key={group.heading} heading={group.heading}>
{group.entries.map((entry) => (
<CommandItem key={entry.id} value={entry.id} onSelect={() => activate(entry)}>
{entry.icon}
<span className="flex min-w-0 items-baseline gap-2">
<span className="max-w-full shrink-0 truncate">{entry.title}</span>
{entry.subtitle && <span className="truncate text-xs text-muted-foreground">{entry.subtitle}</span>}
</span>
<CommandShortcut>{entry.typeLabel}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
);
}

View file

@ -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<CommandPaletteContextValue | null>(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 (
<CommandPaletteContext.Provider value={value}>

View file

@ -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 (
<button
id={optionId}
type="button"
role="option"
aria-selected={active}
tabIndex={-1}
className={cn(
"flex h-11 w-full items-center gap-3 rounded-lg px-2 text-left transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-muted/70",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={onActivate}
onMouseMove={onHover}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted">{item.icon}</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-sm font-medium">{item.title}</span>
{item.subtitle && <span className="truncate text-xs text-muted-foreground">{item.subtitle}</span>}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{item.typeLabel}</span>
</button>
);
}

View file

@ -32,18 +32,18 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean })
}
return (
<button
type="button"
className="hidden h-8 w-64 shrink-0 items-center justify-between rounded-lg border border-border bg-muted/40 px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted lg:w-80 md:flex"
<Button
variant="outline"
className="hidden w-64 justify-between md:flex lg:w-80"
aria-label={label}
aria-expanded={open}
onClick={() => setOpen(true)}
>
<span className="flex min-w-0 items-center gap-2">
<Search className="size-4 shrink-0" />
<Search />
<span className="truncate">{label}</span>
</span>
<Kbd className="ml-2 shrink-0">{shortcut}</Kbd>
</button>
<Kbd>{shortcut}</Kbd>
</Button>
);
}

View file

@ -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<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none", className)}
{...props}
/>
);
}
function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
);
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px w-auto bg-border", className)}
{...props}
/>
);
}
function CommandItem({ className, children, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:**:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View file

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