feat: add full-text search across conversations (#482)

* feat: add full-text search across conversations in sidebar

Add a search mode toggle (Projects/Conversations) to the sidebar search bar.
In Conversations mode, search text content across all JSONL session files
with debounced API calls, highlighted snippets, and click-to-navigate results.

* fix: address PR review feedback - session summary tracking, search sequence invalidation, fallback navigation, SSE streaming

- Track session summaries per-session in a Map instead of file-scoped variable
- Increment searchSeqRef when clearing conversation search to invalidate in-flight requests
- Add fallback session navigation when session not loaded in sidebar paging
- Stream search results via SSE for progressive display with progress indicator

* feat(search): add Codex/Gemini search and scroll-to-message navigation

- Search now includes Codex sessions (JSONL from ~/.codex/sessions/) and
  Gemini sessions (in-memory via sessionManager) in addition to Claude
- Search results include provider info and display a provider badge
- Click handler resolves the correct provider instead of hardcoding claude
- Clicking a search result loads all messages and scrolls to the matched
  message with a highlight flash animation

* fix(search): Codex search path matching and scroll reliability

- Fix Codex search scanning all sessions for every project by checking
  session_meta cwd match BEFORE scanning messages (was inflating match
  count and hitting limit before reaching later projects)
- Fix Codex search missing user messages in response_item entries
  (role=user with input_text content parts)
- Fix scroll-to-message being overridden by initial scrollToBottom
  using searchScrollActiveRef to inhibit competing scroll effects
- Fix snippet matching using contiguous substring instead of
  filtered words (which created non-existent phrases)

* feat(search): add Gemini CLI session support for search and history viewing

Gemini CLI sessions stored in ~/.gemini/tmp/<project>/chats/*.json are now
indexed for conversation search and can be loaded for viewing. Previously
only sessions created through the UI (sessionManager) were searchable.

* fix(search): full-word matching and longer highlight flash

- Search now uses word boundaries (\b) instead of substring matching,
  so "hi" no longer matches "this"
- Highlight flash extended to 4s with thicker outline and subtle
  background tint for better visibility
This commit is contained in:
Eric Blanquer
2026-03-06 14:59:23 +01:00
committed by GitHub
parent d299ab88a0
commit 3950c0e47f
14 changed files with 1383 additions and 46 deletions

View File

@@ -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<SessionDeleteConfirmation | null>(null);
const [showVersionModal, setShowVersionModal] = useState(false);
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchSeqRef = useRef(0);
const eventSourceRef = useRef<EventSource | null>(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<HTMLElement>) => {
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<string>();
@@ -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,