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

@@ -82,6 +82,8 @@ export function useChatSessionState({
const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(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<string, unknown> | 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);