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 1 commit 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) }; |
📝 WalkthroughWalkthroughAdds opt-in fuzzy search using ChangesFuzzy Search Feature
Suggested labels
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: private package registry requires authentication. Disable ESLint in CodeRabbit settings or use public packages. 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
| async searchAllProjects( | ||
| query: string, | ||
| maxResults: number = 50, | ||
| fuzzy: boolean = false | ||
| ): Promise<SearchSessionsResult> { |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Global fuzzy search is still capped by batch order.
Forwarding fuzzy here doesn't change the existing early exit once earlier project batches produce maxResults, and this method still orders merged hits by timestamp. A better fuzzy hit from a later project can never outrank weaker earlier hits, so global fuzzy mode is not actually rank-based. Collect/scored-sort across projects before truncating.
Also applies to: 1146-1148
🤖 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 `@src/main/services/discovery/ProjectScanner.ts` around lines 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.
| maxResults: number = 50, | ||
| fuzzy: boolean = false |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Don't truncate fuzzy results by session recency.
With fuzzy enabled, this still stops after the first maxResults hits from the newest session files. collectFuzzyMatches() only ranks within one file, so stronger matches in older sessions can be skipped entirely and project-scoped fuzzy search becomes "recent first" instead of score-ranked. Aggregate fuzzy candidates across scanned sessions before sorting/truncating.
Also applies to: 145-146
🤖 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 `@src/main/services/discovery/SessionSearcher.ts` around lines 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.
| const [totalMatches, setTotalMatches] = useState(0); | ||
| const [searchIsPartial, setSearchIsPartial] = useState(false); | ||
| const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false); | ||
| const [fuzzyEnabled, setFuzzyEnabled] = useState(false); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reset fuzzy mode when the palette reopens.
Line 247 adds a sticky fuzzyEnabled state, but the open/reset effect only clears globalSearchEnabled. After one fuzzy search, closing and reopening Cmd+K keeps fuzzy matching on, so exact substring search is no longer the default path for a fresh palette session.
Suggested fix
if (commandPaletteOpen && inputRef.current) {
inputRef.current.focus();
setQuery('');
setSessionResults([]);
setSelectedIndex(0);
setTotalMatches(0);
setSearchIsPartial(false);
setGlobalSearchEnabled(false);
+ setFuzzyEnabled(false);
setSessionIdMatch(null);
setPartialIdMatches([]);
}Also applies to: 674-703
🤖 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 `@src/renderer/components/search/CommandPalette.tsx` at 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.
Why
Cross-session search (Cmd+K, including its "Global" mode) matches only exact
case-insensitive substrings. A small typo or approximate phrase returns
nothing — searching
authentcationsurfaces none of the sessions about"authentication". For recall across many transcripts, exact-only is the wrong
default.
What
Adds an opt-in Fuzzy toggle to the command palette, next to the existing
Global toggle. When on,
SessionSearcherscores entries with Fuse.js andreturns ranked, typo-tolerant results; snippets come from the matched ranges.
The flag threads through both the IPC and HTTP search routes, so the Electron
app and the standalone/Docker server behave the same.
Fuzzy is opt-in (default off): exact substring stays the fast path, and
existing behaviour and tests are unchanged.
New dependency:
fuse.js(small, widely used). Discussed in #219.Validation
pnpm typecheck,pnpm lint,pnpm test, andpnpm buildare clean.Live e2e against the standalone server with a synthetic
CLAUDE_ROOT(onesession containing "authentication middleware deployment"):
Raw logs — exact misses the typo, fuzzy finds it
Closes #219.
Summary by CodeRabbit
New Features
Bug Fixes
Tests