From 0206fb372014ee95af23e95c14dd67942740167e Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Mar 2026 21:00:31 +0100 Subject: [PATCH] fix: rank command palette results by match score, not section order When searching in Cmd+K, groups are now ordered by their highest-scoring match instead of the fixed section order. Empty query preserves the default section ordering. Co-Authored-By: Claude Opus 4.6 --- src/components/CommandPalette.test.tsx | 60 ++++++++++++++++++++++++++ src/components/CommandPalette.tsx | 16 ++++--- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx index 6469cc0a..58a61283 100644 --- a/src/components/CommandPalette.test.tsx +++ b/src/components/CommandPalette.test.tsx @@ -165,4 +165,64 @@ describe('CommandPalette', () => { expect(screen.getByText('↵ select')).toBeInTheDocument() expect(screen.getByText('esc close')).toBeInTheDocument() }) + + describe('relevance ranking', () => { + const relevanceCommands: CommandAction[] = [ + makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }), + makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }), + makeCommand({ id: 'switch-theme', label: 'Switch Theme', group: 'Appearance', keywords: ['dark', 'light'] }), + makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }), + ] + + function getVisibleLabels() { + return screen.getAllByText( + (_content, el) => + el?.tagName === 'SPAN' && + el.classList.contains('text-foreground') && + !!el.textContent, + ).map(el => el.textContent) + } + + it('ranks "Toggle Raw Editor" before "Create New Note" for query "raw"', () => { + render() + fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } }) + + const labels = getVisibleLabels() + const rawIdx = labels.indexOf('Toggle Raw Editor') + const createIdx = labels.indexOf('Create New Note') + expect(rawIdx).toBeGreaterThanOrEqual(0) + expect(createIdx).toBeGreaterThanOrEqual(0) + expect(rawIdx).toBeLessThan(createIdx) + }) + + it('ranks "Create New Note" first for query "new note"', () => { + render() + fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } }) + + const labels = getVisibleLabels() + expect(labels[0]).toBe('Create New Note') + }) + + it('ranks theme commands first for query "theme"', () => { + render() + fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'theme' } }) + + const labels = getVisibleLabels() + expect(labels[0]).toBe('Switch Theme') + }) + + it('preserves default section order with empty query', () => { + render() + + const groupHeaders = screen.getAllByText( + (_content, el) => + el?.tagName === 'DIV' && + el.classList.contains('uppercase') && + !!el.textContent, + ).map(el => el.textContent) + + // Default order: Navigation < Note < View < Appearance + expect(groupHeaders).toEqual(['Navigation', 'Note', 'View', 'Appearance']) + }) + }) }) diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index ba360460..0197d2e5 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -30,16 +30,21 @@ function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null { return null } -function groupResults(commands: CommandAction[]): { group: CommandGroup; items: CommandAction[] }[] { +function groupResults( + commands: CommandAction[], + byRelevance: boolean, +): { group: CommandGroup; items: CommandAction[] }[] { const map = new Map() for (const cmd of commands) { const list = map.get(cmd.group) if (list) list.push(cmd) else map.set(cmd.group, [cmd]) } - return Array.from(map.entries()) - .sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0])) - .map(([group, items]) => ({ group, items })) + const entries = Array.from(map.entries()) + if (!byRelevance) { + entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0])) + } + return entries.map(([group, items]) => ({ group, items })) } export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) { @@ -70,7 +75,8 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) .map(r => r.command) }, [enabledCommands, query]) - const groups = useMemo(() => groupResults(filtered), [filtered]) + const hasQuery = !!query.trim() + const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery]) const flatList = useMemo(() => groups.flatMap(g => g.items), [groups]) useEffect(() => {