From 5af2b719e2424c8eadfbbc04829e105995dc111d Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:23:50 +0300 Subject: [PATCH] fix: search performance --- package-lock.json | 48 +- package.json | 1 + .../session-conversations-search.service.ts | 1711 +++++++++-------- .../sidebar/hooks/useSidebarController.ts | 138 +- 4 files changed, 1082 insertions(+), 816 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5a0ba87..c22e33c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@replit/codemirror-minimap": "^0.5.2", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", + "@vscode/ripgrep": "^1.17.1", "@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", @@ -4797,6 +4798,18 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vscode/ripgrep": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.1.tgz", + "integrity": "sha512-xTs7DGyAO3IsJYOCTBP8LnTvPiYVKEuyv8s0xyJDBXfs8rhBfqnZPvb6xDT+RnwWzcXqW27xLS/aGrkjX7lNWw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^7.0.2", + "proxy-from-env": "^1.1.0", + "yauzl": "^2.9.2" + } + }, "node_modules/@xterm/addon-clipboard": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz", @@ -5629,6 +5642,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -8288,6 +8310,15 @@ "walk-up-path": "^4.0.0" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -13392,6 +13423,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/perfect-debounce": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", @@ -13785,7 +13822,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, "license": "MIT" }, "node_modules/pump": { @@ -18236,6 +18272,16 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index eeb9d6b6..938391cb 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@replit/codemirror-minimap": "^0.5.2", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", + "@vscode/ripgrep": "^1.17.1", "@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", diff --git a/server/modules/providers/services/session-conversations-search.service.ts b/server/modules/providers/services/session-conversations-search.service.ts index 58b2bbe9..1c046666 100644 --- a/server/modules/providers/services/session-conversations-search.service.ts +++ b/server/modules/providers/services/session-conversations-search.service.ts @@ -1,13 +1,14 @@ import fsSync, { promises as fs } from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; -import { projectsDb } from '@/modules/database/index.js'; -import { generateDisplayName } from '@/modules/projects/index.js'; -import sessionManager from '@/sessionManager.js'; +import { spawn } from 'cross-spawn'; +import { rgPath } from '@vscode/ripgrep'; + +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; type AnyRecord = Record; +type SearchableProvider = 'claude' | 'codex' | 'gemini'; type SearchSnippetHighlight = { start: number; @@ -19,13 +20,13 @@ type SessionConversationMatch = { snippet: string; highlights: SearchSnippetHighlight[]; timestamp: string | null; - provider: 'claude' | 'codex' | 'gemini'; + provider: SearchableProvider; messageUuid?: string | null; }; type SessionConversationResult = { sessionId: string; - provider: 'claude' | 'codex' | 'gemini'; + provider: SearchableProvider; sessionSummary: string; matches: SessionConversationMatch[]; }; @@ -51,117 +52,53 @@ type SearchSessionConversationsInput = { onProgress?: (update: SessionConversationSearchProgressUpdate) => void; }; -const projectDirectoryCache = new Map(); +type SessionRepositoryRow = ReturnType[number]; +type SearchableSessionRow = SessionRepositoryRow & { + provider: SearchableProvider; + jsonl_path: string; +}; -async function loadProjectConfig(): Promise> { - const configPath = path.join(os.homedir(), '.claude', 'project-config.json'); - try { - const configData = await fs.readFile(configPath, 'utf8'); - return JSON.parse(configData) as Record; - } catch { - return {}; - } -} +type SearchRuntime = { + matchesQuery: (text: string) => boolean; + buildSnippet: (text: string) => { snippet: string; highlights: SearchSnippetHighlight[] }; + limit: number; + totalMatches: number; + isAborted: () => boolean; + matchedSessionKeys: Set; + claudeSessionsByFileKey: Map; + claudeFileResultsCache: Map>; +}; -async function extractProjectDirectory(projectName: string): Promise { - if (projectDirectoryCache.has(projectName)) { - return projectDirectoryCache.get(projectName) as string; - } +type SearchablePathEntry = { + normalizedPath: string; + absolutePath: string; +}; - const config = await loadProjectConfig(); - if (config[projectName]?.originalPath) { - const originalPath = String(config[projectName].originalPath); - projectDirectoryCache.set(projectName, originalPath); - return originalPath; - } +type ProjectBucket = { + key: string; + projectId: string | null; + projectName: string; + projectDisplayName: string; + sessions: SearchableSessionRow[]; +}; - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - const cwdCounts = new Map(); - let latestTimestamp = 0; - let latestCwd: string | null = null; - let extractedPath: string; +const SUPPORTED_PROVIDERS = new Set(['claude', 'codex', 'gemini']); +const MAX_MATCHES_PER_SESSION = 2; +const RIPGREP_FILE_CHUNK_SIZE = 40; +const RIPGREP_CHUNK_CONCURRENCY = 6; +const UNKNOWN_PROJECT_KEY = '__unknown_project__'; - try { - await fs.access(projectDir); - - const files = await fs.readdir(projectDir); - const jsonlFiles = files.filter((file) => file.endsWith('.jsonl')); - - if (jsonlFiles.length === 0) { - extractedPath = projectName.replace(/-/g, '/'); - } else { - for (const file of jsonlFiles) { - const jsonlFile = path.join(projectDir, file); - const fileStream = fsSync.createReadStream(jsonlFile); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - for await (const line of rl) { - if (!line.trim()) { - continue; - } - - try { - const entry = JSON.parse(line) as AnyRecord; - if (!entry.cwd) { - continue; - } - - const cwd = String(entry.cwd); - cwdCounts.set(cwd, (cwdCounts.get(cwd) || 0) + 1); - - const timestamp = new Date(entry.timestamp || 0).getTime(); - if (timestamp > latestTimestamp) { - latestTimestamp = timestamp; - latestCwd = cwd; - } - } catch { - // Skip malformed lines. - } - } - } - - if (cwdCounts.size === 0) { - extractedPath = projectName.replace(/-/g, '/'); - } else if (cwdCounts.size === 1) { - extractedPath = Array.from(cwdCounts.keys())[0] as string; - } else { - const latestCount = latestCwd ? (cwdCounts.get(latestCwd) || 0) : 0; - const maxCount = Math.max(...cwdCounts.values()); - - if (latestCount >= maxCount * 0.25 && latestCwd) { - extractedPath = latestCwd; - } else { - let mostFrequentPath = ''; - for (const [cwd, count] of cwdCounts.entries()) { - if (count === maxCount) { - mostFrequentPath = cwd; - break; - } - } - - extractedPath = mostFrequentPath || latestCwd || projectName.replace(/-/g, '/'); - } - } - } - - projectDirectoryCache.set(projectName, extractedPath); - return extractedPath; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - extractedPath = projectName.replace(/-/g, '/'); - } else { - console.error(`Error extracting project directory for ${projectName}:`, error); - extractedPath = projectName.replace(/-/g, '/'); - } - - projectDirectoryCache.set(projectName, extractedPath); - return extractedPath; - } -} +const INTERNAL_CONTENT_PREFIXES = [ + '', + '', + '', + '', + '', + 'Caveat:', + 'This session is being continued from a previous', + 'Invalid API key', + '[Request interrupted', +] as const; function normalizeComparablePath(inputPath: string): string { if (!inputPath || typeof inputPath !== 'string') { @@ -180,448 +117,104 @@ function normalizeComparablePath(inputPath: string): string { return process.platform === 'win32' ? resolved.toLowerCase() : resolved; } -async function findCodexJsonlFiles(dir: string): Promise { - const files: string[] = []; - - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...await findCodexJsonlFiles(fullPath)); - } else if (entry.name.endsWith('.jsonl')) { - files.push(fullPath); - } - } - } catch { - // Skip directories we can't read. +function chunkArray(items: TItem[], size: number): TItem[][] { + if (size <= 0) { + return [items]; } - return files; + const chunks: TItem[][] = []; + for (let idx = 0; idx < items.length; idx += size) { + chunks.push(items.slice(idx, idx + size)); + } + return chunks; } -async function searchCodexSessionsForProject( - projectPath: string, - projectResult: ProjectConversationResult, - allWordsMatch: (textLower: string) => boolean, - buildSnippet: (text: string, textLower: string) => { snippet: string; highlights: SearchSnippetHighlight[] }, - limit: number, - getTotalMatches: () => number, - addMatches: (count: number) => void, - isAborted: () => boolean, -): Promise { - const normalizedProjectPath = normalizeComparablePath(projectPath); - if (!normalizedProjectPath) { - return; - } - - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - try { - await fs.access(codexSessionsDir); - } catch { - return; - } - - const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); - - for (const filePath of jsonlFiles) { - if (getTotalMatches() >= limit || isAborted()) { - break; - } - - try { - const fileStream = fsSync.createReadStream(filePath); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - let sessionMeta: AnyRecord | null = null; - for await (const line of rl) { - if (!line.trim()) { - continue; - } - - try { - const entry = JSON.parse(line) as AnyRecord; - if (entry.type === 'session_meta' && entry.payload) { - sessionMeta = entry.payload as AnyRecord; - break; - } - } catch { - // Skip malformed lines. - } - } - - if (!sessionMeta) { - continue; - } - - const sessionProjectPath = normalizeComparablePath(String(sessionMeta.cwd || '')); - if (sessionProjectPath !== normalizedProjectPath) { - continue; - } - - const fileStream2 = fsSync.createReadStream(filePath); - const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity }); - let latestUserMessageText: string | null = null; - const matches: SessionConversationMatch[] = []; - - for await (const line of rl2) { - if (getTotalMatches() >= limit || isAborted()) { - break; - } - if (!line.trim()) { - continue; - } - - let entry: AnyRecord; - try { - entry = JSON.parse(line) as AnyRecord; - } catch { - continue; - } - - let text: string | null = null; - let role: string | null = null; - - if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) { - text = String(entry.payload.message); - role = 'user'; - latestUserMessageText = text; - } else if (entry.type === 'response_item' && entry.payload?.type === 'message') { - const contentParts = Array.isArray(entry.payload.content) ? entry.payload.content : []; - if (entry.payload.role === 'user') { - text = contentParts - .filter((part: AnyRecord) => part.type === 'input_text' && part.text) - .map((part: AnyRecord) => String(part.text)) - .join(' '); - role = 'user'; - if (text) { - latestUserMessageText = text; - } - } else if (entry.payload.role === 'assistant') { - text = contentParts - .filter((part: AnyRecord) => part.type === 'output_text' && part.text) - .map((part: AnyRecord) => String(part.text)) - .join(' '); - role = 'assistant'; - } - } - - if (!text || !role) { - continue; - } - - const textLower = text.toLowerCase(); - if (!allWordsMatch(textLower)) { - continue; - } - - if (matches.length < 2) { - const { snippet, highlights } = buildSnippet(text, textLower); - matches.push({ - role, - snippet, - highlights, - timestamp: entry.timestamp ? String(entry.timestamp) : null, - provider: 'codex', - }); - addMatches(1); - } - } - - if (matches.length > 0) { - projectResult.sessions.push({ - sessionId: String(sessionMeta.id || ''), - provider: 'codex', - sessionSummary: latestUserMessageText - ? (latestUserMessageText.length > 50 ? `${latestUserMessageText.substring(0, 50)}...` : latestUserMessageText) - : 'Codex Session', - matches, - }); - } - } catch { - // Skip unreadable or malformed files. - } - } +function getSessionKey(session: Pick): string { + return `${session.provider}:${session.session_id}`; } -async function searchGeminiSessionsForProject( - projectPath: string, - projectResult: ProjectConversationResult, - allWordsMatch: (textLower: string) => boolean, - buildSnippet: (text: string, textLower: string) => { snippet: string; highlights: SearchSnippetHighlight[] }, - limit: number, - getTotalMatches: () => number, - addMatches: (count: number) => void, -): Promise { - for (const [sessionId, session] of sessionManager.sessions as Map) { - if (getTotalMatches() >= limit) { - break; - } - if (session.projectPath !== projectPath) { - continue; - } - - const matches: SessionConversationMatch[] = []; - const sourceMessages = Array.isArray(session.messages) ? session.messages : []; - - for (const msg of sourceMessages) { - if (getTotalMatches() >= limit) { - break; - } - if (msg.role !== 'user' && msg.role !== 'assistant') { - continue; - } - - const text = typeof msg.content === 'string' - ? msg.content - : Array.isArray(msg.content) - ? msg.content.filter((part: AnyRecord) => part.type === 'text').map((part: AnyRecord) => String(part.text)).join(' ') - : ''; - if (!text) { - continue; - } - - const textLower = text.toLowerCase(); - if (!allWordsMatch(textLower)) { - continue; - } - - if (matches.length < 2) { - const { snippet, highlights } = buildSnippet(text, textLower); - matches.push({ - role: String(msg.role), - snippet, - highlights, - timestamp: msg.timestamp ? new Date(msg.timestamp).toISOString() : null, - provider: 'gemini', - }); - addMatches(1); - } - } - - if (matches.length > 0) { - const firstUserMessage = sourceMessages.find((msg: AnyRecord) => msg.role === 'user'); - const summary = firstUserMessage?.content - ? (typeof firstUserMessage.content === 'string' - ? (firstUserMessage.content.length > 50 ? `${firstUserMessage.content.substring(0, 50)}...` : firstUserMessage.content) - : 'Gemini Session') - : 'Gemini Session'; - - projectResult.sessions.push({ - sessionId, - provider: 'gemini', - sessionSummary: summary, - matches, - }); - } - } - - const normalizedProjectPath = normalizeComparablePath(projectPath); - if (!normalizedProjectPath) { - return; - } - - const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); - try { - await fs.access(geminiTmpDir); - } catch { - return; - } - - const trackedSessionIds = new Set(); - for (const [sid] of sessionManager.sessions as Map) { - trackedSessionIds.add(String(sid)); - } - - let projectDirs: string[]; - try { - projectDirs = await fs.readdir(geminiTmpDir); - } catch { - return; - } - - for (const projectDir of projectDirs) { - if (getTotalMatches() >= limit) { - break; - } - - const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root'); - let projectRoot = ''; - try { - projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim(); - } catch { - continue; - } - - if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) { - continue; - } - - const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); - let chatFiles: string[]; - try { - chatFiles = await fs.readdir(chatsDir); - } catch { - continue; - } - - for (const chatFile of chatFiles) { - if (getTotalMatches() >= limit) { - break; - } - if (!chatFile.endsWith('.json')) { - continue; - } - - try { - const filePath = path.join(chatsDir, chatFile); - const data = await fs.readFile(filePath, 'utf8'); - const session = JSON.parse(data) as AnyRecord; - if (!session.messages || !Array.isArray(session.messages)) { - continue; - } - - const cliSessionId = String(session.sessionId || chatFile.replace('.json', '')); - if (trackedSessionIds.has(cliSessionId)) { - continue; - } - - const matches: SessionConversationMatch[] = []; - let firstUserText: string | null = null; - - for (const msg of session.messages as AnyRecord[]) { - if (getTotalMatches() >= limit) { - break; - } - - const role = msg.type === 'user' - ? 'user' - : (msg.type === 'gemini' || msg.type === 'assistant') - ? 'assistant' - : null; - if (!role) { - continue; - } - - let text = ''; - if (typeof msg.content === 'string') { - text = msg.content; - } else if (Array.isArray(msg.content)) { - text = msg.content - .filter((part: AnyRecord) => part.text) - .map((part: AnyRecord) => String(part.text)) - .join(' '); - } - - if (!text) { - continue; - } - if (role === 'user' && !firstUserText) { - firstUserText = text; - } - - const textLower = text.toLowerCase(); - if (!allWordsMatch(textLower)) { - continue; - } - - if (matches.length < 2) { - const { snippet, highlights } = buildSnippet(text, textLower); - matches.push({ - role, - snippet, - highlights, - timestamp: msg.timestamp ? String(msg.timestamp) : null, - provider: 'gemini', - }); - addMatches(1); - } - } - - if (matches.length > 0) { - const summary = firstUserText - ? (firstUserText.length > 50 ? `${firstUserText.substring(0, 50)}...` : firstUserText) - : 'Gemini CLI Session'; - - projectResult.sessions.push({ - sessionId: cliSessionId, - provider: 'gemini', - sessionSummary: summary, - matches, - }); - } - } catch { - // Skip unreadable or malformed files. - } - } - } +function makeProjectKey(projectPath: string | null): string { + const normalized = typeof projectPath === 'string' ? projectPath.trim() : ''; + return normalized.length > 0 ? normalized : UNKNOWN_PROJECT_KEY; } -export async function searchConversations( - query: string, - limit = 50, - onProjectResult: ((update: SessionConversationSearchProgressUpdate) => void) | null = null, - signal: AbortSignal | null = null, -): Promise<{ results: ProjectConversationResult[]; totalMatches: number; query: string }> { - const safeQuery = typeof query === 'string' ? query.trim() : ''; - const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200)); - const claudeDir = path.join(os.homedir(), '.claude', 'projects'); - const config = await loadProjectConfig(); - const results: ProjectConversationResult[] = []; - let totalMatches = 0; - const words = safeQuery.toLowerCase().split(/\s+/).filter((word) => word.length > 0); - if (words.length === 0) { - return { results: [], totalMatches: 0, query: safeQuery }; +function toSummaryText(customName: string | null, fallback: string | null | undefined, emptyLabel: string): string { + const trimmedCustomName = typeof customName === 'string' ? customName.trim() : ''; + if (trimmedCustomName) { + return trimmedCustomName; } - const isAborted = () => signal?.aborted === true; + const trimmedFallback = typeof fallback === 'string' ? fallback.trim() : ''; + if (!trimmedFallback) { + return emptyLabel; + } - const isSystemMessage = (textContent: string): boolean => { - return typeof textContent === 'string' && ( - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('Caveat:') || - textContent.startsWith('This session is being continued from a previous') || - textContent.startsWith('Invalid API key') || - textContent.includes('{"subtasks":') || - textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || - textContent === 'Warmup' - ); - }; + return trimmedFallback.length > 50 ? `${trimmedFallback.slice(0, 50)}...` : trimmedFallback; +} - const extractText = (content: unknown): string => { - if (typeof content === 'string') { - return content; - } - if (Array.isArray(content)) { - return content - .filter((part: AnyRecord) => part.type === 'text' && part.text) - .map((part: AnyRecord) => String(part.text)) - .join(' '); - } - return ''; - }; +function isInternalContent(content: string): boolean { + return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix)); +} - const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function createWordMatcher( + rawQuery: string, + words: string[], +): Pick { + const normalizedQuery = rawQuery.trim().replace(/\s+/g, ' '); + const requireExactPhrase = words.length > 1 && normalizedQuery.length > 0; const wordPatterns = words.map((word) => new RegExp(`(? wordPatterns.every((pattern) => pattern.test(textLower)); + const phrasePattern = words.map((word) => escapeRegex(word)).join('\\s+'); + const phraseRegex = new RegExp(phrasePattern, 'iu'); + + const allWordsMatch = (textLower: string): boolean => + wordPatterns.every((pattern) => pattern.test(textLower)); + + const matchesQuery = (text: string): boolean => { + if (typeof text !== 'string' || text.length === 0) { + return false; + } + + if (requireExactPhrase) { + return phraseRegex.test(text); + } + + if (phraseRegex.test(text) || words.length === 1) { + return true; + } + + return allWordsMatch(text.toLowerCase()); + }; const buildSnippet = ( text: string, - textLower: string, snippetLen = 150, ): { snippet: string; highlights: SearchSnippetHighlight[] } => { + const textLower = text.toLowerCase(); let firstIndex = -1; let firstWordLen = 0; - for (const word of words) { - const regex = new RegExp(`(? 0 ? '...' : ''; const suffix = end < text.length ? '...' : ''; - const snippet = `${prefix}${text.slice(start, end).replace(/\n/g, ' ')}${suffix}`; + const snippetBody = text.slice(start, end).replace(/\n/g, ' '); + const snippet = `${prefix}${snippetBody}${suffix}`; const snippetLower = snippet.toLowerCase(); const highlights: SearchSnippetHighlight[] = []; - for (const word of words) { - const regex = new RegExp(`(?= start && phraseStart + phraseLength <= end) { + const phraseOffset = prefix.length + (phraseStart - start); + highlights.push({ + start: phraseOffset, + end: phraseOffset + phraseLength, + }); + } + + if (!requireExactPhrase) { + for (const word of words) { + const regex = new RegExp(`(? entry.isDirectory()); - let scannedProjects = 0; - const totalProjects = projectDirs.length; + return { matchesQuery, buildSnippet }; +} - for (const projectEntry of projectDirs) { - if (totalMatches >= safeLimit || isAborted()) { - break; +function extractClaudeText(content: unknown): string { + if (typeof content === 'string') { + return content; + } + + if (!Array.isArray(content)) { + return ''; + } + + return content + .filter((part: AnyRecord) => part?.type === 'text' && typeof part?.text === 'string') + .map((part: AnyRecord) => String(part.text)) + .join(' '); +} + +function extractCodexText(content: unknown): string { + if (typeof content === 'string') { + return content; + } + + if (!Array.isArray(content)) { + return ''; + } + + return content + .map((item) => { + if (!item || typeof item !== 'object') { + return ''; } - const projectName = projectEntry.name; - const projectDir = path.join(claudeDir, projectName); - const projectDisplayName = config[projectName]?.displayName - ? String(config[projectName].displayName) - : await generateDisplayName(projectName); + const record = item as AnyRecord; + if ( + (record.type === 'input_text' || record.type === 'output_text' || record.type === 'text') + && typeof record.text === 'string' + ) { + return record.text; + } - let files: string[]; + return ''; + }) + .filter(Boolean) + .join(' '); +} + +function extractGeminiText(content: unknown): string { + if (typeof content === 'string') { + return content; + } + + if (!Array.isArray(content)) { + return ''; + } + + return content + .filter((part: AnyRecord) => typeof part?.text === 'string') + .map((part: AnyRecord) => String(part.text)) + .join(' '); +} + +function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSessionRow[] { + const normalizedRows: SearchableSessionRow[] = []; + + for (const row of rows) { + const provider = row.provider as SearchableProvider; + if (!SUPPORTED_PROVIDERS.has(provider)) { + continue; + } + + const rawJsonlPath = typeof row.jsonl_path === 'string' ? row.jsonl_path.trim() : ''; + if (!rawJsonlPath) { + continue; + } + + const absoluteJsonlPath = path.resolve(rawJsonlPath); + if (!fsSync.existsSync(absoluteJsonlPath)) { + continue; + } + + normalizedRows.push({ + ...row, + provider, + jsonl_path: absoluteJsonlPath, + }); + } + + return normalizedRows; +} + +function buildProjectBuckets(searchableSessions: SearchableSessionRow[]): ProjectBucket[] { + const projectBuckets = new Map(); + const projectMetadataCache = new Map(); + + for (const session of searchableSessions) { + const key = makeProjectKey(session.project_path); + if (!projectBuckets.has(key)) { + if (!projectMetadataCache.has(key)) { + if (key === UNKNOWN_PROJECT_KEY) { + projectMetadataCache.set(key, { + projectId: null, + projectDisplayName: 'Unknown Project', + }); + } else { + const projectRow = projectsDb.getProjectPath(key); + const customProjectName = typeof projectRow?.custom_project_name === 'string' + ? projectRow.custom_project_name.trim() + : ''; + const displayName = customProjectName || path.basename(key) || key; + + projectMetadataCache.set(key, { + projectId: projectRow?.project_id ?? null, + projectDisplayName: displayName, + }); + } + } + + const metadata = projectMetadataCache.get(key) as { projectId: string | null; projectDisplayName: string }; + projectBuckets.set(key, { + key, + projectId: metadata.projectId, + projectName: key, + projectDisplayName: metadata.projectDisplayName, + sessions: [], + }); + } + + const bucket = projectBuckets.get(key) as ProjectBucket; + bucket.sessions.push(session); + } + + const buckets = Array.from(projectBuckets.values()); + for (const bucket of buckets) { + bucket.sessions.sort((left, right) => { + const leftTs = new Date(left.updated_at || left.created_at || 0).getTime(); + const rightTs = new Date(right.updated_at || right.created_at || 0).getTime(); + return rightTs - leftTs; + }); + } + + return buckets; +} + +/** + * Executes ripgrep with the file list explicitly provided from sessionsDb jsonl paths. + * + * This avoids recursive directory walks and uses a fixed known candidate list. + */ +async function runRipgrepFilesWithMatches( + pattern: string, + filePaths: string[], + signal?: AbortSignal, +): Promise> { + if (!pattern || filePaths.length === 0 || signal?.aborted) { + return new Set(); + } + + return new Promise((resolve, reject) => { + const args = [ + '--files-with-matches', + '--no-messages', + '--ignore-case', + '--fixed-strings', + '--', + pattern, + ...filePaths, + ]; + const rg = spawn(rgPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let aborted = false; + + const abortListener = () => { + aborted = true; + rg.kill(); + }; + + if (signal) { + signal.addEventListener('abort', abortListener, { once: true }); + } + + rg.stdout.on('data', (chunk: Buffer) => { + stdoutChunks.push(chunk); + }); + + rg.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + }); + + rg.on('error', (error) => { + if (signal) { + signal.removeEventListener('abort', abortListener); + } + + if (aborted || signal?.aborted) { + resolve(new Set()); + return; + } + + reject(error); + }); + + rg.on('close', (code) => { + if (signal) { + signal.removeEventListener('abort', abortListener); + } + + if (aborted || signal?.aborted) { + resolve(new Set()); + return; + } + + if (code !== 0 && code !== 1) { + const stderr = Buffer.concat(stderrChunks).toString('utf8').trim(); + reject(new Error(`ripgrep failed with code ${String(code)}: ${stderr}`)); + return; + } + + const stdout = Buffer.concat(stdoutChunks).toString('utf8'); + const matchedPaths = new Set(); + + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + matchedPaths.add(normalizeComparablePath(trimmed)); + } + + resolve(matchedPaths); + }); + }); +} + +async function findMatchedFileKeys( + searchablePathEntries: SearchablePathEntry[], + rawQuery: string, + words: string[], + signal?: AbortSignal, +): Promise> { + if (searchablePathEntries.length === 0 || words.length === 0 || signal?.aborted) { + return new Set(); + } + + const normalizedQuery = rawQuery.trim().replace(/\s+/g, ' '); + const requireExactPhrase = words.length > 1 && normalizedQuery.length > 0; + + if (requireExactPhrase) { + const matchedForPhrase = new Set(); + const fileChunks = chunkArray( + searchablePathEntries.map((entry) => entry.absolutePath), + RIPGREP_FILE_CHUNK_SIZE, + ); + + let nextChunkIndex = 0; + const workerCount = Math.min(RIPGREP_CHUNK_CONCURRENCY, fileChunks.length); + const workers = Array.from({ length: workerCount }, async () => { + while (nextChunkIndex < fileChunks.length && !signal?.aborted) { + const currentIndex = nextChunkIndex; + nextChunkIndex += 1; + const chunkMatches = await runRipgrepFilesWithMatches(normalizedQuery, fileChunks[currentIndex], signal); + for (const matchedPath of chunkMatches) { + matchedForPhrase.add(matchedPath); + } + } + }); + + await Promise.all(workers); + if (signal?.aborted) { + return new Set(); + } + + return matchedForPhrase; + } + + let remainingEntries = searchablePathEntries.slice(); + + // Run one ripgrep pass per term and intersect by keeping only files that + // matched every query word. + for (const word of words) { + if (signal?.aborted) { + return new Set(); + } + + const matchedForWord = new Set(); + const fileChunks = chunkArray( + remainingEntries.map((entry) => entry.absolutePath), + RIPGREP_FILE_CHUNK_SIZE, + ); + + let nextChunkIndex = 0; + const workerCount = Math.min(RIPGREP_CHUNK_CONCURRENCY, fileChunks.length); + + const workers = Array.from({ length: workerCount }, async () => { + while (nextChunkIndex < fileChunks.length && !signal?.aborted) { + const currentIndex = nextChunkIndex; + nextChunkIndex += 1; + const chunkMatches = await runRipgrepFilesWithMatches(word, fileChunks[currentIndex], signal); + for (const matchedPath of chunkMatches) { + matchedForWord.add(matchedPath); + } + } + }); + + await Promise.all(workers); + if (signal?.aborted) { + return new Set(); + } + + remainingEntries = remainingEntries.filter((entry) => matchedForWord.has(entry.normalizedPath)); + if (remainingEntries.length === 0) { + break; + } + } + + return new Set(remainingEntries.map((entry) => entry.normalizedPath)); +} + +function addSessionMatch( + runtime: SearchRuntime, + matches: SessionConversationMatch[], + match: SessionConversationMatch, +): void { + if (runtime.totalMatches >= runtime.limit || matches.length >= MAX_MATCHES_PER_SESSION) { + return; + } + + matches.push(match); + runtime.totalMatches += 1; +} + +async function parseClaudeSessionMatches( + session: SearchableSessionRow, + runtime: SearchRuntime, +): Promise { + const fileKey = normalizeComparablePath(session.jsonl_path); + if (!fileKey) { + return null; + } + + if (!runtime.claudeFileResultsCache.has(fileKey)) { + const sessionsForFile = runtime.claudeSessionsByFileKey.get(fileKey) || []; + const matchedSessionsForFile = sessionsForFile.filter((candidate) => + runtime.matchedSessionKeys.has(getSessionKey(candidate)), + ); + + const targetSessions = matchedSessionsForFile.length > 0 + ? matchedSessionsForFile + : [session]; + + const targetSessionIds = new Set(targetSessions.map((candidate) => candidate.session_id)); + const customNameBySessionId = new Map(); + for (const candidate of targetSessions) { + customNameBySessionId.set(candidate.session_id, candidate.custom_name ?? null); + } + + type ClaudeSessionSearchState = { + matches: SessionConversationMatch[]; + pendingSummaries: Map; + fallbackUserText: string | null; + fallbackAssistantText: string | null; + resolvedSummary: string | null; + }; + + const sessionStateById = new Map(); + const getSessionState = (sessionId: string): ClaudeSessionSearchState => { + if (!sessionStateById.has(sessionId)) { + sessionStateById.set(sessionId, { + matches: [], + pendingSummaries: new Map(), + fallbackUserText: null, + fallbackAssistantText: null, + resolvedSummary: null, + }); + } + return sessionStateById.get(sessionId) as ClaudeSessionSearchState; + }; + + let currentSessionId: string | null = null; + + try { + const fileStream = fsSync.createReadStream(session.jsonl_path); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { + break; + } + if (!line.trim()) { + continue; + } + + let entry: AnyRecord; + try { + entry = JSON.parse(line) as AnyRecord; + } catch { + continue; + } + + if (entry.sessionId) { + currentSessionId = String(entry.sessionId); + } + const entrySessionId = entry.sessionId + ? String(entry.sessionId) + : currentSessionId; + if (!entrySessionId || !targetSessionIds.has(entrySessionId)) { + continue; + } + + const state = getSessionState(entrySessionId); + + if (entry.type === 'summary' && entry.summary) { + const summaryValue = String(entry.summary); + if (entry.sessionId) { + state.resolvedSummary = summaryValue; + } else if (entry.leafUuid) { + state.pendingSummaries.set(String(entry.leafUuid), summaryValue); + } + } + + if (!state.resolvedSummary && entry.parentUuid) { + const pendingSummary = state.pendingSummaries.get(String(entry.parentUuid)); + if (pendingSummary) { + state.resolvedSummary = pendingSummary; + } + } + + if (!entry.message?.content || entry.isApiErrorMessage) { + continue; + } + + const role = entry.message.role; + if (role !== 'user' && role !== 'assistant') { + continue; + } + + const text = extractClaudeText(entry.message.content); + if (!text || isInternalContent(text)) { + continue; + } + + if (role === 'user') { + state.fallbackUserText = text; + } else { + state.fallbackAssistantText = text; + } + + if (!runtime.matchesQuery(text)) { + continue; + } + + const { snippet, highlights } = runtime.buildSnippet(text); + addSessionMatch(runtime, state.matches, { + role, + snippet, + highlights, + timestamp: entry.timestamp ? String(entry.timestamp) : null, + provider: 'claude', + messageUuid: entry.uuid ? String(entry.uuid) : null, + }); + } + } catch { + runtime.claudeFileResultsCache.set(fileKey, new Map()); + return null; + } + + const fileResults = new Map(); + for (const [sessionId, state] of sessionStateById.entries()) { + if (state.matches.length === 0) { + continue; + } + + fileResults.set(sessionId, { + sessionId, + provider: 'claude', + sessionSummary: toSummaryText( + customNameBySessionId.get(sessionId) ?? null, + state.resolvedSummary || state.fallbackUserText || state.fallbackAssistantText, + 'New Session', + ), + matches: state.matches, + }); + } + + runtime.claudeFileResultsCache.set(fileKey, fileResults); + } + + return runtime.claudeFileResultsCache.get(fileKey)?.get(session.session_id) ?? null; +} + +function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boolean { + if (!payload || payload.type !== 'user_message') { + return false; + } + + if (payload.kind && payload.kind !== 'plain') { + return false; + } + + return typeof payload.message === 'string' && payload.message.trim().length > 0; +} + +async function parseCodexSessionMatches( + session: SearchableSessionRow, + runtime: SearchRuntime, +): Promise { + const matches: SessionConversationMatch[] = []; + let latestUserMessageText: string | null = null; + const seenMessageFingerprints = new Set(); + + try { + const fileStream = fsSync.createReadStream(session.jsonl_path); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { + break; + } + if (!line.trim()) { + continue; + } + + let entry: AnyRecord; try { - files = await fs.readdir(projectDir); + entry = JSON.parse(line) as AnyRecord; } catch { continue; } - const jsonlFiles = files.filter( - (file) => file.endsWith('.jsonl') && !file.startsWith('agent-'), - ); + let text: string | null = null; + let role: 'user' | 'assistant' | null = null; - let searchProjectId: string | null = null; - try { - const resolvedPath = await extractProjectDirectory(projectName); - const dbRow = projectsDb.getProjectPath(resolvedPath); - if (dbRow?.project_id) { - searchProjectId = String(dbRow.project_id); - } - } catch { - // Best-effort project id resolution. - } - - const projectResult: ProjectConversationResult = { - projectId: searchProjectId, - projectName, - projectDisplayName, - sessions: [], - }; - - for (const file of jsonlFiles) { - if (totalMatches >= safeLimit || isAborted()) { - break; - } - - const filePath = path.join(projectDir, file); - const sessionMatches = new Map(); - const sessionSummaries = new Map(); - const pendingSummaries = new Map(); - const sessionLastMessages = new Map(); - let currentSessionId: string | null = null; - - try { - const fileStream = fsSync.createReadStream(filePath); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - for await (const line of rl) { - if (totalMatches >= safeLimit || isAborted()) { - break; - } - if (!line.trim()) { - continue; - } - - let entry: AnyRecord; - try { - entry = JSON.parse(line) as AnyRecord; - } catch { - continue; - } - - if (entry.sessionId) { - currentSessionId = String(entry.sessionId); - } - - if (entry.type === 'summary' && entry.summary) { - const summary = String(entry.summary); - const sid = entry.sessionId - ? String(entry.sessionId) - : currentSessionId; - if (sid) { - sessionSummaries.set(sid, summary); - } else if (entry.leafUuid) { - pendingSummaries.set(String(entry.leafUuid), summary); - } - } - - if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) { - const pendingSummary = pendingSummaries.get(String(entry.parentUuid)); - if (pendingSummary) { - sessionSummaries.set(currentSessionId, pendingSummary); - } - } - - if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) { - const role = entry.message.role; - if (role === 'user' || role === 'assistant') { - const text = extractText(entry.message.content); - if (text && !isSystemMessage(text)) { - if (!sessionLastMessages.has(currentSessionId)) { - sessionLastMessages.set(currentSessionId, {}); - } - - const messages = sessionLastMessages.get(currentSessionId) as { - user?: string; - assistant?: string; - }; - if (role === 'user') { - messages.user = text; - } else { - messages.assistant = text; - } - } - } - } - - if (!entry.message?.content) { - continue; - } - if (entry.message.role !== 'user' && entry.message.role !== 'assistant') { - continue; - } - if (entry.isApiErrorMessage) { - continue; - } - - const text = extractText(entry.message.content); - if (!text || isSystemMessage(text)) { - continue; - } - - const textLower = text.toLowerCase(); - if (!allWordsMatch(textLower)) { - continue; - } - - const resolvedSessionId = entry.sessionId - ? String(entry.sessionId) - : currentSessionId || file.replace('.jsonl', ''); - if (!sessionMatches.has(resolvedSessionId)) { - sessionMatches.set(resolvedSessionId, []); - } - - const matches = sessionMatches.get(resolvedSessionId) as SessionConversationMatch[]; - if (matches.length < 2) { - const { snippet, highlights } = buildSnippet(text, textLower); - matches.push({ - role: String(entry.message.role), - snippet, - highlights, - timestamp: entry.timestamp ? String(entry.timestamp) : null, - provider: 'claude', - messageUuid: entry.uuid ? String(entry.uuid) : null, - }); - totalMatches += 1; - } + if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) { + text = String(entry.payload.message); + role = 'user'; + latestUserMessageText = text; + } else if ( + entry.type === 'event_msg' + && entry.payload?.type === 'agent_reasoning' + && typeof entry.payload?.text === 'string' + ) { + text = String(entry.payload.text); + role = 'assistant'; + } else if (entry.type === 'response_item' && entry.payload?.type === 'message') { + const payload = entry.payload as AnyRecord; + if (payload.role === 'user') { + text = extractCodexText(payload.content); + role = 'user'; + if (text) { + latestUserMessageText = text; } - } catch { - // Skip unreadable or malformed files. + } else if (payload.role === 'assistant') { + text = extractCodexText(payload.content); + role = 'assistant'; } + } else if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') { + const summaryText = Array.isArray(entry.payload.summary) + ? entry.payload.summary + .map((item: AnyRecord) => (typeof item?.text === 'string' ? item.text : '')) + .filter(Boolean) + .join('\n') + : ''; - for (const [sessionId, matches] of sessionMatches.entries()) { - const lastMessages = sessionLastMessages.get(sessionId); - const fallback = lastMessages?.user || lastMessages?.assistant; - projectResult.sessions.push({ - sessionId, - provider: 'claude', - sessionSummary: sessionSummaries.get(sessionId) - || (fallback ? (fallback.length > 50 ? `${fallback.substring(0, 50)}...` : fallback) : 'New Session'), - matches, - }); + if (summaryText.trim()) { + text = summaryText; + role = 'assistant'; } } - try { - const actualProjectDir = await extractProjectDirectory(projectName); - if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { - await searchCodexSessionsForProject( - actualProjectDir, - projectResult, - allWordsMatch, - buildSnippet, - safeLimit, - () => totalMatches, - (count) => { totalMatches += count; }, - isAborted, - ); - } - } catch { - // Skip codex search errors. + if (!text || !role) { + continue; } - try { - const actualProjectDir = await extractProjectDirectory(projectName); - if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { - await searchGeminiSessionsForProject( - actualProjectDir, - projectResult, - allWordsMatch, - buildSnippet, - safeLimit, - () => totalMatches, - (count) => { totalMatches += count; }, - ); - } - } catch { - // Skip gemini search errors. + const fingerprint = `${role}:${text.trim().toLowerCase()}`; + if (seenMessageFingerprints.has(fingerprint)) { + continue; + } + seenMessageFingerprints.add(fingerprint); + + if (!runtime.matchesQuery(text)) { + continue; } - scannedProjects += 1; - if (projectResult.sessions.length > 0) { - results.push(projectResult); - onProjectResult?.({ projectResult, totalMatches, scannedProjects, totalProjects }); - } else if (onProjectResult && scannedProjects % 10 === 0) { - onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects }); - } + const { snippet, highlights } = runtime.buildSnippet(text); + addSessionMatch(runtime, matches, { + role, + snippet, + highlights, + timestamp: entry.timestamp ? String(entry.timestamp) : null, + provider: 'codex', + }); } } catch { - // ~/.claude/projects does not exist. + return null; } - return { results, totalMatches, query: safeQuery }; + if (matches.length === 0) { + return null; + } + + return { + sessionId: session.session_id, + provider: 'codex', + sessionSummary: toSummaryText(session.custom_name, latestUserMessageText, 'Codex Session'), + matches, + }; +} + +async function parseGeminiSessionMatches( + session: SearchableSessionRow, + runtime: SearchRuntime, +): Promise { + let data: string; + try { + data = await fs.readFile(session.jsonl_path, 'utf8'); + } catch { + return null; + } + + let parsed: AnyRecord; + try { + parsed = JSON.parse(data) as AnyRecord; + } catch { + return null; + } + + const sourceMessages = Array.isArray(parsed.messages) ? parsed.messages as AnyRecord[] : []; + if (sourceMessages.length === 0) { + return null; + } + + const matches: SessionConversationMatch[] = []; + let firstUserText: string | null = null; + + for (const msg of sourceMessages) { + if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { + break; + } + + const role = msg.type === 'user' + ? 'user' + : (msg.type === 'gemini' || msg.type === 'assistant') + ? 'assistant' + : null; + if (!role) { + continue; + } + + const text = extractGeminiText(msg.content); + if (!text) { + continue; + } + + if (role === 'user' && !firstUserText) { + firstUserText = text; + } + + if (!runtime.matchesQuery(text)) { + continue; + } + + const { snippet, highlights } = runtime.buildSnippet(text); + addSessionMatch(runtime, matches, { + role, + snippet, + highlights, + timestamp: msg.timestamp ? String(msg.timestamp) : null, + provider: 'gemini', + }); + } + + if (matches.length === 0) { + return null; + } + + return { + sessionId: session.session_id, + provider: 'gemini', + sessionSummary: toSummaryText(session.custom_name, firstUserText, 'Gemini Session'), + matches, + }; +} + +async function parseSessionMatches( + session: SearchableSessionRow, + runtime: SearchRuntime, +): Promise { + if (session.provider === 'claude') { + return parseClaudeSessionMatches(session, runtime); + } + if (session.provider === 'codex') { + return parseCodexSessionMatches(session, runtime); + } + return parseGeminiSessionMatches(session, runtime); +} + +export async function searchConversations( + query: string, + limit = 50, + onProjectResult: ((update: SessionConversationSearchProgressUpdate) => void) | null = null, + signal: AbortSignal | null = null, +): Promise<{ results: ProjectConversationResult[]; totalMatches: number; query: string }> { + const safeQuery = typeof query === 'string' ? query.trim() : ''; + const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200)); + const words = safeQuery.toLowerCase().split(/\s+/).filter((word) => word.length > 0); + + if (words.length === 0) { + return { results: [], totalMatches: 0, query: safeQuery }; + } + + const isAborted = () => signal?.aborted === true; + if (isAborted()) { + return { results: [], totalMatches: 0, query: safeQuery }; + } + + const searchableSessions = normalizeSearchableSessions(sessionsDb.getAllSessions()); + if (searchableSessions.length === 0) { + return { results: [], totalMatches: 0, query: safeQuery }; + } + + const sessionsByPathKey = new Map(); + const searchablePathEntries: SearchablePathEntry[] = []; + + for (const session of searchableSessions) { + const normalizedPath = normalizeComparablePath(session.jsonl_path); + if (!normalizedPath) { + continue; + } + + if (!sessionsByPathKey.has(normalizedPath)) { + sessionsByPathKey.set(normalizedPath, []); + searchablePathEntries.push({ + normalizedPath, + absolutePath: session.jsonl_path, + }); + } + + const pathSessions = sessionsByPathKey.get(normalizedPath) as SearchableSessionRow[]; + pathSessions.push(session); + } + + const matchedFileKeys = await findMatchedFileKeys( + searchablePathEntries, + safeQuery, + words, + signal ?? undefined, + ); + if (isAborted() || matchedFileKeys.size === 0) { + return { results: [], totalMatches: 0, query: safeQuery }; + } + + const matchedSessionKeys = new Set(); + for (const fileKey of matchedFileKeys) { + const sessions = sessionsByPathKey.get(fileKey); + if (!sessions) { + continue; + } + + for (const session of sessions) { + matchedSessionKeys.add(getSessionKey(session)); + } + } + + const projectBuckets = buildProjectBuckets(searchableSessions); + const totalProjects = projectBuckets.length; + const results: ProjectConversationResult[] = []; + let scannedProjects = 0; + + const runtime: SearchRuntime = { + ...createWordMatcher(safeQuery, words), + limit: safeLimit, + totalMatches: 0, + isAborted, + matchedSessionKeys, + claudeSessionsByFileKey: new Map(), + claudeFileResultsCache: new Map>(), + }; + + for (const [fileKey, sessions] of sessionsByPathKey.entries()) { + const claudeSessions = sessions.filter((session) => session.provider === 'claude'); + if (claudeSessions.length > 0) { + runtime.claudeSessionsByFileKey.set(fileKey, claudeSessions); + } + } + + for (const bucket of projectBuckets) { + if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { + break; + } + + const projectResult: ProjectConversationResult = { + projectId: bucket.projectId, + projectName: bucket.projectName, + projectDisplayName: bucket.projectDisplayName, + sessions: [], + }; + + for (const session of bucket.sessions) { + if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { + break; + } + if (!matchedSessionKeys.has(getSessionKey(session))) { + continue; + } + + const sessionResult = await parseSessionMatches(session, runtime); + if (sessionResult) { + projectResult.sessions.push(sessionResult); + } + } + + scannedProjects += 1; + if (projectResult.sessions.length > 0) { + results.push(projectResult); + onProjectResult?.({ + projectResult, + totalMatches: runtime.totalMatches, + scannedProjects, + totalProjects, + }); + } else if (onProjectResult && scannedProjects % 10 === 0) { + onProjectResult({ + projectResult: null, + totalMatches: runtime.totalMatches, + scannedProjects, + totalProjects, + }); + } + } + + return { + results, + totalMatches: runtime.totalMatches, + query: safeQuery, + }; } /** diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index 90d90833..b99ffe27 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -114,9 +114,9 @@ export function useSidebarController({ const [conversationResults, setConversationResults] = useState(null); const [isSearching, setIsSearching] = useState(false); const [searchProgress, setSearchProgress] = useState(null); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState>(new Map()); const [loadingMoreProjects, setLoadingMoreProjects] = useState>(new Set()); - const searchTimeoutRef = useRef | null>(null); const searchSeqRef = useRef(0); const eventSourceRef = useRef(null); const starToggleSequenceByProjectRef = useRef>(new Map()); @@ -248,17 +248,26 @@ export function useSidebarController({ }); }, [projects]); + // Debounce search text updates so both project filtering and conversation + // SSE requests avoid running on every keypress. + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedSearchQuery(searchFilter.trim()); + }, 300); + + return () => { + clearTimeout(timeout); + }; + }, [searchFilter]); + // Debounced conversation search with SSE streaming useEffect(() => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } - const query = searchFilter.trim(); + const query = debouncedSearchQuery; if (searchMode !== 'conversations' || query.length < 2) { searchSeqRef.current += 1; setConversationResults(null); @@ -270,78 +279,75 @@ export function useSidebarController({ setIsSearching(true); const seq = ++searchSeqRef.current; - searchTimeoutRef.current = setTimeout(() => { - if (seq !== searchSeqRef.current) return; + if (seq !== searchSeqRef.current) { + return; + } - const url = api.searchConversationsUrl(query); - const es = new EventSource(url); - eventSourceRef.current = es; + const url = api.searchConversationsUrl(query); + const es = new EventSource(url); + eventSourceRef.current = es; - const accumulated: ConversationProjectResult[] = []; - let totalMatches = 0; + const accumulated: ConversationProjectResult[] = []; + let totalMatches = 0; - es.addEventListener('result', (evt) => { - if (seq !== searchSeqRef.current) { es.close(); return; } - try { - const data = JSON.parse(evt.data) as { - projectResult: ConversationProjectResult; - totalMatches: number; - scannedProjects: number; - totalProjects: number; - }; - accumulated.push(data.projectResult); - totalMatches = data.totalMatches; - setConversationResults({ results: [...accumulated], totalMatches, query }); - setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects }); - } catch { - // Ignore malformed SSE data - } - }); + es.addEventListener('result', (evt) => { + if (seq !== searchSeqRef.current) { es.close(); return; } + try { + const data = JSON.parse(evt.data) as { + projectResult: ConversationProjectResult; + totalMatches: number; + scannedProjects: number; + totalProjects: number; + }; + accumulated.push(data.projectResult); + totalMatches = data.totalMatches; + setConversationResults({ results: [...accumulated], totalMatches, query }); + setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects }); + } catch { + // Ignore malformed SSE data + } + }); - es.addEventListener('progress', (evt) => { - if (seq !== searchSeqRef.current) { es.close(); return; } - try { - const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number }; - totalMatches = data.totalMatches; - setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects }); - } catch { - // Ignore malformed SSE data - } - }); + es.addEventListener('progress', (evt) => { + if (seq !== searchSeqRef.current) { es.close(); return; } + try { + const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number }; + totalMatches = data.totalMatches; + setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects }); + } catch { + // Ignore malformed SSE data + } + }); - es.addEventListener('done', () => { - if (seq !== searchSeqRef.current) { es.close(); return; } - es.close(); - eventSourceRef.current = null; - setIsSearching(false); - setSearchProgress(null); - if (accumulated.length === 0) { - setConversationResults({ results: [], totalMatches: 0, query }); - } - }); + es.addEventListener('done', () => { + if (seq !== searchSeqRef.current) { es.close(); return; } + es.close(); + eventSourceRef.current = null; + setIsSearching(false); + setSearchProgress(null); + if (accumulated.length === 0) { + setConversationResults({ results: [], totalMatches: 0, query }); + } + }); - es.addEventListener('error', () => { - if (seq !== searchSeqRef.current) { es.close(); return; } - es.close(); - eventSourceRef.current = null; - setIsSearching(false); - setSearchProgress(null); - if (accumulated.length === 0) { - setConversationResults({ results: [], totalMatches: 0, query }); - } - }); - }, 400); + es.addEventListener('error', () => { + if (seq !== searchSeqRef.current) { es.close(); return; } + es.close(); + eventSourceRef.current = null; + setIsSearching(false); + setSearchProgress(null); + if (accumulated.length === 0) { + setConversationResults({ results: [], totalMatches: 0, query }); + } + }); return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } }; - }, [searchFilter, searchMode]); + }, [debouncedSearchQuery, searchMode]); // All sidebar state keys (expanded, starred, loading, etc.) use the DB // `projectId` as their identifier after the migration. @@ -493,8 +499,8 @@ export function useSidebarController({ ); const filteredProjects = useMemo( - () => filterProjects(sortedProjects, searchFilter), - [searchFilter, sortedProjects], + () => filterProjects(sortedProjects, debouncedSearchQuery), + [debouncedSearchQuery, sortedProjects], ); const startEditing = useCallback((project: Project) => {