Skip to content

feat(search): add opt-in fuzzy matching to cross-session search - #220

Open
1fanwang wants to merge 1 commit into
matt1398:mainfrom
1fanwang:fuzzy-cross-session-search
Open

feat(search): add opt-in fuzzy matching to cross-session search#220
1fanwang wants to merge 1 commit into
matt1398:mainfrom
1fanwang:fuzzy-cross-session-search

Conversation

@1fanwang

@1fanwang 1fanwang commented Jun 30, 2026

Copy link
Copy Markdown

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 authentcation surfaces 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, SessionSearcher scores entries with Fuse.js and
returns 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, and pnpm build are clean.

Live e2e against the standalone server with a synthetic CLAUDE_ROOT (one
session containing "authentication middleware deployment"):

Raw logs — exact misses the typo, fuzzy finds it
# CONTROL — exact 'authentication' (correct spelling)
GET /api/search?q=authentication          -> totalMatches = 2

# EXACT — 'authentcation' (typo, no fuzzy)
GET /api/search?q=authentcation           -> totalMatches = 0

# FUZZY — 'authentcation' (typo)
GET /api/search?q=authentcation&fuzzy=1   -> totalMatches = 2   (matchedText: "authentication")

# FUZZY — 'middlewear' (typo)
GET /api/search?q=middlewear&fuzzy=1      -> totalMatches = 2   (matchedText: "middleware")

Closes #219.

Summary by CodeRabbit

  • New Features

    • Added optional fuzzy search across session and project searches for better typo-tolerant results.
    • Added a new toggle in the search UI to switch fuzzy matching on or off.
    • Search requests now support an extra search option in both app and browser modes.
  • Bug Fixes

    • Search behavior now consistently passes the selected matching mode through all search paths.
  • Tests

    • Added coverage for fuzzy search behavior and updated existing search expectations.

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.
@coderabbitai coderabbitai Bot added dependencies Pull requests that update a dependency file feature request New feature or request labels Jun 30, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +34 to +36
// Fuzzy search tuning (opt-in). Fuse threshold: 0 = exact, 1 = match anything.
const FUZZY_THRESHOLD = 0.4;
const FUZZY_MIN_MATCH_CHAR_LENGTH = 2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 $O(N)$ index reconstruction is highly CPU-intensive and can block the Electron main process, causing noticeable UI stuttering.

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.

Suggested change
// 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>>();

Comment on lines +329 to +336
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)),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);
    }

Comment on lines +426 to +433
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) };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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) };

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds opt-in fuzzy search using fuse.js across the full stack. SessionSearcher gains a fuzzy flag that routes matching through a Fuse index with snippet span resolution. The flag is threaded through ProjectScanner, IPC and HTTP handlers, the preload bridge, shared types, and HttpAPIClient. CommandPalette gains a fuzzyEnabled state and a Sparkles-icon toggle button.

Changes

Fuzzy Search Feature

Layer / File(s) Summary
SessionSearcher fuzzy matching core
package.json, src/main/services/discovery/SessionSearcher.ts
Adds fuse.js dependency and tuning constants (FUZZY_THRESHOLD, FUZZY_MIN_MATCH_CHAR_LENGTH). Extends searchSessions and searchSessionFile with a fuzzy flag. Adds collectFuzzyMatches (builds Fuse index, returns ranked results with matchedText) and resolveFuzzyMatchSpan (collapses Fuse match indices into a single [start, end) snippet span with fallback).
ProjectScanner delegation
src/main/services/discovery/ProjectScanner.ts
Extends searchSessions and searchAllProjects signatures with fuzzy: boolean = false and forwards the value to the SessionSearcher delegate calls.
IPC and HTTP transport
src/main/ipc/search.ts, src/main/http/search.ts
IPC handlers for search-sessions and search-all-projects accept fuzzy?: boolean and pass fuzzy === true to ProjectScanner. HTTP route query schemas add fuzzy?: string and derive the boolean from '1'/'true' before forwarding.
Preload bridge, shared types, and HTTP client
src/shared/types/api.ts, src/preload/index.ts, src/renderer/api/httpClient.ts
ElectronAPI.searchAllProjects type gains fuzzy?: boolean. Preload bridge forwards fuzzy through IPC. HttpAPIClient adds fuzzy=1 to query params on both search endpoints when enabled.
CommandPalette fuzzy toggle UI
src/renderer/components/search/CommandPalette.tsx
Adds fuzzyEnabled state (default false), passes it to api.searchAllProjects/api.searchSessions, adds it to the effect dependency array, and renders a Sparkles-icon "Fuzzy" toggle button next to the existing "Global" button.
Tests
test/main/services/discovery/SessionSearcher.test.ts, test/main/ipc/globalSearch.test.ts
New SessionSearcher tests verify typo queries return zero matches under exact mode and non-zero matches under fuzzy mode, and that exact queries still match with fuzzy enabled. Global search IPC tests updated to expect the false fuzzy trailing argument.

Suggested labels

feature request, dependencies

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the opt-in Fuzzy toggle, Fuse.js-backed ranking, snippet ranges, and consistent IPC/HTTP support in both global and project search.
Out of Scope Changes check ✅ Passed The changes stay focused on fuzzy cross-session search and its supporting dependency, tests, and wiring.

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
test/main/ipc/globalSearch.test.ts (1)

136-137: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add one positive-path assertion for fuzzy=true forwarding.

These expectations only pin the default false path. If searchAllProjects(..., true) accidentally still forwards false, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 16cc3c8 and ae800d0.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • package.json
  • src/main/http/search.ts
  • src/main/ipc/search.ts
  • src/main/services/discovery/ProjectScanner.ts
  • src/main/services/discovery/SessionSearcher.ts
  • src/preload/index.ts
  • src/renderer/api/httpClient.ts
  • src/renderer/components/search/CommandPalette.tsx
  • src/shared/types/api.ts
  • test/main/ipc/globalSearch.test.ts
  • test/main/services/discovery/SessionSearcher.test.ts

Comment on lines +1112 to +1116
async searchAllProjects(
query: string,
maxResults: number = 50,
fuzzy: boolean = false
): Promise<SearchSessionsResult> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +64 to +65
maxResults: number = 50,
fuzzy: boolean = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file feature request New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Fuzzy matching for cross-session search

1 participant