From 9dd0b72207bd36f44cc2edfec0571f1fe6ac70e2 Mon Sep 17 00:00:00 2001 From: hashimotofi Date: Fri, 22 May 2026 09:51:13 +0300 Subject: [PATCH 1/3] feat(command-palette): jump to agents and sessions by name Indexes the live session list as command-palette entries so Cmd+K can navigate to top-level agents, nested subagents, and cron sessions by typing part of their label, displayName, identityName, root agent id, or session key. The active session is marked with aria-current and a visible "current" badge. Selecting a result reuses handleSessionChange, which keeps the dirty-workspace guard intact. Closes #180 --- src/App.test.tsx | 1 + src/App.tsx | 14 +- .../command-palette/CommandPalette.test.tsx | 166 ++++++++++++++++++ .../command-palette/CommandPalette.tsx | 6 + src/features/command-palette/commands.test.ts | 147 ++++++++++++++++ src/features/command-palette/commands.ts | 55 +++++- src/features/command-palette/index.ts | 2 +- src/features/command-palette/types.ts | 3 +- 8 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 src/features/command-palette/CommandPalette.test.tsx create mode 100644 src/features/command-palette/commands.test.ts 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..2203719f --- /dev/null +++ b/src/features/command-palette/CommandPalette.test.tsx @@ -0,0 +1,166 @@ +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/ArrowUp moves selection through a mixed list and Enter fires the highlighted 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); + // No filter: sessions group sorts before navigation per CATEGORY_ORDER, + // so index 0 is Reviewer Bot. + 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 data-active-session', () => { + 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(mainButton).toHaveAttribute('data-active-session', 'true'); + expect(reviewerButton).not.toHaveAttribute('aria-current'); + expect(reviewerButton).not.toHaveAttribute('data-active-session'); + }); + + 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..22dda0ec 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', @@ -169,10 +170,15 @@ export function CommandPalette({ open, onClose, commands }: CommandPaletteProps) onClick={() => executeCommand(cmd)} onMouseEnter={() => { if (!usingKeyboard) setSelectedIndex(idx); }} data-active={isSelected} + data-active-session={cmd.isActive ? 'true' : undefined} + aria-current={cmd.isActive ? 'true' : undefined} className="cockpit-command-item" > {cmd.icon || } {cmd.label} + {cmd.isActive && ( + current + )} {cmd.shortcut && ( {cmd.shortcut} diff --git a/src/features/command-palette/commands.test.ts b/src/features/command-palette/commands.test.ts new file mode 100644 index 00000000..7c80e2b8 --- /dev/null +++ b/src/features/command-palette/commands.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Session } from '@/types'; +import { createCommands, createSessionCommands, filterCommands } from './commands'; + +function makeSession(key: string, extra: Partial = {}): Session { + return { sessionKey: key, ...extra }; +} + +function noopActions() { + return { + onNewSession: vi.fn(), + onResetSession: vi.fn(), + onToggleSound: vi.fn(), + onSettings: vi.fn(), + onSearch: vi.fn(), + onAbort: vi.fn(), + onSetTheme: vi.fn(), + onSetFont: vi.fn(), + onTtsProviderChange: vi.fn(), + onToggleWakeWord: vi.fn(), + onToggleEvents: vi.fn(), + onToggleLog: vi.fn(), + onToggleTelemetry: vi.fn(), + onOpenSettings: vi.fn(), + onRefreshSessions: vi.fn(), + onRefreshMemory: vi.fn(), + }; +} + +describe('createSessionCommands', () => { + it('returns one command per input session with category "sessions"', () => { + const sessions = [ + makeSession('agent:main:main', { label: 'Main' }), + makeSession('agent:reviewer:main', { label: 'Reviewer' }), + ]; + const cmds = createSessionCommands(sessions, '', 'Agent', vi.fn()); + expect(cmds).toHaveLength(2); + expect(cmds.every((c) => c.category === 'sessions')).toBe(true); + }); + + it('marks exactly the current session as isActive', () => { + const sessions = [ + makeSession('agent:main:main', { label: 'Main' }), + makeSession('agent:reviewer:main', { label: 'Reviewer' }), + makeSession('agent:planner:main', { label: 'Planner' }), + ]; + const cmds = createSessionCommands(sessions, 'agent:reviewer:main', 'Agent', vi.fn()); + const active = cmds.filter((c) => c.isActive); + expect(active).toHaveLength(1); + expect(active[0].id).toBe('session-agent:reviewer:main'); + expect(cmds.find((c) => c.id === 'session-agent:main:main')?.isActive).toBe(false); + }); + + it('makes nested subagent sessions matchable by displayName', () => { + const sessions = [ + makeSession('agent:main:subagent:abc123', { displayName: 'reviewer' }), + ]; + const cmds = createSessionCommands(sessions, '', 'Agent', vi.fn()); + expect(cmds[0].keywords).toContain('reviewer'); + }); + + it('includes display label, label, displayName, identityName, root agent id, and session key in keywords', () => { + const sessions = [ + makeSession('agent:planner:main', { + label: 'Plan Bot', + displayName: 'plan-display', + identityName: 'Plan Identity', + }), + ]; + const cmds = createSessionCommands(sessions, '', 'Agent', vi.fn()); + const keywords = cmds[0].keywords ?? []; + expect(keywords).toContain('Plan Bot'); + expect(keywords).toContain('plan-display'); + expect(keywords).toContain('Plan Identity'); + expect(keywords).toContain('planner'); + expect(keywords).toContain('agent:planner:main'); + }); + + it('skips missing/empty fields without producing undefined entries', () => { + const sessions = [makeSession('agent:bare:main')]; + const cmds = createSessionCommands(sessions, '', 'Agent', vi.fn()); + const keywords = cmds[0].keywords ?? []; + expect(keywords.every((k) => typeof k === 'string' && k.length > 0)).toBe(true); + }); + + it('falls back to a usable display label via getSessionDisplayLabel when label is empty', () => { + const sessions = [ + makeSession('agent:main:main'), + makeSession('agent:planner:main', { identityName: 'Planner Ident' }), + ]; + const cmds = createSessionCommands(sessions, '', 'Agent', vi.fn()); + expect(cmds[0].label).toBe('Agent (main)'); + expect(cmds[1].label).toBe('Planner Ident (planner)'); + }); + + it('action calls onSelectSession with the right session key exactly once', () => { + const onSelect = vi.fn(); + const sessions = [makeSession('agent:reviewer:main', { label: 'Reviewer' })]; + const cmds = createSessionCommands(sessions, '', 'Agent', onSelect); + cmds[0].action(); + expect(onSelect).toHaveBeenCalledExactlyOnceWith('agent:reviewer:main'); + }); + + it('returns an empty array for an empty session list', () => { + expect(createSessionCommands([], '', 'Agent', vi.fn())).toEqual([]); + }); +}); + +describe('filterCommands with merged static + session commands', () => { + const sessions = [ + makeSession('agent:main:main', { label: 'Main' }), + makeSession('agent:main:subagent:abc', { displayName: 'reviewer' }), + makeSession('agent:planner:main'), + ]; + + function buildAll(currentKey = '') { + const statics = createCommands(noopActions()); + const sessionCmds = createSessionCommands(sessions, currentKey, 'Agent', vi.fn()); + return [...statics, ...sessionCmds]; + } + + it('sorts the sessions group before appearance and voice', () => { + const all = buildAll(); + const sorted = filterCommands(all, ''); + const firstSessions = sorted.findIndex((c) => c.category === 'sessions'); + const firstAppearance = sorted.findIndex((c) => c.category === 'appearance'); + const firstVoice = sorted.findIndex((c) => c.category === 'voice'); + expect(firstSessions).toBeGreaterThanOrEqual(0); + expect(firstSessions).toBeLessThan(firstAppearance); + expect(firstSessions).toBeLessThan(firstVoice); + }); + + it('returns the subagent labelled "reviewer" for query "review" (case-insensitive)', () => { + const all = buildAll(); + const lower = filterCommands(all, 'review'); + const upper = filterCommands(all, 'REVIEW'); + const expectedId = 'session-agent:main:subagent:abc'; + expect(lower.some((c) => c.id === expectedId)).toBe(true); + expect(upper.some((c) => c.id === expectedId)).toBe(true); + }); + + it('matches a session by its root-agent id when label/displayName are empty', () => { + const all = buildAll(); + const results = filterCommands(all, 'planner'); + expect(results.some((c) => c.id === 'session-agent:planner:main')).toBe(true); + }); +}); diff --git a/src/features/command-palette/commands.ts b/src/features/command-palette/commands.ts index 1c845293..4a5bcc53 100644 --- a/src/features/command-palette/commands.ts +++ b/src/features/command-palette/commands.ts @@ -2,6 +2,9 @@ import type { Command } from './types'; import { themes, type ThemeName } from '@/lib/themes'; import { fonts, type FontName } from '@/lib/fonts'; import type { TTSProvider } from '@/features/tts/useTTS'; +import type { Session } from '@/types'; +import { getSessionKey } from '@/types'; +import { getRootAgentId, getSessionDisplayLabel } from '@/features/sessions/sessionKeys'; export type ViewMode = 'chat' | 'kanban'; @@ -220,14 +223,54 @@ export function createCommands(actions: CommandActions): Command[] { } const CATEGORY_ORDER: Record = { - actions: 0, - navigation: 1, - kanban: 2, - settings: 3, - appearance: 4, - voice: 5, + sessions: 0, + actions: 1, + navigation: 2, + kanban: 3, + settings: 4, + appearance: 5, + voice: 6, }; +/** Build dynamic command-palette entries for jumping to a live session. */ +export function createSessionCommands( + sessions: Session[], + currentSessionKey: string, + agentName: string, + onSelectSession: (sessionKey: string) => void, +): Command[] { + return sessions.map((session) => { + const sessionKey = getSessionKey(session); + const displayLabel = getSessionDisplayLabel(session, agentName); + const rootAgentId = getRootAgentId(sessionKey); + + const rawKeywords = [ + displayLabel, + session.label, + session.displayName, + session.identityName, + rootAgentId, + sessionKey, + ]; + const keywords = Array.from( + new Set( + rawKeywords + .map((k) => (typeof k === 'string' ? k.trim() : '')) + .filter((k) => k.length > 0), + ), + ); + + return { + id: `session-${sessionKey}`, + label: displayLabel, + action: () => onSelectSession(sessionKey), + category: 'sessions' as const, + keywords, + isActive: sessionKey === currentSessionKey, + }; + }); +} + /** Filter commands by fuzzy-matching against a search query. */ export function filterCommands(commands: Command[], query: string): Command[] { const candidates = query.trim() diff --git a/src/features/command-palette/index.ts b/src/features/command-palette/index.ts index 46cd67b9..3dd886b8 100644 --- a/src/features/command-palette/index.ts +++ b/src/features/command-palette/index.ts @@ -1,3 +1,3 @@ export { CommandPalette } from './CommandPalette'; -export { createCommands, filterCommands } from './commands'; +export { createCommands, createSessionCommands, filterCommands } from './commands'; export type { Command } from './types'; diff --git a/src/features/command-palette/types.ts b/src/features/command-palette/types.ts index 06aa6485..775e9605 100644 --- a/src/features/command-palette/types.ts +++ b/src/features/command-palette/types.ts @@ -6,6 +6,7 @@ export interface Command { shortcut?: string; // Display string like "⌘K" icon?: ReactNode; action: () => void; - category?: 'navigation' | 'actions' | 'settings' | 'appearance' | 'voice' | 'kanban'; + category?: 'navigation' | 'actions' | 'settings' | 'appearance' | 'voice' | 'kanban' | 'sessions'; keywords?: string[]; // Additional search terms + isActive?: boolean; // Renders an active-state affordance (e.g. current session) } From 0b6d718b3e6f0955b8b5227a37499cec40a22329 Mon Sep 17 00:00:00 2001 From: hashimotofi Date: Fri, 22 May 2026 10:02:10 +0300 Subject: [PATCH 2/3] fix(review): apply autofix feedback --- .../command-palette/CommandPalette.test.tsx | 82 +++++++++++++++++-- .../command-palette/CommandPalette.tsx | 26 +++++- src/features/command-palette/commands.ts | 7 +- 3 files changed, 102 insertions(+), 13 deletions(-) diff --git a/src/features/command-palette/CommandPalette.test.tsx b/src/features/command-palette/CommandPalette.test.tsx index 2203719f..b393de0a 100644 --- a/src/features/command-palette/CommandPalette.test.tsx +++ b/src/features/command-palette/CommandPalette.test.tsx @@ -101,7 +101,27 @@ describe('CommandPalette session entries', () => { expect(action).not.toHaveBeenCalled(); }); - it('ArrowDown/ArrowUp moves selection through a mixed list and Enter fires the highlighted item', () => { + 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 = [ @@ -112,8 +132,6 @@ describe('CommandPalette session entries', () => { }), ]; renderPalette(commands); - // No filter: sessions group sorts before navigation per CATEGORY_ORDER, - // so index 0 is Reviewer Bot. fireEvent.keyDown(input(), { key: 'ArrowDown' }); fireEvent.keyDown(input(), { key: 'ArrowUp' }); fireEvent.keyDown(input(), { key: 'Enter' }); @@ -122,7 +140,7 @@ describe('CommandPalette session entries', () => { expect(staticAction).not.toHaveBeenCalled(); }); - it('marks the active session with aria-current and data-active-session', () => { + 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'), @@ -131,9 +149,61 @@ describe('CommandPalette session entries', () => { const mainButton = screen.getByText('Main').closest('button')!; const reviewerButton = screen.getByText('Reviewer').closest('button')!; expect(mainButton).toHaveAttribute('aria-current', 'true'); - expect(mainButton).toHaveAttribute('data-active-session', 'true'); expect(reviewerButton).not.toHaveAttribute('aria-current'); - expect(reviewerButton).not.toHaveAttribute('data-active-session'); + 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 typing narrows the filtered list below the cursor', () => { + 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 }), + sessionCommand('agent:gamma:main', 'Gamma', { keywords: ['Gamma'], action: vi.fn() }), + ]; + renderPalette(commands); + // Move cursor to the third entry. + fireEvent.keyDown(input(), { key: 'ArrowDown' }); + fireEvent.keyDown(input(), { key: 'ArrowDown' }); + // Type a query that narrows results to one match (the first entry). + fireEvent.change(input(), { target: { value: 'Alpha' } }); + fireEvent.keyDown(input(), { key: 'Enter' }); + act(() => { vi.advanceTimersByTime(100); }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); + + 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', () => { diff --git a/src/features/command-palette/CommandPalette.tsx b/src/features/command-palette/CommandPalette.tsx index 22dda0ec..38608818 100644 --- a/src/features/command-palette/CommandPalette.tsx +++ b/src/features/command-palette/CommandPalette.tsx @@ -29,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(() => { @@ -41,6 +42,19 @@ 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. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clamp converges (Math.min) and only runs when filtered.length changes + setSelectedIndex(i => Math.min(i, Math.max(0, 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; @@ -51,9 +65,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) => { @@ -163,6 +181,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 (