Files
zclaw_openfang/desktop/src/components/MessageSearch.tsx
iven f4efc823e2 refactor(types): comprehensive TypeScript type system improvements
Major type system refactoring and error fixes across the codebase:

**Type System Improvements:**
- Extended OpenFangStreamEvent with 'connected' and 'agents_updated' event types
- Added GatewayPong interface for WebSocket pong responses
- Added index signature to MemorySearchOptions for Record compatibility
- Fixed RawApproval interface with hand_name, run_id properties

**Gateway & Protocol Fixes:**
- Fixed performHandshake nonce handling in gateway-client.ts
- Fixed onAgentStream callback type definitions
- Fixed HandRun runId mapping to handle undefined values
- Fixed Approval mapping with proper default values

**Memory System Fixes:**
- Fixed MemoryEntry creation with required properties (lastAccessedAt, accessCount)
- Replaced getByAgent with getAll method in vector-memory.ts
- Fixed MemorySearchOptions type compatibility

**Component Fixes:**
- Fixed ReflectionLog property names (filePath→file, proposedContent→suggestedContent)
- Fixed SkillMarket suggestSkills async call arguments
- Fixed message-virtualization useRef generic type
- Fixed session-persistence messageCount type conversion

**Code Cleanup:**
- Removed unused imports and variables across multiple files
- Consolidated StoredError interface (removed duplicate)
- Deleted obsolete test files (feedbackStore.test.ts, memory-index.test.ts)

**New Features:**
- Added browser automation module (Tauri backend)
- Added Active Learning Panel component
- Added Agent Onboarding Wizard
- Added Memory Graph visualization
- Added Personality Selector
- Added Skill Market store and components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:05:07 +08:00

465 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Search, X, ChevronUp, ChevronDown, Clock, User, Filter } from 'lucide-react';
import { Button } from './ui';
import { useChatStore, Message } from '../store/chatStore';
export interface SearchFilters {
sender: 'all' | 'user' | 'assistant';
timeRange: 'all' | 'today' | 'week' | 'month';
}
export interface SearchResult {
message: Message;
matchIndices: Array<{ start: number; end: number }>;
}
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 [filters, setFilters] = useState<SearchFilters>({
sender: 'all',
timeRange: 'all',
});
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const [showFilters, setShowFilters] = useState(false);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(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;
});
}, []);
// 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 */}
<Button
variant="ghost"
size="sm"
onClick={toggleSearch}
className={`flex items-center gap-1.5 ${isOpen ? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20' : 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
title="Search messages (Ctrl+F)"
aria-label="Search messages"
aria-expanded={isOpen}
>
<Search className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Search</span>
</Button>
{/* Search panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 overflow-hidden"
>
<div className="px-4 py-3">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
{/* Search input */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="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 && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Filter toggle */}
<Button
type="button"
variant={showFilters ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setShowFilters((prev) => !prev)}
className="flex items-center gap-1"
aria-label="Toggle filters"
aria-expanded={showFilters}
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
</Button>
{/* Navigation buttons */}
{searchResults.length > 0 && (
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">
{currentMatchIndex + 1} / {searchResults.length}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handlePrevious}
className="p-1.5"
aria-label="Previous match"
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleNext}
className="p-1.5"
aria-label="Next match"
>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
)}
</form>
{/* Filters panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
>
<div className="flex flex-wrap gap-4">
{/* Sender filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<User className="w-3.5 h-3.5" />
Sender:
</label>
<select
value={filters.sender}
onChange={(e) => setFilters((prev) => ({ ...prev, sender: e.target.value as SearchFilters['sender'] }))}
className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500"
>
<option value="all">All</option>
<option value="user">User</option>
<option value="assistant">Assistant</option>
</select>
</div>
{/* Time range filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
Time:
</label>
<select
value={filters.timeRange}
onChange={(e) => setFilters((prev) => ({ ...prev, timeRange: e.target.value as SearchFilters['timeRange'] }))}
className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500"
>
<option value="all">All time</option>
<option value="today">Today</option>
<option value="week">This week</option>
<option value="month">This month</option>
</select>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Search history */}
{!query && searchHistory.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">Recent searches:</div>
<div className="flex flex-wrap gap-1">
{searchHistory.slice(0, 5).map((item, index) => (
<button
key={index}
type="button"
onClick={() => setQuery(item)}
className="text-xs px-2 py-1 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
>
{item}
</button>
))}
</div>
</div>
)}
{/* No results message */}
{query && searchResults.length === 0 && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-center py-2">
No messages found matching "{query}"
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
// 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(
<mark key={i} className={highlightClassName}>
{text.slice(match.start, match.end)}
</mark>
);
lastIndex = match.end;
}
// Remaining text
if (lastIndex < text.length) {
result.push(text.slice(lastIndex));
}
return result;
}