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;