Skip to content

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

Open
1fanwang wants to merge 2 commits 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 2 commits into
matt1398:mainfrom
1fanwang:fuzzy-cross-session-search

Conversation

@1fanwang

@1fanwang 1fanwang commented Jun 30, 2026

Copy link
Copy Markdown

Why

Cross-session search only matched exact case-insensitive substrings. A typo such as authentcation returned 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

ROOT=/tmp/claude-devtools-fuzzy-e2e
mkdir -p "$ROOT/projects/-tmp-fuzzy-project"
printf '%s\n' '{"uuid":"user-1","type":"user","timestamp":"2026-09-03T16:00:00.000Z","message":{"role":"user","content":"authentication middleware deployment"},"isMeta":false}' \
  > "$ROOT/projects/-tmp-fuzzy-project/session-1.jsonl"
pnpm standalone:build

# Terminal 1
HOST=127.0.0.1 PORT=3457 CLAUDE_ROOT="$ROOT" node dist-standalone/index.cjs

# Terminal 2
curl -s 'http://127.0.0.1:3457/api/search?q=authentcation' | jq '.totalMatches'
curl -s 'http://127.0.0.1:3457/api/search?q=authentcation&fuzzy=1' \
  | jq -c '{totalMatches, matchedText: .results[0].matchedText, sessionId: .results[0].sessionId}'
Raw logs
# Exact search
0

# Fuzzy search
{"totalMatches":1,"matchedText":"authentication","sessionId":"session-1"}

# Before ae800d0a93c6f3a984e65873c011e2d404607890
expected 'newer' to be 'older'
expected "spy" to be called 9 times, but got 8 times
Tests  2 failed | 13 passed

# After 7eab2f60eb4314eada56b71012daa2ae7924c243
Test Files  2 passed (2)
Tests       15 passed (15)

Closes #219

Summary by CodeRabbit

  • New Features

    • Added an opt-in Fuzzy search toggle in the command palette.
    • Fuzzy search can find results despite spelling variations or typos.
    • Results are ranked by match quality across sessions and projects before the result limit is applied.
    • Standard search behavior remains unchanged when fuzzy matching is disabled.
  • Bug Fixes

    • Improved search result selection so stronger matches are prioritized over newer but weaker matches when fuzzy search is enabled.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 7eab2f6.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 7eab2f6.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 7eab2f6.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 598a432d-d3d3-41e3-b731-cac1d9b29900

📥 Commits

Reviewing files that changed from the base of the PR and between ae800d0 and 7eab2f6.

📒 Files selected for processing (6)
  • src/main/services/discovery/ProjectScanner.ts
  • src/main/services/discovery/SessionSearcher.ts
  • src/main/types/domain.ts
  • src/renderer/components/search/CommandPalette.tsx
  • test/main/ipc/globalSearch.test.ts
  • test/main/services/discovery/SessionSearcher.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/renderer/components/search/CommandPalette.tsx
  • test/main/ipc/globalSearch.test.ts
  • src/main/services/discovery/ProjectScanner.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Adds opt-in fuzzy search using fuse.js across session and global project search. The fuzzy flag passes through SessionSearcher, ProjectScanner, IPC, HTTP, preload, shared types, and HttpAPIClient. CommandPalette adds a Fuzzy toggle. Tests cover score-based ranking before result limiting.

Changes

Fuzzy Search Feature

Layer / File(s) Summary
Fuzzy matching and result ranking
package.json, src/main/services/discovery/SessionSearcher.ts, src/main/types/domain.ts, test/main/services/discovery/SessionSearcher.test.ts
Adds Fuse.js matching, cached indexes, matchScore, fuzzy snippet spans, and deferred maxResults limiting. Exact search behavior remains unchanged.
Cross-project search aggregation
src/main/services/discovery/ProjectScanner.ts, test/main/ipc/globalSearch.test.ts
Forwards the fuzzy flag, scans all projects in fuzzy mode, and ranks merged results by matchScore before applying maxResults.
Search transport and client APIs
src/main/http/search.ts, src/main/ipc/search.ts, src/preload/index.ts, src/shared/types/api.ts, src/renderer/api/httpClient.ts
Propagates fuzzy search through IPC and HTTP routes. HTTP clients send fuzzy=1 when enabled.
CommandPalette toggle
src/renderer/components/search/CommandPalette.tsx
Adds a Fuzzy toggle, passes its state to search calls, includes it in effect dependencies, and resets it when the palette opens.

Suggested labels: feature request, dependencies

Merge Risk: ⚪ Minimal · up to 7eab2

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)
Check name Status Explanation
Linked Issues check ✅ Passed The pull request implements the linked issue requirements [#219]. It adds an opt-in Fuzzy toggle that defaults off, supports project-scoped and global search, threads the fuzzy flag through Electron I…
Out of Scope Changes check ✅ Passed The changes stay within the linked issue scope [#219]. They add the Fuse.js dependency, search API plumbing, fuzzy matching and ranking, the command-palette toggle, result metadata, and related tests.…
Full details: Linked Issues check

Explanation

The pull request implements the linked issue requirements [#219]. It adds an opt-in Fuzzy toggle that defaults off, supports project-scoped and global search, threads the fuzzy flag through Electron IPC and HTTP routes, uses Fuse.js for typo-tolerant scoring, ranks results before applying maxResults, preserves exact search as the default path, and adds coverage for fuzzy ranking.

Full details: Out of Scope Changes check

Explanation

The changes stay within the linked issue scope [#219]. They add the Fuse.js dependency, search API plumbing, fuzzy matching and ranking, the command-palette toggle, result metadata, and related tests. No unrelated code changes are identified.

Warning

Some tools did not complete. Review the errors below.

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

src/renderer/components/search/CommandPalette.tsx

Oops! Something went wrong! :(

ESLint: 9.39.5

Error: Error while loading rule 'tailwindcss/no-contradicting-classname': Could not find tailwindcss
Occurred while linting /src/renderer/components/search/CommandPalette.tsx
at new TailwindUtils (/.eslint-tmp/node_modules/.pnpm/tailwind-api-utils@1.0.3_tailwindcss@3.4.19_tsx@4.23.13_/node_modules/tailwind-api-utils/dist/index.cjs:375:13)
at resolve (/.eslint-tmp/node_modules/.pnpm/eslint-plugin-tailwindcss@3.18.3_tailwindcss@3.4.19_tsx@4.23.13_/node_modules/eslint-plugin-tailwindcss/lib/util/customConfig.js:21:27)
at getTailwindConfig (/.eslint-tmp/node_modules/.pnpm/eslint-plugin-tailwindcss@3.18.3_tailwindcss@3.4.19_tsx@4.23.13_/node_modules/eslint-plugin-tailwindcss/lib/util/tailwindAPI.js:9:17)
at Object.create (/.eslint-tmp/node_modules/.pnpm/eslint-plugin-tailwindcss@3.18.3_tailwindcss@3.4.19_tsx@4.23.13_/node_modules/eslint-plugin-tailwindcss/lib/rules/no-contradicting-classname.js:71:26)
at createRuleListener

... [truncated 801 characters] ...

)
at Linter._verifyWithFlatConfigArray (/.eslint-tmp/node_modules/.pnpm/eslint@9.39.5_jiti@1.21.7_supports-color@7.2.0/node_modules/eslint/lib/linter/linter.js:2306:15)
at Linter.verify (/.eslint-tmp/node_modules/.pnpm/eslint@9.39.5_jiti@1.21.7_supports-color@7.2.0/node_modules/eslint/lib/linter/linter.js:1677:10)
at Linter.verifyAndFix (/.eslint-tmp/node_modules/.pnpm/eslint@9.39.5_jiti@1.21.7_supports-color@7.2.0/node_modules/eslint/lib/linter/linter.js:2571:20)
at verifyText (/.eslint-tmp/node_modules/.pnpm/eslint@9.39.5_jiti@1.21.7_supports-color@7.2.0/node_modules/eslint/lib/eslint/eslint-helpers.js:1180:45)
at readAndVerifyFile (/.eslint-tmp/node_modules/.pnpm/eslint@9.39.5_jiti@1.21.7_supports-color@7.2.0/node_modules/eslint/lib/eslint/eslint-helpers.js:1321:10)

test/main/ipc/globalSearch.test.ts

ESLint skipped: the matched ESLint configuration already failed (plugin-compatibility).

test/main/services/discovery/SessionSearcher.test.ts

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

❤️ 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 thread src/main/services/discovery/ProjectScanner.ts
Comment thread src/main/services/discovery/SessionSearcher.ts
Comment thread src/renderer/components/search/CommandPalette.tsx
Signed-off-by: 1fanwang <1fannnw@gmail.com>
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