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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-05 21:00:31 +01:00
parent 51d1b28460
commit 0206fb3720
2 changed files with 71 additions and 5 deletions

View File

@@ -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(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
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(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
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(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
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(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
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'])
})
})
})

View File

@@ -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<CommandGroup, CommandAction[]>()
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(() => {