From c21350713c3d047be10277f167876d0ce416265e Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:32:37 +0200 Subject: [PATCH] perf: replace remark-based search with plain text indexOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manually ported from upstream 5c7f921e. Key changes: - SessionSearcher: indexOf instead of remark AST, batch size 8→16 - conversationSlice: indexOf with MAX_SEARCH_MATCHES=500 cap - Item-scoped store selectors (searchMatchItemIds Set) to skip re-renders - Pre-filter in markdownTextSearch (skip parse if no raw match) - SearchTextCache: 200→1000 entries - ProjectScanner: 30s search project cache, batch 4→8 --- src/main/services/discovery/ProjectScanner.ts | 21 ++++++- .../services/discovery/SearchTextCache.ts | 2 +- .../services/discovery/SessionSearcher.ts | 54 ++++++++--------- .../components/chat/LastOutputDisplay.tsx | 16 +++-- .../components/chat/UserChatGroup.tsx | 16 +++-- .../components/chat/searchHighlightUtils.ts | 3 + .../chat/viewers/MarkdownViewer.tsx | 19 +++--- .../store/slices/conversationSlice.ts | 58 ++++++++++++++----- src/shared/utils/markdownTextSearch.ts | 8 ++- .../discovery/SessionSearcher.test.ts | 8 +-- 10 files changed, 128 insertions(+), 77 deletions(-) diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 15bc62f7..0b3ac09b 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -71,6 +71,9 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP const logger = createLogger('Discovery:ProjectScanner'); +/** How long to reuse the cached project list for search (ms) */ +const SEARCH_PROJECT_CACHE_TTL_MS = 30_000; + // IPC payload safety: session ID arrays can be extremely large for long-lived projects. // Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive. // Keep this non-zero because parts of the renderer still rely on a (partial) sessionId list @@ -147,6 +150,9 @@ export class ProjectScanner { private scanCache: { projects: Project[]; timestamp: number } | null = null; private static readonly SCAN_CACHE_TTL_MS = 2000; + /** Cached project list for search — avoids re-scanning disk on every query */ + private searchProjectCache: { projects: Project[]; timestamp: number } | null = null; + // Platform-aware batch sizes to avoid UV thread pool saturation on Windows private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64; private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12; @@ -1329,8 +1335,17 @@ export class ProjectScanner { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } - // Get all projects - const projects = await this.scan(); + // Use cached project list to avoid re-scanning disk on every keystroke + let projects: Project[]; + if ( + this.searchProjectCache && + Date.now() - this.searchProjectCache.timestamp < SEARCH_PROJECT_CACHE_TTL_MS + ) { + projects = this.searchProjectCache.projects; + } else { + projects = await this.scan(); + this.searchProjectCache = { projects, timestamp: Date.now() }; + } if (projects.length === 0) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; @@ -1338,7 +1353,7 @@ export class ProjectScanner { // Search across all projects with bounded concurrency const allResults: SearchSessionsResult[] = []; - const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 4; + const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 8; for (let i = 0; i < projects.length; i += searchBatchSize) { const batch = projects.slice(i, i + searchBatchSize); diff --git a/src/main/services/discovery/SearchTextCache.ts b/src/main/services/discovery/SearchTextCache.ts index 68c61ba9..e48916bd 100644 --- a/src/main/services/discovery/SearchTextCache.ts +++ b/src/main/services/discovery/SearchTextCache.ts @@ -21,7 +21,7 @@ export class SearchTextCache { private readonly cache = new Map(); private readonly maxSize: number; - constructor(maxSize: number = 200) { + constructor(maxSize: number = 1000) { this.maxSize = maxSize; } diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index 5f0615f1..0d1b1857 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -15,10 +15,6 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile import { parseJsonlFile } from '@main/utils/jsonl'; import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; -import { - extractMarkdownPlainText, - findMarkdownSearchMatches, -} from '@shared/utils/markdownTextSearch'; import * as path from 'path'; import { startMainSpan } from '../../sentry'; @@ -112,7 +108,7 @@ export class SessionSearcher { sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); // Search session files with bounded concurrency and staged breadth in SSH mode. - const searchBatchSize = fastMode ? 3 : 8; + const searchBatchSize = fastMode ? 3 : 16; const stageBoundaries = fastMode ? this.buildFastSearchStageBoundaries(sessionFiles.length) : [sessionFiles.length]; @@ -236,6 +232,10 @@ export class SessionSearcher { const { entries, sessionTitle } = cached; + // Fast pre-filter: skip sessions where no entry contains the query in raw text + const hasAnyMatch = entries.some((entry) => entry.text.toLowerCase().includes(query)); + if (!hasAnyMatch) return results; + for (const entry of entries) { if (results.length >= maxResults) break; @@ -262,31 +262,20 @@ export class SessionSearcher { sessionId: string, sessionTitle?: string ): void { - const mdMatches = findMarkdownSearchMatches(entry.text, query); - if (mdMatches.length === 0) return; + // Plain indexOf search — no markdown/remark parsing + const lowerText = entry.text.toLowerCase(); + if (!lowerText.includes(query)) return; - // Build plain text once for context snippet extraction - const plainText = extractMarkdownPlainText(entry.text); - const lowerPlain = plainText.toLowerCase(); - - for (const mdMatch of mdMatches) { + // Use raw text directly for context snippets + let pos = 0; + let matchIndex = 0; + while ((pos = lowerText.indexOf(query, pos)) !== -1) { if (results.length >= maxResults) return; - // Find approximate position in plain text for context extraction - let pos = 0; - for (let i = 0; i < mdMatch.matchIndexInItem; i++) { - const idx = lowerPlain.indexOf(query, pos); - if (idx === -1) break; - pos = idx + query.length; - } - const matchPos = lowerPlain.indexOf(query, pos); - const effectivePos = matchPos >= 0 ? matchPos : 0; - - const contextStart = Math.max(0, effectivePos - 50); - const contextEnd = Math.min(plainText.length, effectivePos + query.length + 50); - const context = plainText.slice(contextStart, contextEnd); - const matchedText = - matchPos >= 0 ? plainText.slice(matchPos, matchPos + query.length) : query; + const contextStart = Math.max(0, pos - 50); + const contextEnd = Math.min(entry.text.length, pos + query.length + 50); + const context = entry.text.slice(contextStart, contextEnd); + const matchedText = entry.text.slice(pos, pos + query.length); results.push({ sessionId, @@ -294,15 +283,20 @@ export class SessionSearcher { sessionTitle: sessionTitle ?? 'Untitled Session', matchedText, context: - (contextStart > 0 ? '...' : '') + context + (contextEnd < plainText.length ? '...' : ''), + (contextStart > 0 ? '...' : '') + + context + + (contextEnd < entry.text.length ? '...' : ''), messageType: entry.messageType, timestamp: entry.timestamp, groupId: entry.groupId, itemType: entry.itemType, - matchIndexInItem: mdMatch.matchIndexInItem, - matchStartOffset: effectivePos, + matchIndexInItem: matchIndex, + matchStartOffset: pos, messageUuid: entry.messageUuid, }); + + matchIndex++; + pos += query.length; } } diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx index d4b25355..db710805 100644 --- a/src/renderer/components/chat/LastOutputDisplay.tsx +++ b/src/renderer/components/chat/LastOutputDisplay.tsx @@ -11,7 +11,7 @@ import { CopyButton } from '../common/CopyButton'; import { OngoingBanner } from '../common/OngoingIndicator'; import { createMarkdownComponents, markdownComponents } from './markdownComponents'; -import { createSearchContext } from './searchHighlightUtils'; +import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils'; import type { AIGroupLastOutput } from '@renderer/types/groups'; @@ -41,12 +41,16 @@ export const LastOutputDisplay = ({ isLastGroup = false, isSessionOngoing = false, }: Readonly): React.JSX.Element | null => { + // Only re-render if THIS AI group has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: s.searchQuery, - searchMatches: s.searchMatches, - currentSearchIndex: s.currentSearchIndex, - })) + useShallow((s) => { + const hasMatch = s.searchMatchItemIds.has(aiGroupId); + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); const isTextOutput = lastOutput?.type === 'text' && Boolean(lastOutput.text); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 6cf59051..860a5dc3 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -21,6 +21,7 @@ import { CopyButton } from '../common/CopyButton'; import { createSearchContext, + EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from './searchHighlightUtils'; @@ -401,13 +402,16 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. [teams] ); - // Get search state for highlighting + // Get search state for highlighting — only re-render if THIS item has matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: s.searchQuery, - searchMatches: s.searchMatches, - currentSearchIndex: s.currentSearchIndex, - })) + useShallow((s) => { + const hasMatch = s.searchMatchItemIds.has(groupId); + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); const hasImages = content.images.length > 0; diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 34684f96..39a15be5 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -8,6 +8,9 @@ import React from 'react'; import type { SearchMatch } from '@renderer/store/types'; +/** Stable empty array for item-scoped search selectors (avoids re-renders) */ +export const EMPTY_SEARCH_MATCHES: SearchMatch[] = []; + // Highlight styles matching SearchHighlight.tsx const baseStyles: React.CSSProperties = { borderRadius: '0.125rem', diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 00c5da3f..9636ff2c 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -36,6 +36,7 @@ import { useShallow } from 'zustand/react/shallow'; import { createSearchContext, + EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; @@ -44,8 +45,6 @@ import { highlightLine } from '../viewers/syntaxHighlighter'; import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; -import type { SearchMatch } from '@renderer/store/types'; - // ============================================================================= // Types // ============================================================================= @@ -73,7 +72,6 @@ interface MarkdownViewerProps { const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); -const EMPTY_SEARCH_MATCHES: SearchMatch[] = []; const NOOP_TEAM_CLICK = (): void => undefined; // ============================================================================= @@ -714,13 +712,16 @@ export const MarkdownViewer: React.FC = ({ const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; - // Only subscribe to search store when itemId is provided + // Only re-render if THIS item has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: itemId ? s.searchQuery : '', - searchMatches: itemId ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: itemId ? s.currentSearchIndex : -1, - })) + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). diff --git a/src/renderer/store/slices/conversationSlice.ts b/src/renderer/store/slices/conversationSlice.ts index ec40e5db..d6ca21ac 100644 --- a/src/renderer/store/slices/conversationSlice.ts +++ b/src/renderer/store/slices/conversationSlice.ts @@ -4,7 +4,6 @@ import { findLastOutput } from '@renderer/utils/aiGroupEnhancer'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; -import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch'; import type { AppState, SearchMatch } from '../types'; import type { AIGroupExpansionLevel } from '@renderer/types/groups'; @@ -17,6 +16,9 @@ import type { StateCreator } from 'zustand'; type DetailItemType = 'thinking' | 'text' | 'linked-tool' | 'subagent'; +/** Maximum number of search matches to track. Beyond this, results are capped. */ +const MAX_SEARCH_MATCHES = 500; + const isSearchDebugEnabled = (): boolean => { if (typeof window === 'undefined') return false; try { @@ -57,6 +59,10 @@ export interface ConversationSlice { searchResultCount: number; currentSearchIndex: number; searchMatches: SearchMatch[]; + /** True when total matches exceeded the cap and results were truncated */ + searchResultsCapped: boolean; + /** Item IDs that contain at least one search match — used by components to skip re-renders */ + searchMatchItemIds: Set; // Auto-expand state for search results /** AI group IDs that should be expanded to show search results */ @@ -126,6 +132,8 @@ export const createConversationSlice: StateCreator { - const mdMatches = findMarkdownSearchMatches(text, lowerQuery); - for (const mdMatch of mdMatches) { + if (capped) return; + const lowerText = text.toLowerCase(); + let pos = 0; + let matchIndexInItem = 0; + while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) { + if (matches.length >= MAX_SEARCH_MATCHES) { + capped = true; + return; + } matches.push({ itemId, itemType, - matchIndexInItem: mdMatch.matchIndexInItem, + matchIndexInItem, globalIndex, displayItemId, }); + matchIndexInItem++; globalIndex++; + pos += lowerQuery.length; } }; for (const item of conversation.items) { + if (capped) break; if (item.type === 'user') { const raw = item.group.content.rawText ?? item.group.content.text ?? ''; const text = stripAgentBlocks(raw); - addMarkdownMatches(text, item.group.id, 'user'); + addPlainTextMatches(text, item.group.id, 'user'); } else if (item.type === 'ai') { - // For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents) const aiGroup = item.group; const itemId = aiGroup.id; const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false); if (lastOutput?.type === 'text' && lastOutput.text) { - // Last output text - displayItemId indicates this is lastOutput content - addMarkdownMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); + addPlainTextMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); } - // Skip tool_result type - only searching text output } - // Skip system items entirely } if (isSearchDebugEnabled()) { @@ -293,11 +309,19 @@ export const createConversationSlice: StateCreator(); + for (const match of matches) { + matchItemIds.add(match.itemId); + } + set({ searchQuery: query, searchResultCount: matches.length, currentSearchIndex: matches.length > 0 ? 0 : -1, searchMatches: matches, + searchResultsCapped: capped, + searchMatchItemIds: matchItemIds, }); }, @@ -406,6 +430,8 @@ export const createConversationSlice: StateCreator(); function getCachedSegments(markdown: string): string[] { @@ -154,6 +154,9 @@ export interface MarkdownSearchMatch { export function findMarkdownSearchMatches(markdown: string, query: string): MarkdownSearchMatch[] { if (!query || !markdown) return []; + // Fast pre-filter: skip expensive markdown parsing if query doesn't appear in raw text + if (!markdown.toLowerCase().includes(query.toLowerCase())) return []; + const segments = getCachedSegments(markdown); const lowerQuery = query.toLowerCase(); const matches: MarkdownSearchMatch[] = []; @@ -178,6 +181,9 @@ export function findMarkdownSearchMatches(markdown: string, query: string): Mark export function countMarkdownSearchMatches(markdown: string, query: string): number { if (!query || !markdown) return 0; + // Fast pre-filter: skip expensive markdown parsing if query doesn't appear in raw text + if (!markdown.toLowerCase().includes(query.toLowerCase())) return 0; + const segments = getCachedSegments(markdown); const lowerQuery = query.toLowerCase(); let count = 0; diff --git a/test/main/services/discovery/SessionSearcher.test.ts b/test/main/services/discovery/SessionSearcher.test.ts index 5f18765f..385fa83c 100644 --- a/test/main/services/discovery/SessionSearcher.test.ts +++ b/test/main/services/discovery/SessionSearcher.test.ts @@ -76,7 +76,7 @@ describe('SessionSearcher', () => { ).toBe(true); }); - it('does not produce phantom matches for code fence language identifiers', async () => { + it('matches text in code fences with plain text search', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-md-')); tempDirs.push(projectsDir); @@ -110,13 +110,11 @@ describe('SessionSearcher', () => { const searcher = new SessionSearcher(projectsDir); const result = await searcher.searchSessions(projectId, 'tsx', 50); - // "tsx" should match in user text ("Show me tsx code") but NOT in the - // code fence language identifier (```tsx). It should also not match in - // the code block content since "const x = 1;" doesn't contain "tsx". + // Plain text search: "tsx" matches in user text AND in the code fence identifier const userResults = result.results.filter((r) => r.itemType === 'user'); const aiResults = result.results.filter((r) => r.itemType === 'ai'); expect(userResults).toHaveLength(1); - expect(aiResults).toHaveLength(0); + expect(aiResults).toHaveLength(1); }); });