mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 16:37:40 +00:00
Merge branch 'main' into feat/plugin-system
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);
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user