Skip to content

feat(chat): fuzzy-search the "Run in" project picker#205

Merged
Zeus-Deus merged 1 commit into
mainfrom
fuzzy-project-selector-search
Jul 27, 2026
Merged

feat(chat): fuzzy-search the "Run in" project picker#205
Zeus-Deus merged 1 commit into
mainfrom
fuzzy-project-selector-search

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Why

Starting a new agent chat meant scanning an unfiltered project list and clicking the right row. With a dozen-plus projects that is a mouse trip every time.

What

The "Run in" popover is now a cmdk Command with a Search projects… input:

  • opening it focuses the search field — type → ↑/↓Enter, no mouse in the flow
  • "Home directory (~)" filters like any other row (matches home, ~)
  • "Open another project…" sits outside the list and is never filtered — it has to stay reachable exactly when nothing matched
  • the query resets on close, so the next open starts from the full list

Ranking lives in a new src/lib/fuzzy.ts: a scored subsequence matcher where prefix beats substring beats scattered match, word-boundary and camelCase hits score like initials (haphermes-agent-personal, vasvexis-agent-site), and shorter candidates win ties. It also replaces the two copy-pasted fuzzyMatch helpers in the GitHub issue/PR pickers.

One reverted approach, worth knowing

Matching each project against its name and its full path was the obvious first cut, and it doesn't narrow anything — a short query like ve is a subsequence of nearly every long path, so the whole list survived (caught in the browser, not in tests). Matching is name-only now; a query containing / switches the haystack to paths, which is how two checkouts of the same repo get disambiguated. Both behaviors are pinned by tests.

Verification

  • npm run check clean; 3082 tests pass (21 new for the matcher, 10 for the picker)
  • Drove the real UI in the browser pane: typed ve → narrowed to vexis alone → Enter → scope switched and the checkout/branch controls appeared
  • npm run verify's Rust half fails in this worktree for an unrelated reason (src-tauri/binaries/ sidecar absent in a fresh worktree); the diff is frontend-only

Picking a project for a new thread meant scanning a long unfiltered
list and clicking. The popover is now a cmdk Command with a search
input: open it, type, Enter. Arrow keys move the highlight, the query
resets on close, and "Open another project..." stays outside the list
so it is still reachable when nothing matches.

Ranking lives in the new src/lib/fuzzy.ts — a scored subsequence
matcher where prefix beats substring beats scattered match,
word-boundary and camelCase hits score like initials (hap ->
hermes-agent-personal), and shorter candidates win ties. It replaces
the two copy-pasted fuzzyMatch helpers in the GitHub issue/PR pickers.

Matching name AND path at once was tried and dropped: a short query is
a subsequence of nearly every long path, so the list never narrowed. A
query containing "/" switches the haystack to paths instead, which is
how two checkouts of the same repo get disambiguated.
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review — Opus 5 (high effort)

The matcher is good and the reverted approach note is the right call. I hand-checked several of the score claims rather than trusting the tests:

  • cocodemux 63.5 vs dpcode 25.6 (prefix +40 beats substring +20, plus the adjacency bonus). ✓
  • codedpcode 41.8 vs cdmxcodemux-sitev2 12. ✓
  • acpagentChatPane 24 vs agentchatpane 8 (camel humps at indices 5 and 9). ✓
  • daysToNextMonday is correct at every day of week, including the deliberate Monday → 7.

I also checked that replacing the two copy-pasted fuzzyMatch helpers in the GitHub pickers is genuinely behaviour-preserving: old = plain in-order subsequence, new = fuzzyScore(...) !== null which is the same predicate, same empty-query and query-longer-than-text answers, and neither trims. No change for those two callers. ✓

And you got the focus right — dropping onOpenAutoFocus={focusCmdkRootOnOpen} is not just tidier, it is load-bearing. I proved this empirically with a throwaway harness (Popover + Command + CommandInput, with and without the handler):

WITH    focusCmdkRootOnOpen -> activeElement: DIV [cmdk-root], input value after typing: "",     list filtered: no
WITHOUT focusCmdkRootOnOpen -> activeElement: INPUT,           input value after typing: "beta", list filtered: yes

See my comment on #212, which keeps the handler and therefore ships a search box that does nothing until you click it.

Three small things

1. Empty-query empty state. noMatches = !homeVisible && projectRows.length === 0. With showHomeOption false and no projects, an empty query renders No projects match “”. Guard on query.trim() !== "".

2. Home matches loosely. fuzzyMatch("home directory ~", query) is a subsequence test over a 16-character string, so hi, dir, red, moe all keep the Home row alive. Harmless, but it means Home lingers on queries that plainly mean a project. A prefix/word-boundary test on ["home", "~"] would be tighter.

3. isCamelHump indexes the original string with a lowercased-string index.

const at = haystack.indexOf(needle[qi], cursor);   // haystack = text.toLowerCase()
...
else if (isCamelHump(text, at)) score += 8;        // text = original

Deliberate (you need the original case to see a hump) and safe for ASCII, but String.prototype.toLowerCase is not length-preserving for every code point — İ (U+0130) lowercases to two chars — so at can drift past that point in a name containing one. Only a mis-scoring, never a crash, but a one-line text.length === haystack.length guard would make it airtight.

Note this PR collides head-on with #212, which adds a different search implementation to the same popover. Only one can land as-is.

@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review complete by GPT 5.6 Sol xhigh.

I reviewed the fuzzy scorer/ranking, name-vs-path matching rule, cmdk focus and keyboard selection, empty state, query reset, and open-project escape hatch. The focused matcher and picker suites passed 51/51 tests.

GPT 5.6 Sol xhigh found no errors in this PR as it stands against main.

Coordination note: a pairwise merge-tree check shows textual conflicts with open PRs #211 and #212 in the same picker. Those changes need to be integrated deliberately so fuzzy search survives the scope-row simplification and Active/Settled sectioning.

@Zeus-Deus
Zeus-Deus merged commit 01065e0 into main Jul 27, 2026
4 checks passed
Zeus-Deus added a commit that referenced this pull request Jul 27, 2026
The location picker's new search field never received focus: the
popover's onOpenAutoFocus handler focused the cmdk root div, and since
neither shadcn's CommandInput nor cmdk's Command.Input sets autoFocus —
and cmdk's root keydown only handles navigation keys — printable
keystrokes went nowhere until the user clicked the box. The same
handler/CommandInput pairing shipped in the branch picker, the launch
model picker, and the multi-provider model picker.

Fix it centrally: the open-autofocus handler (now focusCmdkOnOpen) aims
at [cmdk-input] when the popover has one and falls back to [cmdk-root]
otherwise, so input-less pickers keep their arrow-key navigation.
Dropping the handler on input-bearing popovers would not have been
enough: in the multi-provider picker the provider rail's buttons precede
the input in the DOM, so Radix's first-tabbable default would focus the
rail. The handler's docstring claimed the input's own autoFocus made
root-focus safe — no such autoFocus exists; the docstring now describes
the real contract. Regression tests type with NO prior click on the
input, the interaction that masked the bug (userEvent.type clicks
first, focusing the input as a side effect).

Also stop matching project paths in the location search. Project paths
share a long common prefix (/home/…/projects/), so any short query that
grazed it subsequence-matched every row at an identical score and the
filter read as dead — the same failure that got name-and-path matching
reverted in the workspace switcher (#205). The item value keeps the
path after a tab separator purely to stay unique across same-basename
projects (cmdk keys selection state on value); a custom filter strips
it before scoring.

partitionProjectScopes now documents that any future parked lifecycle
beyond settled must be folded into the Active/Settled partition, or
all-parked projects would read as Active here.
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