import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Search, MessageSquare, ArrowRight } from 'lucide-react'; import type { WorkspaceState } from '@shared/domain/workspace'; import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session'; import { isScratchpadProject } from '@shared/domain/project'; export interface SessionSearchPanelProps { workspace: WorkspaceState; onClose: () => void; onSelectSession: (sessionId: string) => void; } interface SearchHit { session: SessionRecord; projectName: string; message: ChatMessageRecord; /** The matching substring context around the first token hit. */ snippet: string; /** Character offset of the match within the snippet for highlighting. */ matchStart: number; matchLength: number; } function extractSnippet(content: string, query: string): { snippet: string; matchStart: number; matchLength: number } | undefined { const lower = content.toLowerCase(); const qLower = query.toLowerCase().trim(); if (!qLower) return undefined; // Find first occurrence of query in content const idx = lower.indexOf(qLower); if (idx === -1) { // Try individual tokens const tokens = qLower.split(/\s+/).filter(Boolean); for (const token of tokens) { const tidx = lower.indexOf(token); if (tidx !== -1) { const start = Math.max(0, tidx - 40); const end = Math.min(content.length, tidx + token.length + 80); const snippet = (start > 0 ? '…' : '') + content.slice(start, end).replace(/\n/g, ' ') + (end < content.length ? '…' : ''); const adjustedStart = (start > 0 ? 1 : 0) + (tidx - start); return { snippet, matchStart: adjustedStart, matchLength: token.length }; } } return undefined; } const start = Math.max(0, idx - 40); const end = Math.min(content.length, idx + qLower.length + 80); const snippet = (start > 0 ? '…' : '') + content.slice(start, end).replace(/\n/g, ' ') + (end < content.length ? '…' : ''); const adjustedStart = (start > 0 ? 1 : 0) + (idx - start); return { snippet, matchStart: adjustedStart, matchLength: qLower.length }; } export function SessionSearchPanel({ workspace, onClose, onSelectSession }: SessionSearchPanelProps) { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); // Escape to close in capture phase useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); onClose(); } }; document.addEventListener('keydown', handleEscape, true); return () => document.removeEventListener('keydown', handleEscape, true); }, [onClose]); // Build project name lookup const projectNames = useMemo(() => { const map = new Map(); for (const p of workspace.projects) { map.set(p.id, isScratchpadProject(p) ? 'Scratchpad' : p.name); } return map; }, [workspace.projects]); // Search across all sessions and messages const hits = useMemo(() => { const q = query.trim(); if (!q) return []; const results: SearchHit[] = []; const activeSessions = workspace.sessions.filter((s) => !s.isArchived); for (const session of activeSessions) { for (const message of session.messages) { if (!message.content) continue; const extracted = extractSnippet(message.content, q); if (extracted) { results.push({ session, projectName: projectNames.get(session.projectId) ?? 'Unknown', message, ...extracted, }); } } } // Limit results for performance and sort by session recency return results.slice(0, 50); }, [query, workspace.sessions, projectNames]); useEffect(() => { setSelectedIndex(0); }, [query]); const handleSelect = useCallback((hit: SearchHit) => { onClose(); onSelectSession(hit.session.id); // After navigation, scroll to the matching message requestAnimationFrame(() => { setTimeout(() => { const el = document.querySelector(`[data-message-id="${CSS.escape(hit.message.id)}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('ring-1', 'ring-[var(--color-accent)]/40', 'rounded-lg'); setTimeout(() => el.classList.remove('ring-1', 'ring-[var(--color-accent)]/40', 'rounded-lg'), 2000); } }, 100); }); }, [onClose, onSelectSession]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); if (hits.length > 0) setSelectedIndex((i) => Math.min(i + 1, hits.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); const hit = hits[selectedIndex]; if (hit) handleSelect(hit); } }, [hits, selectedIndex, handleSelect], ); useEffect(() => { const item = listRef.current?.querySelector(`[data-search-index="${selectedIndex}"]`); item?.scrollIntoView({ block: 'nearest' }); }, [selectedIndex]); return (
e.stopPropagation()} onKeyDown={handleKeyDown} > {/* Search input */}
setQuery(e.target.value)} aria-label="Search session content" autoComplete="off" spellCheck={false} /> {query && ( {hits.length} result{hits.length !== 1 ? 's' : ''} )}
{/* Results */}
{query && hits.length === 0 ? (
No matches found
) : !query ? (
Type to search across all session messages
) : ( hits.map((hit, index) => { const isSelected = index === selectedIndex; return ( ); }) )}
{/* Footer hints */}
↑↓ navigate jump to message esc close
); }