feat(command-palette): sort modes for the browse list - #622
Conversation
The palette has one ordering story and it is built for the keyboard: type
three characters and rankEntries does the work. Not typing gets you catalog
registration order, which is deliberate about authoring adjacency ("like
things stay adjacent") rather than findability. A mouse user who does not
already know a command's name gets ~99 flat rows and no affordance.
Records the design: one control, four modes, empty-query only. Also records
what was deliberately left out and why — no filter chips (grouping subsumes
them), no sort for the other four palette lists, no chord.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four ways to order the command list while the search box is empty: catalog (today's behavior, still the default), A-Z, grouped by surface, and recently used. Persisted as Settings.commandSortMode. The load-bearing constraint is that none of this may touch a SEARCH result. Applying a sort on top of relevance would let A-Z push a tier-5 prefix match below a tier-1 subsequence match, which is the exact inversion class rankEntries was extracted to eliminate. rankCommands enforces it with an early return above the sort, and sortCommands has no notion of a query at all -- it cannot violate a rule it lacks the inputs to violate. browseOrder returns the ordering AND its section headers from one call. An earlier draft had the component call a sort function and a separate grouping function; that is two derivations of one order, and the failure is silent -- a header rendered above the wrong row while the flat array the selection model indexes believes something else. Flattening the groups to produce the list makes them structurally incapable of drifting. Starring composes rather than competes: stars stay hard-partitioned to the top and the chosen sort applies within each half. Letting a sort dissolve the user's explicit pins would mean choosing A-Z silently unpinned everything. In grouped mode that invisible split becomes a leading "Starred" section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The picker sits in the header slot Manage already uses, in commands mode only. While a query is present it shows "Relevance" and disables itself -- the sort genuinely is inert then, and saying so beats letting the user wonder why their setting stopped working. Focus discipline is the whole difficulty in the control: the search input must keep DOM focus throughout, because onKeyDown (arrows, Enter, the mode ladder) is bound to it and typing right after picking a sort has to work. preventDefault on mousedown -- rather than a focus() call afterwards -- means focus never moves in the first place, so there is no restore to sequence against React's commit. Escape closes the menu and stops propagating, or the Dialog's back-out ladder would skip a rung and drop the user's sub-mode. Section headers render inside the existing flat list via an index->label map rather than restructuring it into sections. selectedIndex, arrow navigation, hover, Enter and the clamp effect all keep indexing one flat array, so a keyboard handler whose comments already record three fixed bugs is untouched. Fixes a latent bug on the way through: the scroll-into-view effect resolved rows as listRef.children[selectedIndex], which assumed every child of the list is a selectable row. That was ALREADY false -- the ai-workspace modes render an error banner as a sibling, so every scroll target was off by one while an error showed. Rows now carry data-palette-row and are resolved by attribute, which sibling chrome cannot shift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sort-modes # Conflicts: # src/renderer/src/features/command-palette/ui/CommandPalette.tsx
A Claude and a Codex reviewer went at this independently; both returned FIX-FIRST and both landed on the same seam. Every finding below was verified against the code before acting on it. HIGH -- changing the sort mode left selectedIndex on a different command. Reordering at UNCHANGED length is invisible to both guards: the clamp effect keys on filteredLength, the scroll effect on selectedIndex, and neither moves. So the highlight stayed on row N while row N became something else, and Enter ran a command the user never looked at -- from a catalog containing destructive entries. This is the exact hazard already documented on setQuery, which every enter*Mode callback respects; the sort control was the one reordering path that did not. MEDIUM -- three keys went to the wrong widget. The control's React onKeyDown was unreachable dead code: keepFocusInSearchInput guarantees focus never enters its subtree, and the search input is a SIBLING, not a descendant. So Escape reached Radix and closed the whole palette, while arrows and Enter drove the command list hidden behind the open menu. Replaced with one capture-phase document listener, which beats both competitors (React 18 delegates to the root container; Radix's dismiss layer bubbles) and makes the menu genuinely keyboard-operable, as its ARIA roles always claimed. DESIGN -- grouping keyed on `surface`, which is the conflation CommandCategory was introduced to undo: surface is a machine applicability dimension driving mode gating, and reusing it for presentation means a command cannot be reclassified without changing when it applies. It was also worse at the job -- one section held ~40% of the list. Now groups by category (8 balanced buckets), with a trailing "Other" so uncategorized and extension-contributed commands are never silently dropped from the mode built for discovery. Also: scroll effect now depends on the rendered order, not just the index; section headings are no longer aria-hidden (grouped mode's entire value is the structure it was hiding from assistive tech); the starred divider from #619 is suppressed in grouped mode, where a labelled heading already says it; and the coerceCommandSortMode comment no longer misstates the un-coerced fallback -- it would render as "recent", not catalog. Adds CommandSortControl.renderer.test.tsx, which the plan had declined. That call was right about opening and wrong about keys, and this is the file that would have caught it. Merges origin/main (5 commits, including #619's star column) and resolves the row-render conflict, keeping both the star affordances and grouped headers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round: one Claude + one Codex reviewer, both FIX-FIRST — all findings resolvedTwo orchestrated reviewers went at this independently. Both returned FIX-FIRST and converged on the same seam: the ordering core held up, the popover's integration with the palette's keyboard model did not. Every finding was verified against the code before acting. HIGH — sort change left
|
| Key | Where it actually went |
|---|---|
| Escape | Radix's dismiss handler — closed the entire palette |
| ↑ / ↓ | moved selection in the list behind the open menu |
| Enter | ran paletteCommands[selectedIndex] from that hidden list |
Replaced with a single capture-phase document listener, which beats both competitors (React 18 delegates to the root container; Radix's dismiss layer bubbles). One mechanism fixes all three and makes the menu genuinely keyboard-operable — which its ARIA roles had been claiming all along.
DESIGN — grouping keyed on the wrong axis
Grouping used surface. CommandCategory's own doc comment records that this is the conflation it was introduced to undo: surface is a machine applicability dimension driving mode gating, and reusing it for presentation means a command can't be reclassified without changing when it applies.
It was also just worse at the job. By surface: app 41 / dispatch 34 / session 32 / grid 11 / debug 11 / editor 9 — and since grid and dispatch are mutually exclusive, one section held ~40% of the visible list. By category: session 24 / layout-dispatch 16 / navigate 12 / developer 12 / workspace-tools 11 / editor-files 10 / create 10 / preferences 3.
Now groups by category, with a trailing Other section so uncategorized and extension-contributed commands are never silently dropped from the one mode built for discovery.
Also fixed
- Scroll effect now depends on the rendered order, not just the index — switching modes could otherwise leave the selection off-screen.
- Section headings are no longer
aria-hidden. Grouped mode's entire value is structure, and it was hiding that from assistive tech. - The starred divider from fix(palette): make starred commands actually visible #619 is suppressed in grouped mode, where a labelled
★ Starredheading already says it. coerceCommandSortMode's comment misstated the un-coerced fallback — an unknown mode renders as recent, not catalog.
New test file
CommandSortControl.renderer.test.tsx — which the plan had explicitly declined. That call was right about opening a menu and wrong about keys; this is the file that would have caught the Escape bug. It reproduces the real DOM relationship (input as sibling, focus in input) and asserts behavior, not structure.
Merge
Merged origin/main (5 commits, incl. #619's ★ column) and resolved the row-render conflict, keeping both the star affordances and the grouped headers.
Verification
npm run typecheck— clean, both projects.npm test— 1740 passed, 254 files.- Not visually smoke-tested.
One note on rejected advice: Claude flagged scrollIntoView({block:'nearest'}) clipping a section header when arrowing upward into a group. Real, but purely cosmetic and the fix (scrolling to the header instead of the row) would desync the scroll target from the selection model. Left as-is deliberately.
Adds a sort control to the command palette so the empty-query list can be
ordered four ways: Catalog order (today's behavior, still the default),
A – Z, Grouped by surface, and Recently used.
Why
The palette has exactly one ordering story and it is built for the keyboard:
type three characters and
rankEntriesdoes the work. Not typing gets youcatalog registration order — which is deliberate, but deliberate about
authoring adjacency ("like things stay adjacent"), not about findability.
A mouse-first user who does not already know the command's name gets ~99 flat
rows and no affordance: no alphabet to scan, no categories to narrow by eye.
That is a browse problem, not a search problem, and it only bites when the
query is empty.
Groupedis the mode that motivated this.surfaceis already mandatory onevery
CommandDefand is already carried through toResolvedCommandspecifically so consumers can group by it —
types.tssays so in as manywords. The data model has been waiting for this UI.
The invariant this could have broken
Sorting applies to the empty-query browse state only. The moment the user
types, relevance owns the ordering outright.
This is not a limitation to lift later. A sort applied on top of relevance
would let
A – Zpush a tier-5 prefix match below a tier-1 subsequence match —the exact inversion class
rankEntrieswas extracted to eliminate. Two thingsenforce it:
rankCommandsearly-returns above the sort wheneverquery.length > 0.sortCommandshas no notion of a query at all, so it cannot violate a ruleit lacks the inputs to violate.
The UI makes it legible rather than mysterious: while a query is present the
control shows Relevance and disables itself, so the user is told what the
ordering is instead of wondering why their setting stopped applying.
rankCommands.test.tspins it — results are asserted byte-identical acrossall four modes for a given query, not merely "the winner stayed on top".
Composition with starring
Stars already hoist to the top on an empty query. Sorting composes inside
that partition rather than replacing it: stars stay pinned, and the chosen sort
orders within each half. Letting a sort dissolve the user's explicit pins would
mean choosing
A – Zsilently unpinned everything.In
Groupedmode that currently-invisible split becomes a leading★ Starred section, which is more honest than what ships today.
Design notes
browseOrderreturns the ordering and its section headers from onecall. An earlier draft had the component call a sort function and a
separate grouping function — two derivations of one order, whose failure mode
is silent and ugly (a header above the wrong row while the array the selection
model indexes believes something else). Deriving the flat list by flattening
the groups makes them structurally incapable of drifting.
.map()from an index→label map, soselectedIndex, arrow navigation, hover,Enter and the clamp effect all keep indexing one flat array. A keyboard
handler whose comments already record three separate fixed bugs is untouched.
preventDefaultonmousedownratherthan a
focus()call afterwards, so the search input never loses focus in thefirst place and there is no restore to sequence against React's commit.
Escape closes the menu and stops propagating — otherwise the Dialog's back-out
ladder skips a rung and drops the user's sub-mode.
catalog, so the feature is purely additive: nothing about thepalette changes until the user asks.
Drive-by fix
The scroll-into-view effect resolved rows as
listRef.children[selectedIndex],which assumes every child of the list container is a selectable row. That was
already false on
main: theai-workspace-open/clearmodes render an errorbanner as a sibling of the rows, so while an error was showing every scroll
target was off by one. Grouped mode's headings would have made it wrong in a
fourth mode. Rows now carry
data-palette-rowand are resolved by attribute,which sibling chrome cannot shift.
Verification
npm run typecheck— clean (both projects).npm test— 1732 passed, 253 files.npx electron-vite build— succeeds.sortCommands.test.ts(17 cases) andrankCommands.test.ts(6 cases).Not visually smoke-tested in the running app — the popover geometry and the
disabled state are worth a look under
npm run devbefore merge.Note for the author
This branches from
origin/main. Your localmaincheckout has uncommittedwork adding a ★ column and a starred-block separator to the same row-render
JSX, so expect a small conflict there when you commit it.
🤖 Generated with Claude Code