Skip to content

feat: add ⌘K / Ctrl+K command palette to admin UI (#1010) - #1094

Merged
helpcodeai merged 5 commits into
HelpCode-ai:mainfrom
AYUshJaiswal454:feat/command-palette
Oct 17, 2026
Merged

helpcodeai merged 5 commits into
HelpCode-ai:mainfrom
AYUshJaiswal454:feat/command-palette

Conversation

@AYUshJaiswal454

@AYUshJaiswal454 AYUshJaiswal454 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a Command Palette (⌘K / Ctrl+K) to the admin UI with grouped navigation and actions, addressing PR review feedback and fixing mobile header layout.

Changes

  • Fix phone layout regression where mobile search button with inline shortcut text caused header title truncation in mobile.spec.ts
  • Implement CommandPalette component on @radix-ui/react-dialog with 5 grouped sections (Pages, Settings, Connectors, MCP servers, Actions)
  • Guard against non-array orgs responses with Array.isArray to prevent runtime crashes
  • Move result list into CommandPaletteContent rendered lazily inside Dialog.Content
  • Restore keyboard focus to opening trigger element on close via onCloseAutoFocus and preventDefault()
  • Reset search query to empty every time palette opens
  • Implement stale-while-revalidate caching: show cached connectors/servers immediately and revalidate in background on open
  • Never cache failed connector or MCP server requests as empty arrays; only cache on complete success
  • Clear command palette cache on logout, organization switch, and session replacement/invalidation
  • Platform-specific keyboard shortcut detection: Meta+K on macOS, Ctrl+K elsewhere, ignoring shortcuts when Shift or Alt is pressed, with e.code === 'KeyK' fallback for non-Latin keyboard layouts
  • Shared settings and navigation visibility rules centralized in lib/navigation.ts (removed unused isSettingsSectionVisible helper)
  • Reused shared isMacPlatform() helper in AppShell and updated desktop header button accessible label to Search (${shortcutLabel}) for WCAG 2.5.3 (label in name)
  • Accessible listbox hierarchy with role="group", aria-labelledby, and external loading status
  • Export Connector and McpServer interfaces in lib/api.ts
  • Ignore Enter during IME composition

Type

  • Bug fix
  • New feature
  • Documentation
  • Refactoring
  • Performance improvement
  • Other (describe below)

Testing

Automated test suites run and verified locally:

  • npm run test:e2e: Passed (193/193 tests passed across 28 spec files in 2.4m)
  • npx playwright test tests/e2e/mobile.spec.ts: Passed (20/20 tests passed)
  • npx playwright test tests/e2e/command-palette.spec.ts: Passed (23/23 tests passed)
  • npx tsc --noEmit -p tsconfig.json: Passed (0 errors)
  • npm run lint: Passed (0 errors, 26 pre-existing warnings in unrelated files)
  • npm run build: Passed (Next.js production build succeeded)

Keyboard-Only Walkthrough

The following keyboard-only workflow is verified via automated Playwright E2E testing (command-palette.spec.ts):

  1. Open the palette: Press ⌘K (macOS) / Ctrl+K (other platforms) anywhere in the application, or Tab to the header Search button and press Enter / Space. The palette dialog opens with the search input automatically focused.
  2. Type a query: Type search characters (e.g., audit, stripe, dark). The list filters instantly across Pages, Settings, Connectors, MCP servers, and Actions.
  3. Navigate with arrow keys: Press ArrowDown and ArrowUp to cycle through items. The active item updates visually and updates aria-activedescendant on the combobox.
  4. Press Enter: Press Enter on the selected item to trigger navigation or execute an action (e.g., toggles theme, navigates to /logs, switches organization, logs out).
  5. Press Escape: Press Escape at any time to dismiss the palette dialog.
  6. Focus restoration: On close (Escape, overlay click, or execution), keyboard focus is restored back to the opener element (e.g., the header search button or the previously focused input) using onCloseAutoFocus with preventDefault().

(Note: Automated Playwright accessibility/ARIA attributes and keyboard flow tests passed; no manual VoiceOver or screen-reader testing was physically performed).

Checklist

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation (if applicable)
  • All existing tests pass (npm test)
  • This PR has a descriptive title

Related Issues

Closes #1010

- Extract shared navigation items and settings sections into packages/frontend/src/lib/navigation.ts
- Import shared navigation definitions in app-sidebar.tsx, settings/layout.tsx, and command-palette.tsx
- Implement CommandPalette component on @radix-ui/react-dialog with 5 grouped sections: Pages, Settings, Connectors, MCP servers, and Actions
- Support case-insensitive matching across query words, labels, keywords, and descriptions with maximum 8 results per group
- Cache connectors and MCP servers on first open and invalidate across organization switches
- Enforce role-based access: admin-only settings only for ADMIN, hide "Add connector" for VIEWER, and hide SSO in Cloud
- Implement full WAI-ARIA combobox accessibility (role="combobox", role="listbox", role="option", aria-controls, aria-activedescendant, aria-selected)
- Add visible header trigger showing ⌘K on macOS and Ctrl K elsewhere
- Add comprehensive Playwright e2e test suite covering keyboard shortcuts, navigation, permissions, and accessibility

Closes HelpCode-ai#1010

@helpcodeai helpcodeai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, the shape is right and lib/navigation.ts is now the single source the sidebar and settings layout read from, which was the main thing I wanted. Role and Cloud filtering match the sidebar and there's no new dependency. Two things block it though.

  1. The Frontend e2e job didn't just time out, it's a real regression. command-palette.tsx:219 throws (orgs || []).filter is not a function and takes the whole page down into error.tsx. The groupedResults memo at :122 runs on every AppShell render even with the palette closed, and a lot of the existing specs (billing-awareness, card-trial, footer-links, enterprise-offer, first-success-offer...) stub unknown routes with json({}), so orgs ends up as an object. About 25 tests fail plus retries, hence the 15 min timeout. With Array.isArray(orgs) ? orgs : [] the full suite passes for me locally (181/181), but please also avoid building the result list at all while the palette is closed. Moving the list body into a child component rendered inside Dialog.Content would do that, and it fixes the query reset below for free.

  2. Focus doesn't come back after closing. I focused the filter input on /connectors, pressed Ctrl+K, then Esc, and document.activeElement is <body>. Same when it's opened from the header button. Radix's modal content sends focus to the Dialog.Trigger on close, and there isn't one here. Save document.activeElement when it opens and refocus it in onCloseAutoFocus (with preventDefault()). The issue asks for this explicitly, so a test for it would be good too.

Smaller things I'd like fixed in this PR as well:

The reset in handleOpenChange (:108-114) never runs on open, because the shortcut and the header button call setPaletteOpen directly. Close with Esc, reopen, and the old query is still there.

Every page renders its own <AppShell>, so the palette remounts on each navigation and refetches connectors and servers on the first open of every page. A small module-level cache keyed by organizationId would keep them for the session like the issue says. Workspace switches already do a full reload, so you don't need to worry about clearing it there.

On macOS :64 also grabs Ctrl+K, which is "delete to end of line" in Mac text fields. Please only use metaKey on Mac and ctrlKey elsewhere. Ignoring the combo when shift or alt is held wouldn't hurt either.

The adminOnly/selfHostedOnly check is now written twice, in settings/layout.tsx:46-47 and command-palette.tsx:141-145. Could you move it into a helper in navigation.ts that both call? Then the rules can't drift either, which was the whole point of the file. The palette also skips section level adminOnly, which a shared helper would cover.

Optional, but nice: the listbox currently holds plain divs for group headers and the loading row. Use role="group" with aria-labelledby for the groups and put the loading row outside the listbox. Use the connector/server types from lib/api instead of any[]. The SETTINGS_SECTIONS alias at navigation.ts:84 isn't used anywhere. The NAV import in app-sidebar.tsx:42 should go up with the other imports. And skip Enter while isComposing is true.

Last thing: please fill in the PR template, including the commands you ran and their results, plus the keyboard-only walkthrough and the screen reader check from the acceptance criteria.

…i#1094)

- Guard against non-array orgs responses with Array.isArray in sidebar and command palette
- Lazily render command palette results in CommandPaletteContent inside Dialog.Content
- Restore keyboard focus to opening trigger element on close via onCloseAutoFocus
- Reset search query to empty on palette open
- Cache connectors and MCP servers in module-level cache keyed by organizationId
- Add platform-specific shortcut detection (Meta+K on Mac, Ctrl+K elsewhere) and reject Shift/Alt
- Centralize shared settings visibility filtering in lib/navigation.ts
- Correct listbox ARIA hierarchy with role='group', aria-labelledby, and external status
- Export Connector and McpServer interfaces in lib/api.ts
- Ignore Enter during IME composition and clean up unused SETTINGS_SECTIONS and import ordering
- Add comprehensive regression test suite in command-palette.spec.ts

@helpcodeai helpcodeai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the thorough follow-up, this is a big step forward! I re-ran everything locally on 14e8dc2: the full Playwright suite (189/189), tsc --noEmit and eslint src are all green, and points 1, 2, 3, 5, 6 and the optional items from the last review look great (focus restoration even works with a plain mouse click and an overlay close).

Two small things left, both in the new per-org cache:

  1. Failures get cached. In fetchOrgRecords (command-palette.tsx:53-58), a rejected request becomes [] and goes into recordsCache, so a single transient 500/timeout hides all connectors/servers until a full page reload. Could you only store the result when both requests succeed (and not cache on .catch)?
  2. The cache is never refreshed. If I open the palette, then create a connector, it never shows up in the palette (and a deleted one stays listed and leads to a 404). clearCommandPaletteCache is exported but not called anywhere. The simplest fix is stale-while-revalidate: render the cached list immediately, then refetch in the background each time the palette opens and update it. Please also clear it on logout/org switch, since logout() navigates in-app and the module cache survives into the next session in the same tab.

Nits, feel free to take or leave: the desktop header button shows "Search..." but its aria-label is "Open command palette" (WCAG 2.5.3 label-in-name; something like "Search (Ctrl K)" or dropping the aria-label would fix it); e.code === 'KeyK' as a fallback would make the shortcut work on non-Latin keyboard layouts; app-shell.tsx could reuse isMacPlatform(); and isSettingsSectionVisible in lib/navigation.ts is unused.

Finally, could you add a short keyboard-only walkthrough to the PR description (open → type → arrows → Enter → Esc → where focus lands)? A screen-reader pass is welcome if you can do one, but not a blocker. With the two cache fixes we're happy to merge. Thanks again!

…eviewer feedback (HelpCode-ai#1094)

- Update fetchOrgRecords to never cache failed requests as empty arrays
- Implement stale-while-revalidate caching with immediate render and background revalidation
- Clear command palette cache on logout and organization switch
- Set header search button aria-label to Search (${shortcutLabel}) for WCAG 2.5.3 compliance
- Reuse shared isMacPlatform() helper in AppShell
- Add e.code === 'KeyK' fallback for non-Latin keyboard layouts
- Remove unused isSettingsSectionVisible helper in navigation.ts
- Add regression tests for failed requests, stale-while-revalidate refresh, and logout/org-switch cache clearing

@helpcodeai helpcodeai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the quick and thorough follow-up, @AYUshJaiswal454! I re-reviewed 7c88480 and everything we asked for is in:

  • Failed requests are no longer cached: fetchOrgRecords only writes to the cache when both lists come back as arrays, and a failed refetch keeps the previous list on screen.
  • Stale-while-revalidate works: the cached list renders immediately and a background refetch on every open picks up new connectors. I checked this with the response delayed by 3s.
  • Moving the cache into lib/command-palette-cache.ts and clearing it from AuthProvider (logout, rejected session, switchOrg, replaceSession) is a clean fix. Logging out and signing back in within the same tab no longer shows the previous session's records.
  • Nits done: accessible name now contains the visible "Search", KeyK fallback, shared isMacPlatform(), and the unused helper is gone. And thanks for the keyboard-only walkthrough.

Locally: full frontend e2e suite 211/211, tsc clean, eslint 0 errors (same 26 warnings as main).

Two small, non-blocking follow-ups if you feel like it (fine as a separate PR):

  1. A request still in flight at logout can resolve after clearCommandPaletteCache() and write its result back into recordsCache, so the next session in the same tab and org briefly sees the old list until the refetch replaces it. A generation counter in the cache module (increment it in clearCommandPaletteCache, and only set if the counter hasn't changed since the request started) would close that.
  2. The "clears command palette cache on logout action" test only checks the redirect to /login, so it would still pass without the clear. Asserting that an old connector does not show after signing back in (with the connectors route left unanswered) would make it a real regression test. getCachedOrgRecords is also exported but unused.

Approving. Thanks again for the careful work on this!

@helpcodeai
helpcodeai merged commit f2a5e13 into HelpCode-ai:main Oct 17, 2026
14 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🎃 Add a ⌘K / Ctrl+K command palette to the admin UI

2 participants