From 066cd82115e66ce8239dacda572e46d5fa2dd49f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 15:42:45 -0400 Subject: [PATCH] fix(editor): a palette search lists the best-matching group first cmdk 1.1.1 ranks the items inside a group but never moves the groups: it looks each one up by an id the DOM doesn't carry. So the palette showed groups in the order they were registered, and a strong match could sit below a screen of weak fuzzy ones. Typing "theme" put the theme commands fifth, under Generate, Sheets, Units and Viewer Controls, and Enter ran whatever the first group held. The palette now orders the groups by their best match while a search is typed (no search keeps the registered order). cmdk scrolls to the selection before the list re-renders it, so after the groups move it would scroll to the item selected before; the list goes back to the top once a search has selected its first item. Co-Authored-By: Claude Opus 5.5 --- .../components/ui/command-palette/index.tsx | 60 ++++++++++++++----- .../ui/command-palette/ranked-groups.test.ts | 31 ++++++++++ 2 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 packages/editor/src/components/ui/command-palette/ranked-groups.test.ts diff --git a/packages/editor/src/components/ui/command-palette/index.tsx b/packages/editor/src/components/ui/command-palette/index.tsx index 3869bb9fc8..f83387d746 100644 --- a/packages/editor/src/components/ui/command-palette/index.tsx +++ b/packages/editor/src/components/ui/command-palette/index.tsx @@ -3,15 +3,15 @@ import type { AnyNodeId, LevelNode } from '@pascal-app/core' import { useScene } from '@pascal-app/core' import { markPerfAction, useViewer } from '@pascal-app/viewer' -import { Command, useCommandState } from 'cmdk' +import { Command, defaultFilter, useCommandState } from 'cmdk' import { ChevronRight, Search } from 'lucide-react' -import type { ReactNode } from 'react' -import { useEffect, useState } from 'react' +import type { ReactNode, RefObject } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { create } from 'zustand' import { useShallow } from 'zustand/shallow' import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog' import { getLevelDisplayName } from '@pascal-app/core' -import { useCommandRegistry } from '../../../store/use-command-registry' +import { type CommandAction, useCommandRegistry } from '../../../store/use-command-registry' import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry' // --------------------------------------------------------------------------- @@ -54,6 +54,28 @@ function resolve(value: string | (() => string)): string { return typeof value === 'function' ? value() : value } +type ActionGroup = [group: string, actions: CommandAction[]] + +/** + * The registered actions by group, in the order they were registered. A search puts the group + * holding the best match first: cmdk ranks the items inside a group but leaves the groups where + * they are, so a strong match could sit below a screen of weak ones. + */ +export function rankedGroups(actions: CommandAction[], search: string): ActionGroup[] { + const groups = new Map() + for (const action of actions) { + groups.set(action.group, [...(groups.get(action.group) ?? []), action]) + } + const ordered: ActionGroup[] = [...groups] + if (!search) return ordered + const bestMatch = ([, list]: ActionGroup) => + Math.max(...list.map((action) => defaultFilter(resolve(action.label), search, action.keywords))) + return ordered + .map((entry) => ({ entry, score: bestMatch(entry) })) + .sort((a, b) => b.score - a.score) + .map(({ entry }) => entry) +} + function Shortcut({ keys }: { keys: string[] }) { return ( @@ -162,6 +184,20 @@ export interface CommandPaletteEmptyAction { onSelect: (query: string) => void } +/** + * A search selects the first item; this scrolls the list back to the top to show it. cmdk scrolls + * before the list re-renders the selection, so once a search has moved the groups it scrolls to + * wherever the item selected before went. Rendered after the list, whose ref is set by then. + */ +function TopOnFirstItem({ list }: { list: RefObject }) { + const value = useCommandState((s) => s.value) + useLayoutEffect(() => { + const first = list.current?.querySelector('[cmdk-item]:not([aria-disabled="true"])') + if (value && first?.getAttribute('aria-selected') === 'true') list.current?.scrollTo({ top: 0 }) + }, [list, value]) + return null +} + function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) { const count = useCommandState((s) => s.filtered.count) const search = useCommandState((s) => s.search) @@ -193,6 +229,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm const [meta, setMeta] = useState('⌘') const [isFullscreen, setIsFullscreen] = useState(false) + const listRef = useRef(null) const page = pages[pages.length - 1] @@ -255,15 +292,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm }) } - // --------------------------------------------------------------------------- - // Group registered actions by group (preserving insertion order) - // --------------------------------------------------------------------------- - const grouped = actions.reduce>((acc, action) => { - const list = acc.get(action.group) ?? [] - list.push(action) - acc.set(action.group, list) - return acc - }, new Map()) + const grouped = rankedGroups(actions, inputValue) const onClose = () => setOpen(false) const onBack = () => { @@ -326,7 +355,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm /> - + {(!emptyAction || page) && ( No commands found. @@ -344,7 +373,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm {/* ── Root view: render from registry ───────────────────────── */} {!page && - Array.from(grouped.entries()).map(([group, groupActions]) => ( + grouped.map(([group, groupActions]) => ( {groupActions.map((action) => ( )} + {/* Footer hint */}
diff --git a/packages/editor/src/components/ui/command-palette/ranked-groups.test.ts b/packages/editor/src/components/ui/command-palette/ranked-groups.test.ts new file mode 100644 index 0000000000..aaf16ccccc --- /dev/null +++ b/packages/editor/src/components/ui/command-palette/ranked-groups.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { rankedGroups } from '.' + +const action = (group: string, label: string, keywords?: string[]) => ({ + id: label, + group, + label, + keywords, + execute: () => {}, +}) + +// What a host's palette holds: weak fuzzy matches for "theme" registered before the strong one. +const actions = [ + action('Generate', 'Generate house from template: The Poppy', ['template', 'adu', 'poppy']), + action('Viewer Controls', 'Switch to Solid', ['solid', 'shading', 'render', 'mode']), + action('View', 'Use dark theme', ['theme', 'appearance', 'interface']), + action('Export & Share', 'Take Screenshot', ['screenshot', 'capture', 'image']), +] + +const order = (search: string) => rankedGroups(actions, search).map(([group]) => group) + +describe('command palette groups', () => { + test('a search lists the group with the best match first', () => { + expect(order('theme')[0]).toBe('View') + expect(order('solid')[0]).toBe('Viewer Controls') + }) + + test('with no search the groups keep the order they were registered in', () => { + expect(order('')).toEqual(['Generate', 'Viewer Controls', 'View', 'Export & Share']) + }) +})