Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ vi.mock('@/hooks/useKeyboardShortcuts', () => ({

vi.mock('@/features/command-palette/commands', () => ({
createCommands: () => [],
createSessionCommands: () => [],
}));

vi.mock('@/features/file-browser', () => ({
Expand Down
14 changes: 12 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import type { ViewMode } from '@/features/command-palette/commands';
import { ResizablePanels } from '@/components/ResizablePanels';
import { getContextLimit } from '@/lib/constants';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { createCommands } from '@/features/command-palette/commands';
import { createCommands, createSessionCommands } from '@/features/command-palette/commands';
import { PanelErrorBoundary } from '@/components/PanelErrorBoundary';
import { SpawnAgentDialog } from '@/features/sessions/SpawnAgentDialog';
import { DEFAULT_CHAT_PATH_LINKS_CONFIG, parseChatPathLinksConfig } from '@/features/chat/chatPathLinks';
Expand Down Expand Up @@ -516,7 +516,7 @@ export default function App({ onLogout }: AppProps) {

const openSpawnDialog = useCallback(() => setSpawnDialogOpen(true), []);

const commands = useMemo(() => createCommands({
const staticCommands = useMemo(() => createCommands({
onNewSession: openSpawnDialog,
onResetSession: handleReset,
onToggleSound: toggleSound,
Expand Down Expand Up @@ -677,6 +677,16 @@ export default function App({ onLogout }: AppProps) {
});
}, [getWorkspaceSwitchLabel, requestWorkspaceTransition, setCurrentSession]);

const sessionCommands = useMemo(
() => createSessionCommands(sessions, currentSession, agentName, handleSessionChange),
[sessions, currentSession, agentName, handleSessionChange],
);

const commands = useMemo(
() => [...staticCommands, ...sessionCommands],
[staticCommands, sessionCommands],
);

const handleSpawnSession = useCallback((opts: SpawnSessionOpts) => {
const targetSessionKey = opts.kind === 'root'
? buildAgentRootSessionKey(opts.agentName?.trim() || 'agent', sessions.map(getSessionKey))
Expand Down
260 changes: 260 additions & 0 deletions src/features/command-palette/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { CommandPalette } from './CommandPalette';
import type { Command } from './types';

function staticCommand(id: string, label: string, action = vi.fn()): Command {
return { id, label, action, category: 'actions' };
}

function sessionCommand(
sessionKey: string,
label: string,
opts: { isActive?: boolean; keywords?: string[]; action?: ReturnType<typeof vi.fn> } = {},
): Command {
return {
id: `session-${sessionKey}`,
label,
action: opts.action ?? vi.fn(),
category: 'sessions',
keywords: opts.keywords,
isActive: opts.isActive ?? false,
};
}

describe('CommandPalette session entries', () => {
beforeEach(() => {
vi.useFakeTimers();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
});

afterEach(() => {
vi.useRealTimers();
});

function renderPalette(commands: Command[], onClose = vi.fn()) {
const result = render(<CommandPalette open commands={commands} onClose={onClose} />);
act(() => {
vi.advanceTimersByTime(100);
});
return { ...result, onClose };
}

function input() {
return screen.getByPlaceholderText(/search actions/i) as HTMLInputElement;
}

it('renders a Sessions section header when session commands are present', () => {
const commands = [
staticCommand('search', 'Search messages'),
sessionCommand('agent:main:main', 'Main'),
];
renderPalette(commands);
expect(screen.getByText('Sessions')).toBeInTheDocument();
});

it('narrows results to matching session commands when typing', () => {
const commands = [
staticCommand('search', 'Search messages'),
staticCommand('reset', 'Reset session'),
sessionCommand('agent:reviewer:main', 'Reviewer Bot', {
keywords: ['Reviewer Bot', 'reviewer', 'agent:reviewer:main'],
}),
sessionCommand('agent:planner:main', 'Planner Bot', {
keywords: ['Planner Bot', 'planner', 'agent:planner:main'],
}),
];
renderPalette(commands);
fireEvent.change(input(), { target: { value: 'reviewer' } });
expect(screen.getByText('Reviewer Bot')).toBeInTheDocument();
expect(screen.queryByText('Planner Bot')).not.toBeInTheDocument();
expect(screen.queryByText('Search messages')).not.toBeInTheDocument();
});

it('fires the highlighted session action exactly once when Enter is pressed', () => {
const action = vi.fn();
const commands = [
sessionCommand('agent:reviewer:main', 'Reviewer Bot', {
keywords: ['reviewer'],
action,
}),
];
const { onClose } = renderPalette(commands);
fireEvent.change(input(), { target: { value: 'reviewer' } });
fireEvent.keyDown(input(), { key: 'Enter' });
expect(onClose).toHaveBeenCalledTimes(1);
act(() => { vi.advanceTimersByTime(100); });
expect(action).toHaveBeenCalledTimes(1);
});

it('Esc closes the palette without firing any action', () => {
const action = vi.fn();
const commands = [
sessionCommand('agent:reviewer:main', 'Reviewer Bot', { action }),
];
const { onClose } = renderPalette(commands);
fireEvent.keyDown(input(), { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
expect(action).not.toHaveBeenCalled();
});

it('ArrowDown moves selection forward one step and Enter fires that item', () => {
const sessionAction = vi.fn();
const staticAction = vi.fn();
const commands = [
staticCommand('toggle-log', 'Toggle Log Panel', staticAction),
sessionCommand('agent:reviewer:main', 'Reviewer Bot', {
keywords: ['Reviewer'],
action: sessionAction,
}),
];
renderPalette(commands);
// Sessions group sorts before actions per CATEGORY_ORDER, so index 0 is
// Reviewer Bot and ArrowDown advances to index 1, Toggle Log Panel.
fireEvent.keyDown(input(), { key: 'ArrowDown' });
fireEvent.keyDown(input(), { key: 'Enter' });
act(() => { vi.advanceTimersByTime(100); });
expect(staticAction).toHaveBeenCalledTimes(1);
expect(sessionAction).not.toHaveBeenCalled();
});

it('ArrowDown then ArrowUp returns to the previous selection', () => {
const sessionAction = vi.fn();
const staticAction = vi.fn();
const commands = [
staticCommand('toggle-log', 'Toggle Log Panel', staticAction),
sessionCommand('agent:reviewer:main', 'Reviewer Bot', {
keywords: ['Reviewer'],
action: sessionAction,
}),
];
renderPalette(commands);
fireEvent.keyDown(input(), { key: 'ArrowDown' });
fireEvent.keyDown(input(), { key: 'ArrowUp' });
fireEvent.keyDown(input(), { key: 'Enter' });
act(() => { vi.advanceTimersByTime(100); });
expect(sessionAction).toHaveBeenCalledTimes(1);
expect(staticAction).not.toHaveBeenCalled();
});

it('marks the active session with aria-current and a "current" badge', () => {
const commands = [
sessionCommand('agent:main:main', 'Main', { isActive: true }),
sessionCommand('agent:reviewer:main', 'Reviewer'),
];
renderPalette(commands);
const mainButton = screen.getByText('Main').closest('button')!;
const reviewerButton = screen.getByText('Reviewer').closest('button')!;
expect(mainButton).toHaveAttribute('aria-current', 'true');
expect(reviewerButton).not.toHaveAttribute('aria-current');
const badges = screen.getAllByText('current');
expect(badges).toHaveLength(1);
expect(mainButton).toContainElement(badges[0]);
});

it('does not render the active affordance for a non-session command with isActive', () => {
const cmd: Command = {
id: 'rogue',
label: 'Rogue Command',
action: vi.fn(),
category: 'actions',
isActive: true,
};
renderPalette([cmd]);
const button = screen.getByText('Rogue Command').closest('button')!;
expect(button).not.toHaveAttribute('aria-current');
expect(screen.queryByText('current')).not.toBeInTheDocument();
});

it('clamps selectedIndex when filtered commands shrink below the cursor', () => {
const first = vi.fn();
const second = vi.fn();
const onClose = vi.fn();
const commands: Command[] = [
sessionCommand('agent:alpha:main', 'Alpha', { action: first }),
sessionCommand('agent:beta:main', 'Beta', { action: second }),
sessionCommand('agent:gamma:main', 'Gamma', { action: vi.fn() }),
];
const { rerender } = render(<CommandPalette open commands={commands} onClose={onClose} />);
act(() => { vi.advanceTimersByTime(100); });
// Move cursor to the third entry.
fireEvent.keyDown(input(), { key: 'ArrowDown' });
fireEvent.keyDown(input(), { key: 'ArrowDown' });
// Shrink filtered list without typing so the clamp effect is the only
// path that can bring selectedIndex back into range.
rerender(<CommandPalette open commands={[commands[0]]} onClose={onClose} />);
fireEvent.keyDown(input(), { key: 'Enter' });
act(() => { vi.advanceTimersByTime(100); });
expect(first).toHaveBeenCalledTimes(1);
expect(second).not.toHaveBeenCalled();
});

it('ArrowDown on an empty filtered list does not produce a negative selectedIndex', () => {
const fallback = vi.fn();
const onClose = vi.fn();
const { rerender } = render(
<CommandPalette open commands={[] as Command[]} onClose={onClose} />,
);
act(() => { vi.advanceTimersByTime(100); });
fireEvent.keyDown(input(), { key: 'ArrowDown' });
fireEvent.keyDown(input(), { key: 'Enter' });
// Enter while filtered is empty must be a no-op (no action fires, palette
// stays open).
expect(onClose).not.toHaveBeenCalled();
// When commands arrive, the first item must be reachable on Enter without
// an extra ArrowDown (proving selectedIndex was clamped, not stuck at -1).
const next = [sessionCommand('agent:alpha:main', 'Alpha', { action: fallback })];
rerender(<CommandPalette open commands={next} onClose={onClose} />);
fireEvent.keyDown(input(), { key: 'Enter' });
act(() => { vi.advanceTimersByTime(100); });
expect(fallback).toHaveBeenCalledTimes(1);
});

it('rapid double-Enter only fires the most-recent action once', () => {
const first = vi.fn();
const second = vi.fn();
const commands = [
sessionCommand('agent:alpha:main', 'Alpha', { keywords: ['Alpha'], action: first }),
sessionCommand('agent:beta:main', 'Beta', { keywords: ['Beta'], action: second }),
];
const { onClose } = renderPalette(commands);
fireEvent.keyDown(input(), { key: 'Enter' });
fireEvent.keyDown(input(), { key: 'ArrowDown' });
fireEvent.keyDown(input(), { key: 'Enter' });
act(() => { vi.advanceTimersByTime(100); });
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(2);
});

it('a new command surfaced via parent re-render is searchable without remount', () => {
const initial = [sessionCommand('agent:main:main', 'Main')];
const { rerender } = render(
<CommandPalette open commands={initial} onClose={vi.fn()} />,
);
act(() => { vi.advanceTimersByTime(100); });

const next = [
sessionCommand('agent:main:main', 'Main'),
sessionCommand('agent:newcomer:main', 'Newcomer', {
keywords: ['Newcomer', 'newcomer', 'agent:newcomer:main'],
}),
];
rerender(<CommandPalette open commands={next} onClose={vi.fn()} />);
fireEvent.change(input(), { target: { value: 'newcomer' } });
expect(screen.getByText('Newcomer')).toBeInTheDocument();
});

it('shows the empty-state copy when no command matches', () => {
const commands = [
staticCommand('search', 'Search messages'),
sessionCommand('agent:main:main', 'Main'),
];
renderPalette(commands);
fireEvent.change(input(), { target: { value: 'zzzzzzz-no-match' } });
expect(screen.getByText(/no matching command/i)).toBeInTheDocument();
});
});
29 changes: 27 additions & 2 deletions src/features/command-palette/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface CommandPaletteProps {
}

const CATEGORY_LABELS: Record<string, string> = {
sessions: 'Sessions',
actions: 'Actions',
navigation: 'Navigation',
settings: 'Settings',
Expand All @@ -28,6 +29,7 @@ export function CommandPalette({ open, onClose, commands }: CommandPaletteProps)
const lastMousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });

const filtered = useMemo(() => filterCommands(commands, query), [commands, query]);
const pendingAction = useRef<ReturnType<typeof setTimeout> | null>(null);

// Reset state and focus when opened
useEffect(() => {
Expand All @@ -40,6 +42,20 @@ export function CommandPalette({ open, onClose, commands }: CommandPaletteProps)
}
}, [open]);

// Keep selectedIndex inside the bounds of the current filtered list.
// Typing a narrower query can shrink filtered below selectedIndex, leaving
// Enter pointed at undefined. The Math.max(0, ...) outer guard also catches
// the empty-list case (filtered.length === 0 -> min is -1 without it).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clamp converges and only runs when filtered.length changes
setSelectedIndex(i => Math.max(0, Math.min(i, filtered.length - 1)));
}, [filtered.length]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Cancel any scheduled action on unmount.
useEffect(() => () => {
if (pendingAction.current !== null) clearTimeout(pendingAction.current);
}, []);

// Scroll selected item into view
useEffect(() => {
const list = listRef.current;
Expand All @@ -50,17 +66,21 @@ export function CommandPalette({ open, onClose, commands }: CommandPaletteProps)
}, [selectedIndex]);

const executeCommand = useCallback((cmd: Command) => {
if (pendingAction.current !== null) clearTimeout(pendingAction.current);
onClose();
// Small delay to let dialog close animation start
setTimeout(() => cmd.action(), 50);
pendingAction.current = setTimeout(() => {
pendingAction.current = null;
cmd.action();
}, 50);
}, [onClose]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setUsingKeyboard(true);
setSelectedIndex(i => Math.min(i + 1, filtered.length - 1));
setSelectedIndex(i => Math.max(0, Math.min(i + 1, filtered.length - 1)));
break;
case 'ArrowUp':
e.preventDefault();
Expand Down Expand Up @@ -162,17 +182,22 @@ export function CommandPalette({ open, onClose, commands }: CommandPaletteProps)
{cmds.map((cmd) => {
const idx = flatIndexMap.get(cmd.id) ?? -1;
const isSelected = idx === selectedIndex;
const showActiveAffordance = cmd.category === 'sessions' && cmd.isActive === true;
return (
<button
key={cmd.id}
data-selected={isSelected}
onClick={() => executeCommand(cmd)}
onMouseEnter={() => { if (!usingKeyboard) setSelectedIndex(idx); }}
data-active={isSelected}
aria-current={showActiveAffordance ? 'true' : undefined}
className="cockpit-command-item"
>
{cmd.icon || <CommandIcon size={15} className="text-muted-foreground" />}
<span className="flex-1 text-[0.933rem] font-medium text-foreground">{cmd.label}</span>
{showActiveAffordance && (
<span className="cockpit-badge" data-tone="primary">current</span>
)}
{cmd.shortcut && (
<kbd className="cockpit-kbd">
{cmd.shortcut}
Expand Down
Loading
Loading