diff --git a/server/index.js b/server/index.js index 8f25fc29..30116c4b 100755 --- a/server/index.js +++ b/server/index.js @@ -44,7 +44,7 @@ import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; -import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; +import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; @@ -608,6 +608,51 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => { } }); +// Search conversations content (SSE streaming) +app.get('/api/search/conversations', authenticateToken, async (req, res) => { + const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''; + const parsedLimit = Number.parseInt(String(req.query.limit), 10); + const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100)); + + if (query.length < 2) { + return res.status(400).json({ error: 'Query must be at least 2 characters' }); + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + let closed = false; + const abortController = new AbortController(); + req.on('close', () => { closed = true; abortController.abort(); }); + + try { + await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => { + if (closed) return; + if (projectResult) { + res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`); + } else { + res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`); + } + }, abortController.signal); + if (!closed) { + res.write(`event: done\ndata: {}\n\n`); + } + } catch (error) { + console.error('Error searching conversations:', error); + if (!closed) { + res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`); + } + } finally { + if (!closed) { + res.end(); + } + } +}); + const expandWorkspacePath = (inputPath) => { if (!inputPath) return inputPath; if (inputPath === '~') { diff --git a/server/projects.js b/server/projects.js index 586b58d3..875ca2c5 100755 --- a/server/projects.js +++ b/server/projects.js @@ -481,9 +481,13 @@ async function getProjects(progressCallback = null) { } applyCustomSessionNames(project.codexSessions, 'codex'); - // Also fetch Gemini sessions for this project + // Also fetch Gemini sessions for this project (UI + CLI) try { - project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; + const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; + const cliSessions = await getGeminiCliSessions(actualProjectDir); + const uiIds = new Set(uiSessions.map(s => s.id)); + const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))]; + project.geminiSessions = mergedGemini; } catch (e) { console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message); project.geminiSessions = []; @@ -584,9 +588,12 @@ async function getProjects(progressCallback = null) { } applyCustomSessionNames(project.codexSessions, 'codex'); - // Try to fetch Gemini sessions for manual projects too + // Try to fetch Gemini sessions for manual projects too (UI + CLI) try { - project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; + const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; + const cliSessions = await getGeminiCliSessions(actualProjectDir); + const uiIds = new Set(uiSessions.map(s => s.id)); + project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))]; } catch (e) { console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message); } @@ -1862,6 +1869,675 @@ async function deleteCodexSession(sessionId) { } } +async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) { + 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 = []; + let totalMatches = 0; + const words = safeQuery.toLowerCase().split(/\s+/).filter(w => w.length > 0); + if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery }; + + const isAborted = () => signal?.aborted === true; + + const isSystemMessage = (textContent) => { + 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' + ); + }; + + const extractText = (content) => { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter(part => part.type === 'text' && part.text) + .map(part => part.text) + .join(' '); + } + return ''; + }; + + const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const wordPatterns = words.map(w => new RegExp(`(? { + return wordPatterns.every(p => p.test(textLower)); + }; + + const buildSnippet = (text, textLower, snippetLen = 150) => { + let firstIndex = -1; + let firstWordLen = 0; + for (const w of words) { + const re = new RegExp(`(? 0 ? '...' : ''; + const suffix = end < text.length ? '...' : ''; + snippet = prefix + snippet + suffix; + const snippetLower = snippet.toLowerCase(); + const highlights = []; + for (const word of words) { + const re = new RegExp(`(? a.start - b.start); + const merged = []; + for (const h of highlights) { + const last = merged[merged.length - 1]; + if (last && h.start <= last.end) { + last.end = Math.max(last.end, h.end); + } else { + merged.push({ ...h }); + } + } + return { snippet, highlights: merged }; + }; + + try { + await fs.access(claudeDir); + const entries = await fs.readdir(claudeDir, { withFileTypes: true }); + const projectDirs = entries.filter(e => e.isDirectory()); + let scannedProjects = 0; + const totalProjects = projectDirs.length; + + for (const projectEntry of projectDirs) { + if (totalMatches >= safeLimit || isAborted()) break; + + const projectName = projectEntry.name; + const projectDir = path.join(claudeDir, projectName); + const displayName = config[projectName]?.displayName + || await generateDisplayName(projectName); + + let files; + try { + files = await fs.readdir(projectDir); + } catch { + continue; + } + + const jsonlFiles = files.filter( + file => file.endsWith('.jsonl') && !file.startsWith('agent-') + ); + + const projectResult = { + projectName, + projectDisplayName: displayName, + 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 = 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; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + if (entry.sessionId) { + currentSessionId = entry.sessionId; + } + if (entry.type === 'summary' && entry.summary) { + const sid = entry.sessionId || currentSessionId; + if (sid) { + sessionSummaries.set(sid, entry.summary); + } else if (entry.leafUuid) { + pendingSummaries.set(entry.leafUuid, entry.summary); + } + } + + // Apply pending summary via parentUuid + if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) { + const pending = pendingSummaries.get(entry.parentUuid); + if (pending) sessionSummaries.set(currentSessionId, pending); + } + + // Track last user/assistant message for fallback title + 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 msgs = sessionLastMessages.get(currentSessionId); + if (role === 'user') msgs.user = text; + else msgs.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 sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', ''); + if (!sessionMatches.has(sessionId)) { + sessionMatches.set(sessionId, []); + } + + const matches = sessionMatches.get(sessionId); + if (matches.length < 2) { + const { snippet, highlights } = buildSnippet(text, textLower); + matches.push({ + role: entry.message.role, + snippet, + highlights, + timestamp: entry.timestamp || null, + provider: 'claude', + messageUuid: entry.uuid || null + }); + totalMatches++; + } + } + } catch { + continue; + } + + for (const [sessionId, matches] of sessionMatches) { + projectResult.sessions.push({ + sessionId, + provider: 'claude', + sessionSummary: sessionSummaries.get(sessionId) || (() => { + const msgs = sessionLastMessages.get(sessionId); + const lastMsg = msgs?.user || msgs?.assistant; + return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session'; + })(), + matches + }); + } + } + + // Search Codex sessions for this project + try { + const actualProjectDir = await extractProjectDirectory(projectName); + if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { + await searchCodexSessionsForProject( + actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage, + buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted + ); + } + } catch { + // Skip codex search errors + } + + // Search Gemini sessions for this project + try { + const actualProjectDir = await extractProjectDirectory(projectName); + if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { + await searchGeminiSessionsForProject( + actualProjectDir, projectResult, words, allWordsMatch, + buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; } + ); + } + } catch { + // Skip gemini search errors + } + + scannedProjects++; + if (projectResult.sessions.length > 0) { + results.push(projectResult); + if (onProjectResult) { + onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects }); + } + } else if (onProjectResult && scannedProjects % 10 === 0) { + onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects }); + } + } + } catch { + // claudeDir doesn't exist + } + + return { results, totalMatches, query: safeQuery }; +} + +async function searchCodexSessionsForProject( + projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage, + buildSnippet, limit, getTotalMatches, addMatches, isAborted +) { + 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 }); + + // First pass: read session_meta to check project path match + let sessionMeta = null; + for await (const line of rl) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (entry.type === 'session_meta' && entry.payload) { + sessionMeta = entry.payload; + break; + } + } catch { continue; } + } + + // Skip sessions that don't belong to this project + if (!sessionMeta) continue; + const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd); + if (sessionProjectPath !== normalizedProjectPath) continue; + + // Second pass: re-read file to find matching messages + const fileStream2 = fsSync.createReadStream(filePath); + const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity }); + let lastUserMessage = null; + const matches = []; + + for await (const line of rl2) { + if (getTotalMatches() >= limit || isAborted()) break; + if (!line.trim()) continue; + + let entry; + try { entry = JSON.parse(line); } catch { continue; } + + let text = null; + let role = null; + + if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) { + text = entry.payload.message; + role = 'user'; + lastUserMessage = text; + } else if (entry.type === 'response_item' && entry.payload?.type === 'message') { + const contentParts = entry.payload.content || []; + if (entry.payload.role === 'user') { + text = contentParts + .filter(p => p.type === 'input_text' && p.text) + .map(p => p.text) + .join(' '); + role = 'user'; + if (text) lastUserMessage = text; + } else if (entry.payload.role === 'assistant') { + text = contentParts + .filter(p => p.type === 'output_text' && p.text) + .map(p => p.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 || null, provider: 'codex' }); + addMatches(1); + } + } + + if (matches.length > 0) { + projectResult.sessions.push({ + sessionId: sessionMeta.id, + provider: 'codex', + sessionSummary: lastUserMessage + ? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) + : 'Codex Session', + matches + }); + } + } catch { + continue; + } + } +} + +async function searchGeminiSessionsForProject( + projectPath, projectResult, words, allWordsMatch, + buildSnippet, limit, getTotalMatches, addMatches +) { + // 1) Search in-memory sessions (created via UI) + for (const [sessionId, session] of sessionManager.sessions) { + if (getTotalMatches() >= limit) break; + if (session.projectPath !== projectPath) continue; + + const matches = []; + for (const msg of session.messages) { + 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(p => p.type === 'text').map(p => p.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: msg.role, snippet, highlights, + timestamp: msg.timestamp ? msg.timestamp.toISOString() : null, + provider: 'gemini' + }); + addMatches(1); + } + } + + if (matches.length > 0) { + const firstUserMsg = session.messages.find(m => m.role === 'user'); + const summary = firstUserMsg?.content + ? (typeof firstUserMsg.content === 'string' + ? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content) + : 'Gemini Session') + : 'Gemini Session'; + + projectResult.sessions.push({ + sessionId, + provider: 'gemini', + sessionSummary: summary, + matches + }); + } + } + + // 2) Search Gemini CLI sessions on disk (~/.gemini/tmp//chats/*.json) + 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) { + trackedSessionIds.add(sid); + } + + let projectDirs; + 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; + 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); + if (!session.messages || !Array.isArray(session.messages)) continue; + + const cliSessionId = session.sessionId || chatFile.replace('.json', ''); + if (trackedSessionIds.has(cliSessionId)) continue; + + const matches = []; + let firstUserText = null; + + for (const msg of session.messages) { + 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(p => p.text) + .map(p => p.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 || 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 { + continue; + } + } + } +} + +async function getGeminiCliSessions(projectPath) { + const normalizedProjectPath = normalizeComparablePath(projectPath); + if (!normalizedProjectPath) return []; + + const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); + try { + await fs.access(geminiTmpDir); + } catch { + return []; + } + + const sessions = []; + let projectDirs; + try { + projectDirs = await fs.readdir(geminiTmpDir); + } catch { + return []; + } + + for (const projectDir of projectDirs) { + 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; + try { + chatFiles = await fs.readdir(chatsDir); + } catch { + continue; + } + + for (const chatFile of chatFiles) { + if (!chatFile.endsWith('.json')) continue; + try { + const filePath = path.join(chatsDir, chatFile); + const data = await fs.readFile(filePath, 'utf8'); + const session = JSON.parse(data); + if (!session.messages || !Array.isArray(session.messages)) continue; + + const sessionId = session.sessionId || chatFile.replace('.json', ''); + const firstUserMsg = session.messages.find(m => m.type === 'user'); + let summary = 'Gemini CLI Session'; + if (firstUserMsg) { + const text = Array.isArray(firstUserMsg.content) + ? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ') + : (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : ''); + if (text) { + summary = text.length > 50 ? text.substring(0, 50) + '...' : text; + } + } + + sessions.push({ + id: sessionId, + summary, + messageCount: session.messages.length, + lastActivity: session.lastUpdated || session.startTime || null, + provider: 'gemini' + }); + } catch { + continue; + } + } + } + + return sessions.sort((a, b) => + new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0) + ); +} + +async function getGeminiCliSessionMessages(sessionId) { + const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); + let projectDirs; + try { + projectDirs = await fs.readdir(geminiTmpDir); + } catch { + return []; + } + + for (const projectDir of projectDirs) { + const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); + let chatFiles; + try { + chatFiles = await fs.readdir(chatsDir); + } catch { + continue; + } + + for (const chatFile of chatFiles) { + if (!chatFile.endsWith('.json')) continue; + try { + const filePath = path.join(chatsDir, chatFile); + const data = await fs.readFile(filePath, 'utf8'); + const session = JSON.parse(data); + const fileSessionId = session.sessionId || chatFile.replace('.json', ''); + if (fileSessionId !== sessionId) continue; + + return (session.messages || []).map(msg => { + const role = msg.type === 'user' ? 'user' + : (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant' + : msg.type; + + let content = ''; + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = msg.content.filter(p => p.text).map(p => p.text).join('\n'); + } + + return { + type: 'message', + message: { role, content }, + timestamp: msg.timestamp || null + }; + }); + } catch { + continue; + } + } + } + + return []; +} + export { getProjects, getSessions, @@ -1878,5 +2554,8 @@ export { clearProjectDirectoryCache, getCodexSessions, getCodexSessionMessages, - deleteCodexSession + deleteCodexSession, + getGeminiCliSessions, + getGeminiCliSessionMessages, + searchConversations }; diff --git a/server/routes/gemini.js b/server/routes/gemini.js index 7c2f3425..594a4683 100644 --- a/server/routes/gemini.js +++ b/server/routes/gemini.js @@ -1,6 +1,7 @@ import express from 'express'; import sessionManager from '../sessionManager.js'; import { sessionNamesDb } from '../database/db.js'; +import { getGeminiCliSessionMessages } from '../projects.js'; const router = express.Router(); @@ -12,7 +13,12 @@ router.get('/sessions/:sessionId/messages', async (req, res) => { return res.status(400).json({ success: false, error: 'Invalid session ID format' }); } - const messages = sessionManager.getSessionMessages(sessionId); + let messages = sessionManager.getSessionMessages(sessionId); + + // Fallback to Gemini CLI sessions on disk + if (messages.length === 0) { + messages = await getGeminiCliSessionMessages(sessionId); + } res.json({ success: true, diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 71c9a6cb..fc9dd50e 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -82,6 +82,8 @@ export function useChatSessionState({ const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false); const scrollContainerRef = useRef(null); + const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null); + const searchScrollActiveRef = useRef(false); const isLoadingSessionRef = useRef(false); const isLoadingMoreRef = useRef(false); const allMessagesLoadedRef = useRef(false); @@ -301,12 +303,14 @@ export function useChatSessionState({ const isInitialLoadRef = useRef(true); useEffect(() => { - pendingInitialScrollRef.current = true; + if (!searchScrollActiveRef.current) { + pendingInitialScrollRef.current = true; + setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); + } topLoadLockRef.current = false; pendingScrollRestoreRef.current = null; prevSessionMessagesLengthRef.current = 0; isInitialLoadRef.current = true; - setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); setIsUserScrolledUp(false); }, [selectedProject?.name, selectedSession?.id]); @@ -321,9 +325,11 @@ export function useChatSessionState({ } pendingInitialScrollRef.current = false; - setTimeout(() => { - scrollToBottom(); - }, 200); + if (!searchScrollActiveRef.current) { + setTimeout(() => { + scrollToBottom(); + }, 200); + } }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); useEffect(() => { @@ -502,13 +508,28 @@ export function useChatSessionState({ selectedSession, ]); + // Detect search navigation target from selectedSession object reference change + // This must be a separate effect because the loading effect depends on selectedSession?.id + // which doesn't change when clicking a search result for the already-loaded session + useEffect(() => { + const session = selectedSession as Record | null; + const targetSnippet = session?.__searchTargetSnippet; + const targetTimestamp = session?.__searchTargetTimestamp; + if (typeof targetSnippet === 'string' && targetSnippet) { + searchScrollActiveRef.current = true; + setSearchTarget({ + snippet: targetSnippet, + timestamp: typeof targetTimestamp === 'string' ? targetTimestamp : undefined, + }); + } + }, [selectedSession]); + useEffect(() => { if (selectedSession?.id) { pendingViewSessionRef.current = null; } }, [pendingViewSessionRef, selectedSession?.id]); - useEffect(() => { // Only sync sessionMessages to chatMessages when: // 1. Not currently loading (to avoid overwriting user's just-sent message) @@ -533,6 +554,110 @@ export function useChatSessionState({ } }, [chatMessages, selectedProject]); + // Scroll to search target message after messages are loaded + useEffect(() => { + if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return; + + const target = searchTarget; + // Clear immediately to prevent re-triggering + setSearchTarget(null); + + const scrollToTarget = async () => { + // Always load all messages when navigating from search + // (hasMoreMessages may not be set yet due to race with loading effect) + if (!allMessagesLoadedRef.current && selectedSession && selectedProject) { + const sessionProvider = selectedSession.__provider || 'claude'; + if (sessionProvider !== 'cursor') { + try { + const response = await (api.sessionMessages as any)( + selectedProject.name, + selectedSession.id, + null, + 0, + sessionProvider, + ); + if (response.ok) { + const data = await response.json(); + const allMessages = data.messages || data; + setSessionMessages(Array.isArray(allMessages) ? allMessages : []); + setHasMoreMessages(false); + setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0); + messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0; + setVisibleMessageCount(Infinity); + setAllMessagesLoaded(true); + allMessagesLoadedRef.current = true; + // Wait for messages to render after state update + await new Promise(resolve => setTimeout(resolve, 300)); + } + } catch { + // Fall through and scroll in current messages + } + } + } + setVisibleMessageCount(Infinity); + + // Retry finding the element in the DOM until React finishes rendering all messages + const findAndScroll = (retriesLeft: number) => { + const container = scrollContainerRef.current; + if (!container) return; + + let targetElement: Element | null = null; + + // Match by snippet text content (most reliable) + if (target.snippet) { + const cleanSnippet = target.snippet.replace(/^\.{3}/, '').replace(/\.{3}$/, '').trim(); + // Use a contiguous substring from the snippet (don't filter words, it breaks matching) + const searchPhrase = cleanSnippet.slice(0, 80).toLowerCase().trim(); + + if (searchPhrase.length >= 10) { + const messageElements = container.querySelectorAll('.chat-message'); + for (const el of messageElements) { + const text = (el.textContent || '').toLowerCase(); + if (text.includes(searchPhrase)) { + targetElement = el; + break; + } + } + } + } + + // Fallback to timestamp matching + if (!targetElement && target.timestamp) { + const targetDate = new Date(target.timestamp).getTime(); + const messageElements = container.querySelectorAll('[data-message-timestamp]'); + let closestDiff = Infinity; + + for (const el of messageElements) { + const ts = el.getAttribute('data-message-timestamp'); + if (!ts) continue; + const diff = Math.abs(new Date(ts).getTime() - targetDate); + if (diff < closestDiff) { + closestDiff = diff; + targetElement = el; + } + } + } + + if (targetElement) { + targetElement.scrollIntoView({ block: 'center', behavior: 'smooth' }); + targetElement.classList.add('search-highlight-flash'); + setTimeout(() => targetElement?.classList.remove('search-highlight-flash'), 4000); + searchScrollActiveRef.current = false; + } else if (retriesLeft > 0) { + setTimeout(() => findAndScroll(retriesLeft - 1), 200); + } else { + searchScrollActiveRef.current = false; + } + }; + + // Start polling after a short delay to let React begin rendering + setTimeout(() => findAndScroll(15), 150); + }; + + scrollToTarget(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatMessages.length, isLoadingSessionMessages, searchTarget]); + useEffect(() => { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { setTokenBudget(null); @@ -588,6 +713,10 @@ export function useChatSessionState({ return; } + if (searchScrollActiveRef.current) { + return; + } + if (autoScrollToBottom) { if (!isUserScrolledUp) { setTimeout(() => scrollToBottom(), 50); diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index cdb4d686..6542e370 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -96,6 +96,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o return (
{message.type === 'user' ? ( diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index 600057d8..7141208e 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type React from 'react'; import type { TFunction } from 'i18next'; import { api } from '../../../utils/api'; import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; @@ -19,6 +20,44 @@ import { sortProjects, } from '../utils/utils'; +type SnippetHighlight = { + start: number; + end: number; +}; + +type ConversationMatch = { + role: string; + snippet: string; + highlights: SnippetHighlight[]; + timestamp: string | null; + provider?: string; + messageUuid?: string | null; +}; + +type ConversationSession = { + sessionId: string; + sessionSummary: string; + provider?: string; + matches: ConversationMatch[]; +}; + +type ConversationProjectResult = { + projectName: string; + projectDisplayName: string; + sessions: ConversationSession[]; +}; + +export type ConversationSearchResults = { + results: ConversationProjectResult[]; + totalMatches: number; + query: string; +}; + +export type SearchProgress = { + scannedProjects: number; + totalProjects: number; +}; + type UseSidebarControllerArgs = { projects: Project[]; selectedProject: Project | null; @@ -71,6 +110,13 @@ export function useSidebarController({ const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); const [showVersionModal, setShowVersionModal] = useState(false); const [starredProjects, setStarredProjects] = useState>(() => loadStarredProjects()); + const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects'); + const [conversationResults, setConversationResults] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [searchProgress, setSearchProgress] = useState(null); + const searchTimeoutRef = useRef | null>(null); + const searchSeqRef = useRef(0); + const eventSourceRef = useRef(null); const isSidebarCollapsed = !isMobile && !sidebarVisible; @@ -140,6 +186,116 @@ export function useSidebarController({ }; }, []); + // 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(); + if (searchMode !== 'conversations' || query.length < 2) { + searchSeqRef.current += 1; + setConversationResults(null); + setSearchProgress(null); + setIsSearching(false); + return; + } + + setIsSearching(true); + const seq = ++searchSeqRef.current; + + searchTimeoutRef.current = setTimeout(() => { + if (seq !== searchSeqRef.current) return; + + const url = api.searchConversationsUrl(query); + const es = new EventSource(url); + eventSourceRef.current = es; + + 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('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('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); + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + }, [searchFilter, searchMode]); + + const handleTouchClick = useCallback( + (callback: () => void) => + (event: React.TouchEvent) => { + const target = event.target as HTMLElement; + if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + callback(); + }, + [], + ); + const toggleProject = useCallback((projectName: string) => { setExpandedProjects((prev) => { const next = new Set(); @@ -466,6 +622,21 @@ export function useSidebarController({ setEditingName, setEditingSession, setEditingSessionName, + searchMode, + setSearchMode, + conversationResults, + isSearching, + searchProgress, + clearConversationResults: useCallback(() => { + searchSeqRef.current += 1; + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + setIsSearching(false); + setSearchProgress(null); + setConversationResults(null); + }, []), setSearchFilter, setDeleteConfirmation, setSessionDeleteConfirmation, diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 36b7ba19..cba08749 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -60,6 +60,12 @@ function Sidebar({ editingSession, editingSessionName, searchFilter, + searchMode, + setSearchMode, + conversationResults, + isSearching, + searchProgress, + clearConversationResults, deletingProjects, deleteConfirmation, sessionDeleteConfirmation, @@ -220,6 +226,37 @@ function Sidebar({ searchFilter={searchFilter} onSearchFilterChange={setSearchFilter} onClearSearchFilter={() => setSearchFilter('')} + searchMode={searchMode} + onSearchModeChange={(mode: 'projects' | 'conversations') => { + setSearchMode(mode); + if (mode === 'projects') clearConversationResults(); + }} + conversationResults={conversationResults} + isSearching={isSearching} + searchProgress={searchProgress} + onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => { + const resolvedProvider = (provider || 'claude') as SessionProvider; + const project = projects.find(p => p.name === projectName); + const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null }; + const sessionObj = { + id: sessionId, + __provider: resolvedProvider, + __projectName: projectName, + ...searchTarget, + }; + if (project) { + handleProjectSelect(project); + const sessions = getProjectSessions(project); + const existing = sessions.find(s => s.id === sessionId); + if (existing) { + handleSessionClick({ ...existing, ...searchTarget }, projectName); + } else { + handleSessionClick(sessionObj, projectName); + } + } else { + handleSessionClick(sessionObj, projectName); + } + }} onRefresh={() => { void refreshProjects(); }} diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 495e0deb..bd09290d 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -1,3 +1,5 @@ +import { type ReactNode } from 'react'; +import { Folder, MessageSquare, Search } from 'lucide-react'; import type { TFunction } from 'i18next'; import { ScrollArea } from '../../../../shared/view/ui'; import type { Project } from '../../../../types/app'; @@ -5,6 +7,33 @@ import type { ReleaseInfo } from '../../../../types/sharedTypes'; import SidebarFooter from './SidebarFooter'; import SidebarHeader from './SidebarHeader'; import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList'; +import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController'; + +type SearchMode = 'projects' | 'conversations'; + +function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) { + const parts: ReactNode[] = []; + let cursor = 0; + for (const h of highlights) { + if (h.start > cursor) { + parts.push(snippet.slice(cursor, h.start)); + } + parts.push( + + {snippet.slice(h.start, h.end)} + + ); + cursor = h.end; + } + if (cursor < snippet.length) { + parts.push(snippet.slice(cursor)); + } + return ( + + {parts} + + ); +} type SidebarContentProps = { isPWA: boolean; @@ -14,6 +43,12 @@ type SidebarContentProps = { searchFilter: string; onSearchFilterChange: (value: string) => void; onClearSearchFilter: () => void; + searchMode: SearchMode; + onSearchModeChange: (mode: SearchMode) => void; + conversationResults: ConversationSearchResults | null; + isSearching: boolean; + searchProgress: SearchProgress | null; + onConversationResultClick: (projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void; onRefresh: () => void; isRefreshing: boolean; onCreateProject: () => void; @@ -35,6 +70,12 @@ export default function SidebarContent({ searchFilter, onSearchFilterChange, onClearSearchFilter, + searchMode, + onSearchModeChange, + conversationResults, + isSearching, + searchProgress, + onConversationResultClick, onRefresh, isRefreshing, onCreateProject, @@ -47,6 +88,9 @@ export default function SidebarContent({ projectListProps, t, }: SidebarContentProps) { + const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2; + const hasPartialResults = conversationResults && conversationResults.results.length > 0; + return (
- + {showConversationSearch ? ( + isSearching && !hasPartialResults ? ( +
+
+
+
+

{t('search.searching')}

+ {searchProgress && ( +

+ {t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects} +

+ )} +
+ ) : !isSearching && conversationResults && conversationResults.results.length === 0 ? ( +
+
+ +
+

{t('search.noResults')}

+

{t('search.tryDifferentQuery')}

+
+ ) : hasPartialResults ? ( +
+
+

+ {t('search.matches', { count: conversationResults.totalMatches })} +

+ {isSearching && searchProgress && ( +
+
+

+ {searchProgress.scannedProjects}/{searchProgress.totalProjects} +

+
+ )} +
+ {isSearching && searchProgress && ( +
+
+
+ )} + {conversationResults.results.map((projectResult) => ( +
+
+ + + {projectResult.projectDisplayName} + +
+ {projectResult.sessions.map((session) => ( + + ))} +
+ ))} +
+ ) : null + ) : ( + + )} void; onClearSearchFilter: () => void; + searchMode: SearchMode; + onSearchModeChange: (mode: SearchMode) => void; onRefresh: () => void; isRefreshing: boolean; onCreateProject: () => void; @@ -26,6 +31,8 @@ export default function SidebarHeader({ searchFilter, onSearchFilterChange, onClearSearchFilter, + searchMode, + onSearchModeChange, onRefresh, isRefreshing, onCreateProject, @@ -101,23 +108,55 @@ export default function SidebarHeader({ {/* Search bar */} {projectsCount > 0 && !isLoading && ( -
- - onSearchFilterChange(event.target.value)} - className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" - /> - {searchFilter && ( +
+ {/* Search mode toggle */} +
- )} + +
+
+ + onSearchFilterChange(event.target.value)} + className="nav-search-input pl-9 pr-8 h-9 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200" + /> + {searchFilter && ( + + )} +
)}
@@ -162,23 +201,54 @@ export default function SidebarHeader({ {/* Mobile search */} {projectsCount > 0 && !isLoading && ( -
- - onSearchFilterChange(event.target.value)} - className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" - /> - {searchFilter && ( +
+
- )} + +
+
+ + onSearchFilterChange(event.target.value)} + className="nav-search-input pl-10 pr-9 h-10 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200" + /> + {searchFilter && ( + + )} +
)}
diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index 630ad7df..a56c1b84 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -46,7 +46,8 @@ "editSessionName": "Manually edit session name", "deleteSession": "Delete this session permanently", "save": "Save", - "cancel": "Cancel" + "cancel": "Cancel", + "clearSearch": "Clear search" }, "navigation": { "chat": "Chat", @@ -103,6 +104,18 @@ "version": { "updateAvailable": "Update available" }, + "search": { + "modeProjects": "Projects", + "modeConversations": "Conversations", + "conversationsPlaceholder": "Search in conversations...", + "searching": "Searching...", + "noResults": "No results found", + "tryDifferentQuery": "Try a different search query", + "matches_one": "{{count}} match", + "matches_other": "{{count}} matches", + "projectsScanned_one": "{{count}} project scanned", + "projectsScanned_other": "{{count}} projects scanned" + }, "deleteConfirmation": { "deleteProject": "Delete Project", "deleteSession": "Delete Session", diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index 5e823f45..27cacec5 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -46,7 +46,8 @@ "editSessionName": "手动编辑会话名称", "deleteSession": "永久删除此会话", "save": "保存", - "cancel": "取消" + "cancel": "取消", + "clearSearch": "清除搜索" }, "navigation": { "chat": "聊天", @@ -103,6 +104,18 @@ "version": { "updateAvailable": "有可用更新" }, + "search": { + "modeProjects": "项目", + "modeConversations": "对话", + "conversationsPlaceholder": "搜索对话内容...", + "searching": "搜索中...", + "noResults": "未找到结果", + "tryDifferentQuery": "尝试不同的搜索词", + "matches_one": "{{count}} 个匹配", + "matches_other": "{{count}} 个匹配", + "projectsScanned_one": "{{count}} 个项目已扫描", + "projectsScanned_other": "{{count}} 个项目已扫描" + }, "deleteConfirmation": { "deleteProject": "删除项目", "deleteSession": "删除会话", diff --git a/src/index.css b/src/index.css index 7b8c097f..17620341 100644 --- a/src/index.css +++ b/src/index.css @@ -904,4 +904,23 @@ summary svg[class*="transition-transform"] { transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); } + + /* Search result highlight flash */ + .search-highlight-flash { + animation: search-flash 4s ease-out; + } + + @keyframes search-flash { + 0%, 50% { + outline: 3px solid hsl(var(--primary)); + outline-offset: 4px; + border-radius: 8px; + background-color: hsl(var(--primary) / 0.06); + } + 100% { + outline: 3px solid transparent; + outline-offset: 4px; + background-color: transparent; + } + } } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 465e5d11..ba368e4b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -6,4 +6,10 @@ declare global { refreshProjects?: () => void | Promise; openSettings?: (tab?: string) => void; } + + interface EventSourceEventMap { + result: MessageEvent; + progress: MessageEvent; + done: MessageEvent; + } } diff --git a/src/utils/api.js b/src/utils/api.js index 47c7c2eb..98b26c06 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -94,6 +94,12 @@ export const api = { authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, { method: 'DELETE', }), + searchConversationsUrl: (query, limit = 50) => { + const token = localStorage.getItem('auth-token'); + const params = new URLSearchParams({ q: query, limit: String(limit) }); + if (token) params.set('token', token); + return `/api/search/conversations?${params.toString()}`; + }, createProject: (path) => authenticatedFetch('/api/projects/create', { method: 'POST',