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

@@ -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();
}}

View File

@@ -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(
<mark key={h.start} className="bg-yellow-200 dark:bg-yellow-800 text-foreground rounded-sm px-0.5">
{snippet.slice(h.start, h.end)}
</mark>
);
cursor = h.end;
}
if (cursor < snippet.length) {
parts.push(snippet.slice(cursor));
}
return (
<span className="text-xs text-muted-foreground leading-relaxed">
{parts}
</span>
);
}
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 (
<div
className="flex h-full flex-col bg-background/80 backdrop-blur-sm md:w-72 md:select-none"
@@ -60,6 +104,8 @@ export default function SidebarContent({
searchFilter={searchFilter}
onSearchFilterChange={onSearchFilterChange}
onClearSearchFilter={onClearSearchFilter}
searchMode={searchMode}
onSearchModeChange={onSearchModeChange}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
onCreateProject={onCreateProject}
@@ -68,7 +114,103 @@ export default function SidebarContent({
/>
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
<SidebarProjectList {...projectListProps} />
{showConversationSearch ? (
isSearching && !hasPartialResults ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
<p className="text-sm text-muted-foreground">{t('search.searching')}</p>
{searchProgress && (
<p className="text-xs text-muted-foreground/60 mt-1">
{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}
</p>
)}
</div>
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('search.noResults')}</h3>
<p className="text-sm text-muted-foreground">{t('search.tryDifferentQuery')}</p>
</div>
) : hasPartialResults ? (
<div className="space-y-3 px-2">
<div className="flex items-center justify-between px-1">
<p className="text-xs text-muted-foreground">
{t('search.matches', { count: conversationResults.totalMatches })}
</p>
{isSearching && searchProgress && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
<p className="text-[10px] text-muted-foreground/60">
{searchProgress.scannedProjects}/{searchProgress.totalProjects}
</p>
</div>
)}
</div>
{isSearching && searchProgress && (
<div className="mx-1 h-0.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary/60 rounded-full transition-all duration-300"
style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}
/>
</div>
)}
{conversationResults.results.map((projectResult) => (
<div key={projectResult.projectName} className="space-y-1">
<div className="flex items-center gap-1.5 px-1 py-1">
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-medium text-foreground truncate">
{projectResult.projectDisplayName}
</span>
</div>
{projectResult.sessions.map((session) => (
<button
key={`${projectResult.projectName}-${session.sessionId}`}
className="w-full text-left rounded-md px-2 py-2 hover:bg-accent/50 transition-colors"
onClick={() => onConversationResultClick(
projectResult.projectName,
session.sessionId,
session.provider || session.matches[0]?.provider || 'claude',
session.matches[0]?.timestamp,
session.matches[0]?.snippet
)}
>
<div className="flex items-center gap-1.5 mb-1">
<MessageSquare className="w-3 h-3 text-primary flex-shrink-0" />
<span className="text-xs font-medium text-foreground truncate">
{session.sessionSummary}
</span>
{session.provider && session.provider !== 'claude' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase flex-shrink-0">
{session.provider}
</span>
)}
</div>
<div className="space-y-1 pl-4">
{session.matches.map((match, idx) => (
<div key={idx} className="flex items-start gap-1">
<span className="text-[10px] text-muted-foreground/60 font-medium uppercase flex-shrink-0 mt-0.5">
{match.role === 'user' ? 'U' : 'A'}
</span>
<HighlightedSnippet
snippet={match.snippet}
highlights={match.highlights}
/>
</div>
))}
</div>
</button>
))}
</div>
))}
</div>
) : null
) : (
<SidebarProjectList {...projectListProps} />
)}
</ScrollArea>
<SidebarFooter

View File

@@ -1,7 +1,10 @@
import { FolderPlus, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button, Input } from '../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../constants/config';
import { cn } from '../../../../lib/utils';
type SearchMode = 'projects' | 'conversations';
type SidebarHeaderProps = {
isPWA: boolean;
@@ -11,6 +14,8 @@ type SidebarHeaderProps = {
searchFilter: string;
onSearchFilterChange: (value: string) => 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 && (
<div className="relative mt-2.5">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => 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 && (
<div className="mt-2.5 space-y-2">
{/* Search mode toggle */}
<div className="flex rounded-lg bg-muted/50 p-0.5">
<button
onClick={onClearSearchFilter}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<X className="h-3 w-3 text-muted-foreground" />
<Folder className="w-3 h-3" />
{t('search.modeProjects')}
</button>
)}
<button
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="w-3 h-3" />
{t('search.modeConversations')}
</button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50 pointer-events-none" />
<Input
type="text"
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => 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 && (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded-md"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
</div>
)}
</div>
@@ -162,23 +201,54 @@ export default function SidebarHeader({
{/* Mobile search */}
{projectsCount > 0 && !isLoading && (
<div className="relative mt-2.5">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
<Input
type="text"
placeholder={t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => 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 && (
<div className="mt-2.5 space-y-2">
<div className="flex rounded-lg bg-muted/50 p-0.5">
<button
onClick={onClearSearchFilter}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
onClick={() => onSearchModeChange('projects')}
aria-pressed={searchMode === 'projects'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'projects'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
<Folder className="w-3 h-3" />
{t('search.modeProjects')}
</button>
)}
<button
onClick={() => onSearchModeChange('conversations')}
aria-pressed={searchMode === 'conversations'}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
searchMode === 'conversations'
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="w-3 h-3" />
{t('search.modeConversations')}
</button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/50 pointer-events-none" />
<Input
type="text"
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => 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 && (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded-md"
>
<X className="w-3.5 h-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
)}
</div>