mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-15 19:07:23 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user