diff --git a/src/App.test.tsx b/src/App.test.tsx index e6da570c..69761c6a 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -233,6 +233,7 @@ vi.mock('@/hooks/useKeyboardShortcuts', () => ({ vi.mock('@/features/command-palette/commands', () => ({ createCommands: () => [], + createSessionCommands: () => [], })); vi.mock('@/features/file-browser', () => ({ diff --git a/src/App.tsx b/src/App.tsx index 615939f9..19626694 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -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, @@ -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)) diff --git a/src/features/command-palette/CommandPalette.test.tsx b/src/features/command-palette/CommandPalette.test.tsx new file mode 100644 index 00000000..a8eee3c2 --- /dev/null +++ b/src/features/command-palette/CommandPalette.test.tsx @@ -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 } = {}, +): 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(); + 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(); + 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(); + 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( + , + ); + 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(); + 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( + , + ); + act(() => { vi.advanceTimersByTime(100); }); + + const next = [ + sessionCommand('agent:main:main', 'Main'), + sessionCommand('agent:newcomer:main', 'Newcomer', { + keywords: ['Newcomer', 'newcomer', 'agent:newcomer:main'], + }), + ]; + rerender(); + 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(); + }); +}); diff --git a/src/features/command-palette/CommandPalette.tsx b/src/features/command-palette/CommandPalette.tsx index 914b3312..60d56b6c 100644 --- a/src/features/command-palette/CommandPalette.tsx +++ b/src/features/command-palette/CommandPalette.tsx @@ -11,6 +11,7 @@ interface CommandPaletteProps { } const CATEGORY_LABELS: Record = { + sessions: 'Sessions', actions: 'Actions', navigation: 'Navigation', settings: 'Settings', @@ -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 | null>(null); // Reset state and focus when opened useEffect(() => { @@ -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]); + + // Cancel any scheduled action on unmount. + useEffect(() => () => { + if (pendingAction.current !== null) clearTimeout(pendingAction.current); + }, []); + // Scroll selected item into view useEffect(() => { const list = listRef.current; @@ -50,9 +66,13 @@ 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) => { @@ -60,7 +80,7 @@ export function CommandPalette({ open, onClose, commands }: CommandPaletteProps) 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(); @@ -162,6 +182,7 @@ 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 (