feat(search): add opt-in fuzzy matching to cross-session search - #220
feat(search): add opt-in fuzzy matching to cross-session search#2201fanwang wants to merge 2 commits into
Conversation
Cross-session search (Cmd+K) matched only exact case-insensitive substrings, so a small typo or approximate spelling returned nothing. Add a "Fuzzy" toggle that scores entries with Fuse.js, ranking approximate matches and deriving snippets from the matched character ranges. Fuzzy is opt-in (default off): exact stays the fast path and existing behaviour and tests are unchanged. The flag threads through the IPC and HTTP search routes so both the Electron app and the standalone/Docker server honour it.
There was a problem hiding this comment.
Code Review
This pull request introduces fuzzy search capabilities to the session search functionality using the fuse.js library. It updates the backend search routes, IPC handlers, and services to accept an optional fuzzy parameter, adds a 'Fuzzy' toggle button to the command palette UI, and includes corresponding tests. The review feedback highlights a critical performance bottleneck where Fuse instances are rebuilt on every query, suggesting a caching mechanism using WeakMap. Additionally, it addresses a UX issue where collapsing disjointed match ranges results in excessively large text snippets, recommending that only the first match range be used.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // Fuzzy search tuning (opt-in). Fuse threshold: 0 = exact, 1 = match anything. | ||
| const FUZZY_THRESHOLD = 0.4; | ||
| const FUZZY_MIN_MATCH_CHAR_LENGTH = 2; |
There was a problem hiding this comment.
Performance Bottleneck: Rebuilding Fuse Index on Every Query
Currently, a new Fuse instance is instantiated and indexed for every session file on every single keystroke/search query. For projects with many sessions, this
Since the extracted entries are already cached by SearchTextCache and their reference remains stable unless the file is modified, we can cache the Fuse instances using a WeakMap keyed by the entries array. To make the Fuse instance fully query-independent and reusable, we should also make minMatchCharLength static.
| // Fuzzy search tuning (opt-in). Fuse threshold: 0 = exact, 1 = match anything. | |
| const FUZZY_THRESHOLD = 0.4; | |
| const FUZZY_MIN_MATCH_CHAR_LENGTH = 2; | |
| // Fuzzy search tuning (opt-in). Fuse threshold: 0 = exact, 1 = match anything. | |
| const FUZZY_THRESHOLD = 0.4; | |
| const FUZZY_MIN_MATCH_CHAR_LENGTH = 2; | |
| const fuseCache = new WeakMap<SearchableEntry[], Fuse<SearchableEntry>>(); |
| const fuse = new Fuse(entries, { | ||
| keys: ['text'], | ||
| includeMatches: true, | ||
| includeScore: true, | ||
| ignoreLocation: true, | ||
| threshold: FUZZY_THRESHOLD, | ||
| minMatchCharLength: Math.max(FUZZY_MIN_MATCH_CHAR_LENGTH, Math.min(query.length, 3)), | ||
| }); |
There was a problem hiding this comment.
Retrieve Cached Fuse Instance
Use the fuseCache WeakMap to retrieve the pre-built Fuse instance for the stable entries array. If it doesn't exist, instantiate it and cache it. We also use a static minMatchCharLength of FUZZY_MIN_MATCH_CHAR_LENGTH to ensure the cached instance is reusable across different query lengths.
let fuse = fuseCache.get(entries);
if (!fuse) {
fuse = new Fuse(entries, {
keys: ['text'],
includeMatches: true,
includeScore: true,
ignoreLocation: true,
threshold: FUZZY_THRESHOLD,
minMatchCharLength: FUZZY_MIN_MATCH_CHAR_LENGTH,
});
fuseCache.set(entries, fuse);
}| let minStart = Infinity; | ||
| let maxEnd = -1; | ||
| for (const [rangeStart, rangeEnd] of indices) { | ||
| if (rangeStart < minStart) minStart = rangeStart; | ||
| if (rangeEnd > maxEnd) maxEnd = rangeEnd; | ||
| } | ||
|
|
||
| return { start: Math.max(0, minStart), end: Math.min(textLength, maxEnd + 1) }; |
There was a problem hiding this comment.
UX Issue: Excessively Large Matched Spans
If a long message contains multiple disjoint matches (e.g., one at the very beginning and one at the end), collapsing all indices into a single span will result in an extremely large matchedText and context block. This can break the compact layout of the command palette.
To keep the snippet focused and compact, we should use the first match range returned by Fuse.js instead of blindly collapsing all disjoint ranges.
| let minStart = Infinity; | |
| let maxEnd = -1; | |
| for (const [rangeStart, rangeEnd] of indices) { | |
| if (rangeStart < minStart) minStart = rangeStart; | |
| if (rangeEnd > maxEnd) maxEnd = rangeEnd; | |
| } | |
| return { start: Math.max(0, minStart), end: Math.min(textLength, maxEnd + 1) }; | |
| const [firstStart, firstEnd] = indices[0]; | |
| return { start: Math.max(0, firstStart), end: Math.min(textLength, firstEnd + 1) }; |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (3)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughAdds opt-in fuzzy search using ChangesFuzzy Search Feature
Suggested labels: Merge Risk: ⚪ Minimal · up to This change adds an opt-in typo-tolerant search mode while preserving exact search as the default. Fuzzy results are ranked before limiting, with no current merge-readiness risk identified. 🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
Full details: Linked Issues checkExplanation The pull request implements the linked issue requirements [ Full details: Out of Scope Changes checkExplanation The changes stay within the linked issue scope [ Warning Some tools did not complete. Review the errors below. 🔧 ESLint
src/renderer/components/search/CommandPalette.tsxOops! Something went wrong! :( ESLint: 9.39.5 Error: Error while loading rule 'tailwindcss/no-contradicting-classname': Could not find tailwindcss ... [truncated 801 characters] ... ) test/main/ipc/globalSearch.test.tsESLint skipped: the matched ESLint configuration already failed (plugin-compatibility). test/main/services/discovery/SessionSearcher.test.tsESLint skipped: the matched ESLint configuration already failed (plugin-compatibility). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
test/main/ipc/globalSearch.test.ts (1)
136-137: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd one positive-path assertion for
fuzzy=trueforwarding.These expectations only pin the default
falsepath. IfsearchAllProjects(..., true)accidentally still forwardsfalse, this suite stays green while the new opt-in behavior is broken.Suggested follow-up test
+ it('should forward fuzzy=true to session search', async () => { + const now = Date.now(); + mockScan.mockResolvedValue([ + { + id: 'project1', + path: '/path/to/project1', + name: 'Project 1', + sessions: ['session1'], + createdAt: now, + }, + ] satisfies Project[]); + + mockSearchSessions.mockResolvedValue({ + results: [], + totalMatches: 0, + sessionsSearched: 1, + query: 'test', + } satisfies SearchSessionsResult); + + await projectScanner.searchAllProjects('test', 50, true); + + expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 50, true); + });Also applies to: 263-263
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/main/ipc/globalSearch.test.ts` around lines 136 - 137, Add a positive-path assertion in the global search tests to verify that searchAllProjects forwards fuzzy=true correctly. Update the relevant test around mockSearchSessions so it covers the opt-in branch and asserts the call uses true for the fuzzy argument, alongside the existing default false coverage, to catch regressions in searchAllProjects behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/main/services/discovery/ProjectScanner.ts`:
- Around line 1112-1116: The global fuzzy path in
ProjectScanner.searchAllProjects still truncates by batch order and
timestamp-based merging, so better matches from later projects can be dropped
before ranking. Update the searchAllProjects flow to collect results from all
project batches first, compute a global relevance score for fuzzy matches, then
sort/scored-rank the combined set before applying maxResults. Make sure the
fuzzy branch uses the same search result aggregation path as the project-level
search helpers rather than relying on early exits or timestamp ordering.
In `@src/main/services/discovery/SessionSearcher.ts`:
- Around line 64-65: The fuzzy search path in SessionSearcher is still being
truncated per session file, so older files with stronger matches can be skipped.
Update the search flow around the search method and collectFuzzyMatches() so
fuzzy candidates are accumulated across all scanned sessions first, then sorted
by score and only truncated to maxResults afterward. Keep the non-fuzzy recency
behavior unchanged, and apply the same fix to the other fuzzy call site noted in
the diff.
In `@src/renderer/components/search/CommandPalette.tsx`:
- Line 247: Reset the sticky fuzzy search mode when the command palette opens so
each Cmd+K session starts in the default exact-substring search behavior. Update
the open/reset effect in CommandPalette to clear fuzzyEnabled alongside
globalSearchEnabled, and make sure any other open/close or search-reset logic in
CommandPalette and the related search handling around the referenced fuzzy
search code paths also restores fuzzyEnabled to false when reopening.
---
Nitpick comments:
In `@test/main/ipc/globalSearch.test.ts`:
- Around line 136-137: Add a positive-path assertion in the global search tests
to verify that searchAllProjects forwards fuzzy=true correctly. Update the
relevant test around mockSearchSessions so it covers the opt-in branch and
asserts the call uses true for the fuzzy argument, alongside the existing
default false coverage, to catch regressions in searchAllProjects behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 54aae23c-e610-4390-9fea-a72e559aaf54
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (11)
package.jsonsrc/main/http/search.tssrc/main/ipc/search.tssrc/main/services/discovery/ProjectScanner.tssrc/main/services/discovery/SessionSearcher.tssrc/preload/index.tssrc/renderer/api/httpClient.tssrc/renderer/components/search/CommandPalette.tsxsrc/shared/types/api.tstest/main/ipc/globalSearch.test.tstest/main/services/discovery/SessionSearcher.test.ts
Signed-off-by: 1fanwang <1fannnw@gmail.com>
Why
Cross-session search only matched exact case-insensitive substrings. A typo such as
authentcationreturned no sessions about authentication.What
Adds an opt-in Fuzzy toggle backed by Fuse.js. The flag flows through the Electron and standalone HTTP paths.
Fuzzy mode ranks matches across every scanned session and project before applying
maxResults. Fuse indexes are reused while cached session entries remain unchanged, and reopening the command palette restores exact search as the default.Exact substring search keeps its existing recent-first fast path.
Validation
Raw logs
Closes #219
Summary by CodeRabbit
New Features
Bug Fixes