From d3823cd1d3e5800edda353f89b3dbb1b695ffed0 Mon Sep 17 00:00:00 2001 From: Hank Zhang Date: Wed, 17 Jun 2026 12:07:31 +0800 Subject: [PATCH] feat: add in-session search with Ctrl+F / Cmd+F Add a search bar that appears on Ctrl/Cmd+F to search within the current conversation. Matches are highlighted inline in user messages (agent markdown is excluded) with navigation between matches. Changes: - ChatSearchBar: new component with input, prev/next arrows, match count (N/M), close button, Escape to dismiss - Chat: add search state, Ctrl+F keyboard handler, match computation via useMemo, searchQuery passed to MessageList - MessageList: forward searchQuery to MessageRow - MessageRow: highlightText helper wraps matches in tags - main.css: search bar, nav buttons, highlight styling - i18n: 5 new keys across all 10 locales --- src/renderer/src/assets/main.css | 81 +++++++++++++++++ src/renderer/src/screens/Chat/Chat.tsx | 65 +++++++++++++- .../src/screens/Chat/ChatSearchBar.tsx | 89 +++++++++++++++++++ src/renderer/src/screens/Chat/MessageList.tsx | 5 +- src/renderer/src/screens/Chat/MessageRow.tsx | 59 ++++++------ src/shared/i18n/locales/en/chat.ts | 5 ++ src/shared/i18n/locales/es/chat.ts | 5 ++ src/shared/i18n/locales/id/chat.ts | 5 ++ src/shared/i18n/locales/ja/chat.ts | 5 ++ src/shared/i18n/locales/pl/chat.ts | 5 ++ src/shared/i18n/locales/pt-BR/chat.ts | 5 ++ src/shared/i18n/locales/pt-PT/chat.ts | 5 ++ src/shared/i18n/locales/tr/chat.ts | 5 ++ src/shared/i18n/locales/zh-CN/chat.ts | 5 ++ src/shared/i18n/locales/zh-TW/chat.ts | 5 ++ 15 files changed, 314 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/screens/Chat/ChatSearchBar.tsx diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index a1585ad6a..a8366794c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -10738,3 +10738,84 @@ body { font-size: 11px; color: var(--text-muted); } + +/* Session search bar (Ctrl+F) */ +.chat-search-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.chat-search-icon { + color: var(--text-muted); + flex-shrink: 0; +} + +.chat-search-input { + flex: 1; + min-width: 0; + padding: 4px 0; + background: none; + border: none; + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-sans); + outline: none; +} + +.chat-search-count { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +.chat-search-nav { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; +} + +.chat-search-nav:hover:not(:disabled) { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.chat-search-nav:disabled { + opacity: 0.3; + cursor: default; +} + +.chat-search-close { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; +} + +.chat-search-close:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +/* Search highlight in messages */ +.chat-search-highlight { + background: var(--accent); + color: var(--text-primary); + border-radius: 2px; + padding: 0 1px; +} diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index eed0a00b4..c8445ed4f 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Zap } from "lucide-react"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; import { ChatEmptyState } from "./ChatEmptyState"; @@ -26,6 +26,7 @@ import type { ActiveTurn, ChatMessage, UsageState } from "./types"; import type { ContextUsage } from "./ContextGauge"; import { contextWindowForModel } from "./contextWindows"; import { QueuedMessages } from "./QueuedMessages"; +import { ChatSearchBar } from "./ChatSearchBar"; interface QueuedMessage { text: string; @@ -121,6 +122,10 @@ function Chat({ const chatInputRef = useRef(null); const queueRef = useRef([]); const [queuedMessages, setQueuedMessages] = useState([]); + // Session search (Ctrl+F / Cmd+F) + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchMatchIndex, setSearchMatchIndex] = useState(0); const activeTurnRef = useRef(null); const dashboardChatEnabled = dashboardChatEnabledForConnection( import.meta.env.VITE_HERMES_DESKTOP_DASHBOARD_CHAT, @@ -279,6 +284,11 @@ function Chat({ e.preventDefault(); onNewChat?.(); } + // Ctrl/Cmd+F — open in-session search + if ((e.metaKey || e.ctrlKey) && e.key === "f") { + e.preventDefault(); + setSearchOpen(true); + } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); @@ -468,6 +478,47 @@ function Chat({ chatInputRef.current?.setText(text); }, []); + // ---- Session search (Ctrl+F) ---- + // Compute match positions across all message content + const searchMatches = useMemo(() => { + if (!searchQuery) return []; + const q = searchQuery.toLowerCase(); + const matches: { msgId: string; index: number }[] = []; + let idx = 0; + for (const m of messages) { + const content = + "content" in m ? ((m as { content?: string }).content || "") : ""; + let pos = 0; + while ((pos = content.toLowerCase().indexOf(q, pos)) !== -1) { + matches.push({ msgId: m.id, index: idx++ }); + pos += q.length; + } + } + return matches; + }, [messages, searchQuery]); + + const totalMatches = searchMatches.length; + const safeMatchIndex = Math.min(searchMatchIndex, Math.max(0, totalMatches - 1)); + + const handleSearchClose = useCallback(() => { + setSearchOpen(false); + setSearchQuery(""); + setSearchMatchIndex(0); + }, []); + + const handleSearchPrev = useCallback(() => { + setSearchMatchIndex((i) => (i > 0 ? i - 1 : totalMatches - 1)); + }, [totalMatches]); + + const handleSearchNext = useCallback(() => { + setSearchMatchIndex((i) => (i < totalMatches - 1 ? i + 1 : 0)); + }, [totalMatches]); + + const handleSearchQueryChange = useCallback((q: string) => { + setSearchQuery(q); + setSearchMatchIndex(0); + }, []); + const handlePickFolder = useCallback(async () => { const path = await window.hermesAPI.selectFolder(); if (path) setContextFolder(path); @@ -548,6 +599,17 @@ function Chat({
+ {searchOpen && ( + + )}
{messages.length === 0 ? ( @@ -559,6 +621,7 @@ function Chat({ onApprove={actions.handleApprove} onDeny={actions.handleDeny} onClarifyResolved={handleClarifyResolved} + searchQuery={searchOpen ? searchQuery : ""} /> )}
diff --git a/src/renderer/src/screens/Chat/ChatSearchBar.tsx b/src/renderer/src/screens/Chat/ChatSearchBar.tsx new file mode 100644 index 000000000..a448d14c2 --- /dev/null +++ b/src/renderer/src/screens/Chat/ChatSearchBar.tsx @@ -0,0 +1,89 @@ +import { memo, useEffect, useRef } from "react"; +import { Search, X, ChevronUp, ChevronDown } from "lucide-react"; +import { useI18n } from "../../components/useI18n"; + +interface ChatSearchBarProps { + query: string; + onQueryChange: (query: string) => void; + onClose: () => void; + matchIndex: number; + totalMatches: number; + onPrev: () => void; + onNext: () => void; +} + +export const ChatSearchBar = memo(function ChatSearchBar({ + query, + onQueryChange, + onClose, + matchIndex, + totalMatches, + onPrev, + onNext, +}: ChatSearchBarProps): React.JSX.Element { + const { t } = useI18n(); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + function handleKeyDown(e: React.KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) onPrev(); + else onNext(); + } + } + + return ( +
+ + onQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + /> + {query && ( + + {totalMatches > 0 ? `${matchIndex + 1}/${totalMatches}` : t("chat.searchNoMatch")} + + )} + + + +
+ ); +}); diff --git a/src/renderer/src/screens/Chat/MessageList.tsx b/src/renderer/src/screens/Chat/MessageList.tsx index da4e15443..6b58a6c69 100644 --- a/src/renderer/src/screens/Chat/MessageList.tsx +++ b/src/renderer/src/screens/Chat/MessageList.tsx @@ -20,8 +20,9 @@ interface MessageListProps { toolProgress: string | null; onApprove: () => void; onDeny: () => void; - /** Mark an inline clarify card resolved once the user answers/skips. */ onClarifyResolved: (requestId: string, answer: string) => void; + /** Current search query for in-session search highlighting. */ + searchQuery?: string; } function TypingIndicator({ @@ -66,6 +67,7 @@ export const MessageList = memo(function MessageList({ onApprove, onDeny, onClarifyResolved, + searchQuery, }: MessageListProps): React.JSX.Element { // Bubbles with empty content are still hidden (live-stream placeholders). // History rows pass through unconditionally. @@ -154,6 +156,7 @@ export const MessageList = memo(function MessageList({ onApprove={onApprove} onDeny={onDeny} showAvatar={showAvatar} + searchQuery={searchQuery} />, ); } diff --git a/src/renderer/src/screens/Chat/MessageRow.tsx b/src/renderer/src/screens/Chat/MessageRow.tsx index 45520aa00..77098aedf 100644 --- a/src/renderer/src/screens/Chat/MessageRow.tsx +++ b/src/renderer/src/screens/Chat/MessageRow.tsx @@ -18,6 +18,22 @@ function isChatBubbleMessage(msg: ChatMessage): msg is ChatBubbleMessage { ); } +// Highlight search matches in text content +function highlightText(text: string, query: string): React.ReactNode { + if (!query) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = text.split(new RegExp(`(${escaped})`, "gi")); + return parts.map((part, i) => + part.toLowerCase() === query.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ); +} + export const HermesAvatar = memo(function HermesAvatar({ size = 30, }: { @@ -30,12 +46,6 @@ export const HermesAvatar = memo(function HermesAvatar({ ); }); -/** - * Empty box the size of an avatar. Rendered in place of the avatar on - * continuation rows of a turn (the thinking/tool rows and answer bubble that - * follow the first row) so one turn shows a single avatar while every row - * stays aligned to the same content column. - */ export const AvatarSpacer = memo(function AvatarSpacer(): React.JSX.Element { return