import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Search, X, ChevronUp, ChevronDown, Clock, User, Filter, Globe, MessageSquare } from 'lucide-react'; import { Button } from './ui'; import { useChatStore, Message } from '../store/chatStore'; import { intelligence, PersistentMemory } from '../lib/intelligence-backend'; export interface SearchFilters { sender: 'all' | 'user' | 'assistant'; timeRange: 'all' | 'today' | 'week' | 'month'; } export type SearchScope = 'session' | 'global'; export interface SearchResult { message: Message; matchIndices: Array<{ start: number; end: number }>; } export interface GlobalSearchResult { memory: PersistentMemory; } interface MessageSearchProps { onNavigateToMessage: (messageId: string) => void; } const SEARCH_HISTORY_KEY = 'zclaw-search-history'; const MAX_HISTORY_ITEMS = 10; export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) { const { messages } = useChatStore(); const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(''); const [scope, setScope] = useState('session'); const [filters, setFilters] = useState({ sender: 'all', timeRange: 'all', }); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [showFilters, setShowFilters] = useState(false); const [searchHistory, setSearchHistory] = useState([]); const [globalResults, setGlobalResults] = useState([]); const [globalLoading, setGlobalLoading] = useState(false); const inputRef = useRef(null); // Load search history from localStorage useEffect(() => { try { const saved = localStorage.getItem(SEARCH_HISTORY_KEY); if (saved) { setSearchHistory(JSON.parse(saved)); } } catch { // Ignore parse errors } }, []); // Save search query to history const saveToHistory = useCallback((searchQuery: string) => { if (!searchQuery.trim()) return; setSearchHistory((prev) => { const filtered = prev.filter((item) => item !== searchQuery); const updated = [searchQuery, ...filtered].slice(0, MAX_HISTORY_ITEMS); try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated)); } catch { // Ignore storage errors } return updated; }); }, []); // Global search: query VikingStorage when scope is 'global' useEffect(() => { if (scope !== 'global' || !query.trim()) { setGlobalResults([]); return; } let cancelled = false; const debounceTimer = setTimeout(async () => { setGlobalLoading(true); try { const results = await intelligence.memory.search({ query: query.trim(), limit: 20, }); if (!cancelled) { setGlobalResults(results.map((memory) => ({ memory }))); } } catch (err) { if (!cancelled) { setGlobalResults([]); } } finally { if (!cancelled) { setGlobalLoading(false); } } }, 300); return () => { cancelled = true; clearTimeout(debounceTimer); }; }, [scope, query]); // Filter messages by time range const filterByTimeRange = useCallback((message: Message, timeRange: SearchFilters['timeRange']): boolean => { if (timeRange === 'all') return true; const messageTime = new Date(message.timestamp).getTime(); const now = Date.now(); const day = 24 * 60 * 60 * 1000; switch (timeRange) { case 'today': return messageTime >= now - day; case 'week': return messageTime >= now - 7 * day; case 'month': return messageTime >= now - 30 * day; default: return true; } }, []); // Filter messages by sender const filterBySender = useCallback((message: Message, sender: SearchFilters['sender']): boolean => { if (sender === 'all') return true; if (sender === 'user') return message.role === 'user'; if (sender === 'assistant') return message.role === 'assistant' || message.role === 'tool'; return true; }, []); // Search messages and find matches const searchResults = useMemo((): SearchResult[] => { if (!query.trim()) return []; const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean); if (searchTerms.length === 0) return []; const results: SearchResult[] = []; for (const message of messages) { // Apply filters if (!filterBySender(message, filters.sender)) continue; if (!filterByTimeRange(message, filters.timeRange)) continue; const content = message.content.toLowerCase(); const matchIndices: Array<{ start: number; end: number }> = []; // Find all matches for (const term of searchTerms) { let startIndex = 0; while (true) { const index = content.indexOf(term, startIndex); if (index === -1) break; matchIndices.push({ start: index, end: index + term.length }); startIndex = index + 1; } } if (matchIndices.length > 0) { // Sort and merge overlapping matches matchIndices.sort((a, b) => a.start - b.start); const merged: Array<{ start: number; end: number }> = []; for (const match of matchIndices) { if (merged.length === 0 || merged[merged.length - 1].end < match.start) { merged.push(match); } else { merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, match.end); } } results.push({ message, matchIndices: merged }); } } return results; }, [query, messages, filters, filterBySender, filterByTimeRange]); // Navigate to previous match const handlePrevious = useCallback(() => { if (searchResults.length === 0) return; setCurrentMatchIndex((prev) => prev > 0 ? prev - 1 : searchResults.length - 1 ); const result = searchResults[currentMatchIndex > 0 ? currentMatchIndex - 1 : searchResults.length - 1]; onNavigateToMessage(result.message.id); }, [searchResults, currentMatchIndex, onNavigateToMessage]); // Navigate to next match const handleNext = useCallback(() => { if (searchResults.length === 0) return; setCurrentMatchIndex((prev) => prev < searchResults.length - 1 ? prev + 1 : 0 ); const result = searchResults[currentMatchIndex < searchResults.length - 1 ? currentMatchIndex + 1 : 0]; onNavigateToMessage(result.message.id); }, [searchResults, currentMatchIndex, onNavigateToMessage]); // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+F or Cmd+F to open search if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); setIsOpen((prev) => !prev); setTimeout(() => inputRef.current?.focus(), 100); } // Escape to close search if (e.key === 'Escape' && isOpen) { setIsOpen(false); setQuery(''); } // Enter to navigate to next match if (e.key === 'Enter' && isOpen && searchResults.length > 0) { if (e.shiftKey) { handlePrevious(); } else { handleNext(); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, searchResults.length, handlePrevious, handleNext]); // Reset current match index when results change useEffect(() => { setCurrentMatchIndex(0); }, [searchResults.length]); // Handle search submit const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (query.trim()) { saveToHistory(query.trim()); if (searchResults.length > 0) { onNavigateToMessage(searchResults[0].message.id); } } }; // Clear search const handleClear = () => { setQuery(''); inputRef.current?.focus(); }; // Toggle search panel const toggleSearch = () => { setIsOpen((prev) => !prev); if (!isOpen) { setTimeout(() => inputRef.current?.focus(), 100); } }; return ( <> {/* Search toggle button */} {/* Search panel */} {isOpen && (
{/* Scope toggle */}
{/* Search input */}
setQuery(e.target.value)} placeholder={scope === 'global' ? 'Search all memories...' : 'Search messages...'} className="w-full pl-9 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400 focus:border-transparent" aria-label="Search query" /> {query && ( )}
{/* Filter toggle (session only) */} {scope === 'session' && ( )} {/* Navigation buttons (session only) */} {scope === 'session' && searchResults.length > 0 && (
{currentMatchIndex + 1} / {searchResults.length}
)}
{/* Filters panel */} {showFilters && (
{/* Sender filter */}
{/* Time range filter */}
)}
{/* Search history */} {!query && searchHistory.length > 0 && (
Recent searches:
{searchHistory.slice(0, 5).map((item, index) => ( ))}
)} {/* Global search results */} {scope === 'global' && query && (
{globalLoading && (
Searching memories...
)} {!globalLoading && globalResults.length === 0 && (
No memories found matching "{query}"
)} {!globalLoading && globalResults.length > 0 && (
{globalResults.length} memories found
{globalResults.map((result) => (
{result.memory.memory_type} | {result.memory.agent_id} {result.memory.importance > 5 && ( {'*'.repeat(Math.min(result.memory.importance - 4, 5))} )}
{highlightSearchMatches(result.memory.content, query)}
{result.memory.created_at.split('T')[0]}
))}
)}
)} {/* No results message (session search) */} {scope === 'session' && query && searchResults.length === 0 && (
No messages found matching "{query}"
)}
)}
); } // Utility function to highlight search matches in text export function highlightSearchMatches( text: string, query: string, highlightClassName: string = 'bg-yellow-200 dark:bg-yellow-700/50 rounded px-0.5' ): React.ReactNode[] { if (!query.trim()) return [text]; const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean); if (searchTerms.length === 0) return [text]; const lowerText = text.toLowerCase(); const matches: Array<{ start: number; end: number }> = []; // Find all matches for (const term of searchTerms) { let startIndex = 0; while (true) { const index = lowerText.indexOf(term, startIndex); if (index === -1) break; matches.push({ start: index, end: index + term.length }); startIndex = index + 1; } } if (matches.length === 0) return [text]; // Sort and merge overlapping matches matches.sort((a, b) => a.start - b.start); const merged: Array<{ start: number; end: number }> = []; for (const match of matches) { if (merged.length === 0 || merged[merged.length - 1].end < match.start) { merged.push({ ...match }); } else { merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, match.end); } } // Build highlighted result const result: React.ReactNode[] = []; let lastIndex = 0; for (let i = 0; i < merged.length; i++) { const match = merged[i]; // Text before match if (match.start > lastIndex) { result.push(text.slice(lastIndex, match.start)); } // Highlighted match result.push( {text.slice(match.start, match.end)} ); lastIndex = match.end; } // Remaining text if (lastIndex < text.length) { result.push(text.slice(lastIndex)); } return result; }