diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index f93380d..888ab59 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -4,6 +4,7 @@ import { ChatInput } from './ChatInput'; import { TypingIndicator } from './TypingIndicator'; import type { ChatMessage, ConnectionStatus } from '../types'; import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown } from 'lucide-react'; +import { MessageSearch } from './MessageSearch'; import { useT } from '../hooks/useLocale'; import { getLocale, type TranslationKey } from '../lib/i18n'; import { useToolCollapse } from '../hooks/useToolCollapse'; @@ -131,8 +132,61 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session const { globalState, collapseAll, expandAll } = useToolCollapse(); const hasToolCalls = useMemo(() => messages.some(m => m.blocks.some(b => b.type === 'tool_use' || b.type === 'tool_result')), [messages]); + // Message search + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchActiveIndex, setSearchActiveIndex] = useState(0); + // Compute matches: list of message IDs containing the query + const searchMatches = useMemo(() => { + if (!searchQuery.trim()) return [] as string[]; + const q = searchQuery.toLowerCase(); + return visibleMessages + .filter(({ msg }) => { + const content = msg.content?.toLowerCase() || ''; + if (content.includes(q)) return true; + return msg.blocks.some(b => { + if (b.type === 'text' || b.type === 'thinking') return b.text.toLowerCase().includes(q); + if (b.type === 'tool_result') return b.content.toLowerCase().includes(q); + return false; + }); + }) + .map(({ msg }) => msg.id); + }, [visibleMessages, searchQuery]); + + const handleSearch = useCallback((query: string, activeIndex: number) => { + setSearchQuery(query); + setSearchActiveIndex(activeIndex); + }, []); + + // Scroll to active match + useEffect(() => { + if (searchMatches.length === 0) return; + const id = searchMatches[searchActiveIndex]; + if (!id) return; + const el = scrollContainerRef.current?.querySelector(`[data-msg-id="${id}"]`); + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, [searchActiveIndex, searchMatches]); + + const closeSearch = useCallback(() => { + setSearchOpen(false); + setSearchQuery(''); + }, []); + + // Ctrl+F handler + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + setSearchOpen(true); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + return (
+
{messages.length === 0 && isLoadingHistory && ( @@ -153,8 +207,10 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
{t('chat.welcomeSub')}
)} - {visibleMessages.map(({ msg, showSep }) => ( -
+ {visibleMessages.map(({ msg, showSep }) => { + const isActiveMatch = searchMatches.length > 0 && searchMatches[searchActiveIndex] === msg.id; + return ( +
{showSep && (
@@ -162,9 +218,12 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
)} - +
+ +
- ))} + ); + })} {showTyping && }
diff --git a/src/components/KeyboardShortcuts.tsx b/src/components/KeyboardShortcuts.tsx index f4a34a8..c01b637 100644 --- a/src/components/KeyboardShortcuts.tsx +++ b/src/components/KeyboardShortcuts.tsx @@ -90,6 +90,10 @@ export function KeyboardShortcuts({ open, onClose }: Props) { keys={Esc} label={t('shortcuts.stop')} /> + {mod}+F} + label={t('shortcuts.searchMessages')} + />
diff --git a/src/components/MessageSearch.tsx b/src/components/MessageSearch.tsx new file mode 100644 index 0000000..8b78ca6 --- /dev/null +++ b/src/components/MessageSearch.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'; +import { useT } from '../hooks/useLocale'; + +interface Props { + open: boolean; + onClose: () => void; + onSearch: (query: string, activeIndex: number) => void; + matchCount: number; +} + +export function MessageSearch({ open, onClose, onSearch, matchCount }: Props) { + const t = useT(); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + + useEffect(() => { + if (open) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [open]); + + useEffect(() => { + onSearch(query, activeIndex); + }, [query, activeIndex, onSearch]); + + // Reset active index when query changes + useEffect(() => { + setActiveIndex(0); + }, [query]); + + const navigate = useCallback((dir: 1 | -1) => { + if (matchCount === 0) return; + setActiveIndex(prev => { + const next = prev + dir; + if (next < 0) return matchCount - 1; + if (next >= matchCount) return 0; + return next; + }); + }, [matchCount]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === 'Enter') { + e.preventDefault(); + navigate(e.shiftKey ? -1 : 1); + } + }, [onClose, navigate]); + + if (!open) return null; + + return ( +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('search.placeholder')} + className="bg-transparent text-sm text-pc-text placeholder:text-pc-text-muted outline-none w-48" + aria-label={t('search.placeholder')} + /> + {query && ( + + {matchCount > 0 ? `${activeIndex + 1}/${matchCount}` : t('search.noResults')} + + )} + + + +
+ ); +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 1ee22f4..608a3c5 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -110,6 +110,13 @@ const en = { 'theme.dark': 'Dark', 'theme.light': 'Light', 'theme.oled': 'OLED', + + // Message search + 'search.placeholder': 'Search messages…', + 'search.noResults': '0 results', + 'search.prev': 'Previous match', + 'search.next': 'Next match', + 'shortcuts.searchMessages': 'Search messages', } as const; const fr: Record = { @@ -202,6 +209,12 @@ const fr: Record = { 'theme.dark': 'Sombre', 'theme.light': 'Clair', 'theme.oled': 'OLED', + + 'search.placeholder': 'Rechercher dans les messages…', + 'search.noResults': '0 résultat', + 'search.prev': 'Résultat précédent', + 'search.next': 'Résultat suivant', + 'shortcuts.searchMessages': 'Rechercher dans les messages', }; export type TranslationKey = keyof typeof en;