feat: message search with Ctrl+F — filter and navigate matches in conversation

This commit is contained in:
Nicolas Varrot
2026-02-13 00:57:19 +00:00
parent 7bcbf8192b
commit 6c19c26b84
4 changed files with 178 additions and 4 deletions

View File

@@ -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 (
<div className="flex-1 flex flex-col min-h-0 relative">
<MessageSearch open={searchOpen} onClose={closeSearch} onSearch={handleSearch} matchCount={searchMatches.length} />
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative" role="log" aria-label={t('chat.messages')} aria-live="polite">
<div className="max-w-4xl mx-auto py-4 w-full">
{messages.length === 0 && isLoadingHistory && (
@@ -153,8 +207,10 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
<div className="text-sm mt-1 text-pc-text-muted">{t('chat.welcomeSub')}</div>
</div>
)}
{visibleMessages.map(({ msg, showSep }) => (
<div key={msg.id}>
{visibleMessages.map(({ msg, showSep }) => {
const isActiveMatch = searchMatches.length > 0 && searchMatches[searchActiveIndex] === msg.id;
return (
<div key={msg.id} data-msg-id={msg.id}>
{showSep && (
<div className="flex items-center gap-3 py-3 px-4 select-none" aria-label={formatDateSeparator(msg.timestamp, t)}>
<div className="flex-1 h-px bg-[var(--pc-hover-strong)]" />
@@ -162,9 +218,12 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
<div className="flex-1 h-px bg-[var(--pc-hover-strong)]" />
</div>
)}
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} />
<div className={isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''}>
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} />
</div>
</div>
))}
);
})}
{showTyping && <TypingIndicator />}
<div ref={bottomRef} />
</div>

View File

@@ -90,6 +90,10 @@ export function KeyboardShortcuts({ open, onClose }: Props) {
keys={<Kbd>Esc</Kbd>}
label={t('shortcuts.stop')}
/>
<ShortcutRow
keys={<><Kbd>{mod}</Kbd><span className="text-pc-text-faint">+</span><Kbd>F</Kbd></>}
label={t('shortcuts.searchMessages')}
/>
</div>
<div className="py-3">

View File

@@ -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<HTMLInputElement>(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 (
<div className="absolute top-2 right-4 z-20 flex items-center gap-1.5 rounded-xl border border-pc-border-strong bg-pc-elevated/95 backdrop-blur-lg px-3 py-1.5 shadow-lg">
<Search size={14} className="text-pc-text-muted shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={e => 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 && (
<span className="text-xs text-pc-text-muted whitespace-nowrap">
{matchCount > 0 ? `${activeIndex + 1}/${matchCount}` : t('search.noResults')}
</span>
)}
<button
onClick={() => navigate(-1)}
disabled={matchCount === 0}
className="p-1 rounded-lg text-pc-text-muted hover:text-pc-text hover:bg-[var(--pc-hover)] disabled:opacity-30 transition-colors"
aria-label={t('search.prev')}
>
<ChevronUp size={14} />
</button>
<button
onClick={() => navigate(1)}
disabled={matchCount === 0}
className="p-1 rounded-lg text-pc-text-muted hover:text-pc-text hover:bg-[var(--pc-hover)] disabled:opacity-30 transition-colors"
aria-label={t('search.next')}
>
<ChevronDown size={14} />
</button>
<button
onClick={onClose}
className="p-1 rounded-lg text-pc-text-muted hover:text-pc-text hover:bg-[var(--pc-hover)] transition-colors"
aria-label={t('shortcuts.close')}
>
<X size={14} />
</button>
</div>
);
}

View File

@@ -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<keyof typeof en, string> = {
@@ -202,6 +209,12 @@ const fr: Record<keyof typeof en, string> = {
'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;