refactor(search): session sidebar and project-context filters use the shared matcher

Sidebar session/folder/group search and the Todos, Memory, Plans and
Notes filters required the whole query as one literal substring; they
now match tokens in any order and ignore punctuation via
matchesRankQuery, keeping their own list order and tree structure.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 15:04:14 +03:00
parent c02d7ff399
commit da309a2a8d
7 changed files with 27 additions and 26 deletions
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -186,13 +187,10 @@ export const MemorySection: React.FC<{
}; };
}, [markViewed, viewKey]); }, [markViewed, viewKey]);
const visibleEntries = React.useMemo(() => { const visibleEntries = React.useMemo(
const needle = query.trim().toLowerCase(); () => entries.filter((entry) => matchesRankQuery([entry.title, entry.body], query)),
if (!needle) return entries; [entries, query],
return entries.filter((entry) => ( );
entry.title.toLowerCase().includes(needle) || entry.body.toLowerCase().includes(needle)
));
}, [entries, query]);
const handleDelete = React.useCallback(async (memoryId: string) => { const handleDelete = React.useCallback(async (memoryId: string) => {
if (!await deleteEntry(scope, memoryId)) { if (!await deleteEntry(scope, memoryId)) {
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -178,11 +179,10 @@ export const NotesSection: React.FC<{
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody); const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
const deleteNote = useProjectContextStore((state) => state.deleteNote); const deleteNote = useProjectContextStore((state) => state.deleteNote);
const visibleNotes = React.useMemo(() => { const visibleNotes = React.useMemo(
const needle = query.trim().toLowerCase(); () => notes.filter((note) => matchesRankQuery([note.body], query)),
if (!needle) return notes; [notes, query],
return notes.filter((note) => note.body.toLowerCase().includes(needle)); );
}, [notes, query]);
// The store keeps the failure reason; without passing it through, every // The store keeps the failure reason; without passing it through, every
// failure looks identical to the user and tells them nothing about the cause. // failure looks identical to the user and tells them nothing about the cause.
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -146,11 +147,10 @@ export const PlansSection: React.FC<{
[onTogglePinned, projectRef, t] [onTogglePinned, projectRef, t]
); );
const visiblePlans = React.useMemo(() => { const visiblePlans = React.useMemo(
const needle = query.trim().toLowerCase(); () => plans.filter((plan) => matchesRankQuery([plan.title], query)),
if (!needle) return plans; [plans, query],
return plans.filter((plan) => plan.title.toLowerCase().includes(needle)); );
}, [plans, query]);
const handleOpenPlan = React.useCallback( const handleOpenPlan = React.useCallback(
(plan: ProjectPlanLink) => { (plan: ProjectPlanLink) => {
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { import {
DndContext, DndContext,
@@ -183,11 +184,10 @@ export const TodosSection: React.FC<{
const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0); const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0);
// Filtering is display-only: every handler above still edits the full list, // Filtering is display-only: every handler above still edits the full list,
// so reordering or clearing while a filter is active cannot drop hidden items. // so reordering or clearing while a filter is active cannot drop hidden items.
const visibleTodos = React.useMemo(() => { const visibleTodos = React.useMemo(
const needle = query.trim().toLowerCase(); () => todos.filter((todo) => matchesRankQuery([todo.text], query)),
if (!needle) return todos; [query, todos],
return todos.filter((todo) => todo.text.toLowerCase().includes(needle)); );
}, [query, todos]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
@@ -483,7 +484,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
return true; return true;
} }
const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery); const folderMatches = matchesRankQuery([entry.folder.name], normalizedSessionSearchQuery);
if (folderMatches || entry.nodes.length > 0) { if (folderMatches || entry.nodes.length > 0) {
keepByFolderId.set(folderId, true); keepByFolderId.set(folderId, true);
return true; return true;
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree'; import type { WorktreeMetadata } from '@/types/worktree';
@@ -44,7 +45,7 @@ export const useSessionGrouping = (args: Args) => {
} }
return nodes.flatMap((node) => { return nodes.flatMap((node) => {
const nodeMatches = buildSessionSearchText(node.session).includes(query); const nodeMatches = matchesRankQuery([buildSessionSearchText(node.session)], query);
if (nodeMatches) { if (nodeMatches) {
return [node]; return [node];
} }
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode, GroupSearchData } from '../types'; import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
@@ -161,10 +162,10 @@ export const useSessionSidebarSections = (args: Args) => {
section.groups.forEach((group) => { section.groups.forEach((group) => {
const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery); const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery);
const matchedSessionCount = countNodes(filteredNodes); const matchedSessionCount = countNodes(filteredNodes);
const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery); const groupMatches = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery);
const scopeKey = normalizePath(group.directory ?? null); const scopeKey = normalizePath(group.directory ?? null);
const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : []; const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : [];
const folderNameMatchCount = scopeFolders.filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length; const folderNameMatchCount = scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length;
result.set(group, { result.set(group, {
filteredNodes, filteredNodes,