feat: message search with Ctrl+F — filter and navigate matches in conversation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
98
src/components/MessageSearch.tsx
Normal file
98
src/components/MessageSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user