From 00b127d7ca3e15cae0475cfc758213e85a17f8a6 Mon Sep 17 00:00:00 2001
From: Brad Groux <3053586+BradGroux@users.noreply.github.com>
Date: Wed, 2 Sep 2026 18:43:48 -0500
Subject: [PATCH] feat: improve command palette navigation (#1318)
---
.../command-dialogs-mantine.test.tsx | 78 ++++-
web/src/components/layout/CommandPalette.tsx | 273 +++++++++++++-----
2 files changed, 280 insertions(+), 71 deletions(-)
diff --git a/web/src/__tests__/command-dialogs-mantine.test.tsx b/web/src/__tests__/command-dialogs-mantine.test.tsx
index 92983484..07b892ec 100644
--- a/web/src/__tests__/command-dialogs-mantine.test.tsx
+++ b/web/src/__tests__/command-dialogs-mantine.test.tsx
@@ -1,5 +1,5 @@
-import { afterEach, describe, expect, it } from 'vitest';
-import { cleanup, fireEvent, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
import { ViewProvider } from '@/contexts/ViewContext';
import { KeyboardProvider } from '@/hooks/useKeyboard';
@@ -35,6 +35,80 @@ describe('command and shortcut dialogs Mantine migration', () => {
expect(baseElement.querySelector('[data-slot="dialog-title"]')).toBeNull();
});
+ it('filters commands and exposes disabled reasons without a pointer tooltip', async () => {
+ renderCommandSurface();
+ fireEvent.keyDown(window, { key: 'k', metaKey: true });
+
+ const input = await screen.findByRole('textbox', { name: 'Search commands' });
+ fireEvent.change(input, { target: { value: 'restart local' } });
+
+ const disabledCommand = screen.getByRole('button', { name: /Restart Local Server/ });
+ expect(disabledCommand.getAttribute('aria-disabled')).toBe('true');
+ expect(disabledCommand.getAttribute('title')).toBeNull();
+ expect(disabledCommand.getAttribute('aria-describedby')).toBe(
+ 'command-disabled-restart-local-server'
+ );
+ expect(
+ screen.getByText('The desktop bridge does not expose server restart from the web app yet.')
+ ).toBeDefined();
+
+ disabledCommand.focus();
+ expect(document.activeElement).toBe(disabledCommand);
+ });
+
+ it('skips unavailable commands and Enter runs the visibly selected command', async () => {
+ const onDiagnostics = vi.fn();
+ window.addEventListener('veritas:open-diagnostics', onDiagnostics, { once: true });
+ renderCommandSurface();
+ fireEvent.keyDown(window, { key: 'k', metaKey: true });
+
+ const input = await screen.findByRole('textbox', { name: 'Search commands' });
+ fireEvent.change(input, { target: { value: 'diagnostics' } });
+
+ const selected = screen.getByRole('button', { name: 'Open Logs and Diagnostics' });
+ expect(selected.getAttribute('data-selected')).toBe('true');
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ expect(selected.getAttribute('data-selected')).toBe('true');
+
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await waitFor(() => expect(onDiagnostics).toHaveBeenCalledOnce());
+ });
+
+ it('focuses search, restores trigger focus, and provides viewport scroll cues', async () => {
+ renderCommandSurface(
+ <>
+
+
+ >
+ );
+ const trigger = screen.getByRole('button', { name: 'Palette trigger' });
+ trigger.focus();
+ fireEvent.keyDown(window, { key: 'k', metaKey: true });
+
+ const input = await screen.findByRole('textbox', { name: 'Search commands' });
+ await waitFor(() => expect(document.activeElement).toBe(input));
+ expect(screen.getByTestId('command-palette-surface').className).toContain('100dvh');
+
+ const viewport = screen.getByLabelText('Available commands');
+ Object.defineProperties(viewport, {
+ clientHeight: { configurable: true, value: 300 },
+ scrollHeight: { configurable: true, value: 900 },
+ scrollTop: { configurable: true, writable: true, value: 0 },
+ });
+ fireEvent.scroll(viewport);
+ expect(screen.getByText('More commands below')).toBeDefined();
+
+ viewport.scrollTop = 600;
+ fireEvent.scroll(viewport);
+ expect(screen.getByText('More commands above')).toBeDefined();
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog', { name: 'Command palette' })).toBeNull()
+ );
+ await waitFor(() => expect(document.activeElement).toBe(trigger));
+ });
+
it('opens keyboard shortcuts through direct Mantine modal and key badges', async () => {
const { baseElement } = renderCommandSurface();
diff --git a/web/src/components/layout/CommandPalette.tsx b/web/src/components/layout/CommandPalette.tsx
index ca16b0fa..c2c7011d 100644
--- a/web/src/components/layout/CommandPalette.tsx
+++ b/web/src/components/layout/CommandPalette.tsx
@@ -92,12 +92,34 @@ interface CommandItem extends CommandDescriptor {
iconNode: ReactNode;
}
+function firstEnabledIndex(commands: readonly CommandItem[]): number {
+ return commands.findIndex((command) => !command.disabledReason);
+}
+
+function nextEnabledIndex(
+ commands: readonly CommandItem[],
+ selectedIndex: number,
+ direction: 1 | -1
+): number {
+ if (commands.length === 0) return -1;
+
+ let index = selectedIndex;
+ for (let visited = 0; visited < commands.length; visited++) {
+ index = (index + direction + commands.length) % commands.length;
+ if (!commands[index]?.disabledReason) return index;
+ }
+
+ return -1;
+}
+
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [searchMounted, setSearchMounted] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
+ const [canScrollUp, setCanScrollUp] = useState(false);
+ const [canScrollDown, setCanScrollDown] = useState(false);
const inputRef = useRef(null);
const listRef = useRef(null);
@@ -179,6 +201,9 @@ export function CommandPalette() {
return groups;
}, [filtered]);
+ const orderedCommands = useMemo(() => grouped.flatMap((group) => group.items), [grouped]);
+ const unavailableCount = filtered.filter((command) => command.disabledReason).length;
+
// Reset on open/close
useEffect(() => {
if (open) {
@@ -188,12 +213,16 @@ export function CommandPalette() {
}
}, [open]);
- // Clamp selected index
+ // Keep selection on an executable command after filtering or registry changes.
useEffect(() => {
- if (selectedIndex >= filtered.length) {
- setSelectedIndex(Math.max(0, filtered.length - 1));
+ if (
+ selectedIndex < 0 ||
+ selectedIndex >= orderedCommands.length ||
+ orderedCommands[selectedIndex]?.disabledReason
+ ) {
+ setSelectedIndex(firstEnabledIndex(orderedCommands));
}
- }, [filtered.length, selectedIndex]);
+ }, [orderedCommands, selectedIndex]);
// ⌘K listener
useEffect(() => {
@@ -220,21 +249,41 @@ export function CommandPalette() {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
- setSelectedIndex((i) => (i < filtered.length - 1 ? i + 1 : 0));
+ setSelectedIndex((index) => nextEnabledIndex(orderedCommands, index, 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
- setSelectedIndex((i) => (i > 0 ? i - 1 : filtered.length - 1));
- } else if (e.key === 'Enter' && filtered[selectedIndex]) {
+ setSelectedIndex((index) => nextEnabledIndex(orderedCommands, index, -1));
+ } else if (e.key === 'Enter' && orderedCommands[selectedIndex]) {
e.preventDefault();
- runCommand(filtered[selectedIndex]);
+ runCommand(orderedCommands[selectedIndex]);
}
};
+ const updateScrollCues = useCallback(() => {
+ const viewport = listRef.current;
+ if (!viewport) return;
+
+ const remaining = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
+ setCanScrollUp(viewport.scrollTop > 1);
+ setCanScrollDown(remaining > 1);
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+ const frame = window.requestAnimationFrame(updateScrollCues);
+ window.addEventListener('resize', updateScrollCues);
+ return () => {
+ window.cancelAnimationFrame(frame);
+ window.removeEventListener('resize', updateScrollCues);
+ };
+ }, [open, orderedCommands, updateScrollCues]);
+
// Scroll selected item into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`);
el?.scrollIntoView({ block: 'nearest' });
- }, [selectedIndex]);
+ updateScrollCues();
+ }, [selectedIndex, updateScrollCues]);
// Don't show if help dialog is open
if (isHelpOpen) return null;
@@ -246,13 +295,17 @@ export function CommandPalette() {
setOpen(false)}
- size={520}
+ size={600}
padding={0}
title={Command palette}
withCloseButton={false}
classNames={{ content: 'overflow-hidden', header: 'sr-only', body: 'p-0' }}
>
-
+
Search and run board actions, navigation commands, and shortcuts.
@@ -267,6 +320,11 @@ export function CommandPalette() {
}}
placeholder="Type a command or search..."
aria-label="Search commands"
+ aria-activedescendant={
+ orderedCommands[selectedIndex]
+ ? `command-${orderedCommands[selectedIndex].id}`
+ : undefined
+ }
variant="unstyled"
leftSection={}
className="min-w-0 flex-1"
@@ -279,68 +337,145 @@ export function CommandPalette() {
-
- {filtered.length === 0 ? (
-
- No commands found
-
- ) : (
- grouped.map((group) => (
-
-
- {group.category}
-
-
- {group.items.map((cmd) => {
- flatIndex++;
- const idx = flatIndex;
- return (
- runCommand(cmd)}
- onMouseEnter={() => setSelectedIndex(idx)}
- >
-
+
+ {filtered.length === 0 ? (
+
+ No commands found
+
+ ) : (
+ grouped.map((group) => (
+
+
+ {group.category}
+
+
+ {group.items.map((cmd) => {
+ flatIndex++;
+ const idx = flatIndex;
+ const isSelected = idx === selectedIndex;
+ const disabledDescriptionId = cmd.disabledReason
+ ? `command-disabled-${cmd.id}`
+ : undefined;
+ return (
+ runCommand(cmd)}
+ onFocus={() => !cmd.disabledReason && setSelectedIndex(idx)}
+ onMouseEnter={() => !cmd.disabledReason && setSelectedIndex(idx)}
>
- {cmd.iconNode}
-
- {cmd.label}
- {cmd.shortcut && (
-
- {cmd.shortcut}
-
- )}
-
- );
- })}
-
-
- ))
+
+ {cmd.iconNode}
+
+
+ {cmd.label}
+ {cmd.disabledReason && (
+
+ {cmd.disabledReason}
+
+ )}
+
+ {cmd.shortcut && (
+
+ {cmd.shortcut}
+
+ )}
+
+ );
+ })}
+
+
+ ))
+ )}
+
+ {canScrollUp && (
+
)}
-
+ {canScrollDown && (
+
+ )}
+
+
+
+
+ {filtered.length} command{filtered.length === 1 ? '' : 's'}
+ {unavailableCount > 0 ? ` · ${unavailableCount} unavailable` : ''}
+
+
+ {canScrollUp && canScrollDown
+ ? 'More above and below'
+ : canScrollUp
+ ? 'More commands above'
+ : canScrollDown
+ ? 'More commands below'
+ : 'All commands visible'}
+
+
+ ↑↓
+
+ Navigate
+
+ Enter
+
+ Run
+
+
+
{searchMounted && (