Skip to content

feat(command-palette): sort modes for the browse list - #622

Merged
Juliusolsson05 merged 5 commits into
mainfrom
feat/command-palette-sort-modes
Jul 28, 2026
Merged

feat(command-palette): sort modes for the browse list#622
Juliusolsson05 merged 5 commits into
mainfrom
feat/command-palette-sort-modes

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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 rankEntries does the work. Not typing gets you
catalog 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.

Grouped is the mode that motivated this. surface is already mandatory on
every CommandDef and is already carried through to ResolvedCommand
specifically so consumers can group by it — types.ts says so in as many
words. 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 – Z push a tier-5 prefix match below a tier-1 subsequence match —
the exact inversion class rankEntries was extracted to eliminate. Two things
enforce it:

  • rankCommands early-returns above the sort whenever query.length > 0.
  • sortCommands has no notion of a query at all, so it cannot violate a rule
    it 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.ts pins it — results are asserted byte-identical across
all 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 – Z silently unpinned everything.

In Grouped mode that currently-invisible split becomes a leading
★ Starred section, which is more honest than what ships today.

Design notes

  • 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 — 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.
  • Headers do not restructure the list. They render inside the existing flat
    .map() from an index→label map, so selectedIndex, 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.
  • Focus discipline in the control: preventDefault on mousedown rather
    than a focus() call afterwards, so the search input never loses focus in the
    first 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.
  • Default is catalog, so the feature is purely additive: nothing about the
    palette 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:
the ai-workspace-open/clear modes render an error
banner 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-row and 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.
  • New: sortCommands.test.ts (17 cases) and rankCommands.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 dev before merge.

Note for the author

This branches from origin/main. Your local main checkout has uncommitted
work 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

Juliusolsson05 and others added 5 commits July 28, 2026 16:26
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>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review round: one Claude + one Codex reviewer, both FIX-FIRST — all findings resolved

Two 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 selectedIndex on a different command

Reordering at unchanged length is invisible to both guards: the clamp effect keys on filteredLength (doesn't move), the scroll effect on selectedIndex (doesn't move). 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 that contains risk: 'destructive' entries.

This is the exact hazard already documented on the setQuery handler, which every one of the nine enter*Mode callbacks respects. The sort control was the one reordering path that didn't. Fixed.

MEDIUM — three keys were going 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 the handler could never fire:

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 ★ Starred heading 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 test1740 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.

@Juliusolsson05
Juliusolsson05 merged commit 8802b9e into main Jul 28, 2026
1 check passed
@Juliusolsson05
Juliusolsson05 deleted the feat/command-palette-sort-modes branch July 28, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant