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);

View File

@@ -96,6 +96,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
return (
<div
ref={messageRef}
data-message-timestamp={message.timestamp || undefined}
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
>
{message.type === 'user' ? (

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,

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>