-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: add in-session search (Ctrl+F) #705
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ChatInputHandle>(null); | ||
| const queueRef = useRef<QueuedMessage[]>([]); | ||
| const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]); | ||
| // Session search (Ctrl+F / Cmd+F) | ||
| const [searchOpen, setSearchOpen] = useState(false); | ||
| const [searchQuery, setSearchQuery] = useState(""); | ||
| const [searchMatchIndex, setSearchMatchIndex] = useState(0); | ||
| const activeTurnRef = useRef<ActiveTurn | null>(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]); | ||
|
Comment on lines
+500
to
+515
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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({ | |
| <ConfigHealthBanner profile={profile} onOpenDiagnose={onOpenDiagnose} /> | ||
|
|
||
| <div className="chat-body"> | ||
| {searchOpen && ( | ||
| <ChatSearchBar | ||
| query={searchQuery} | ||
| onQueryChange={handleSearchQueryChange} | ||
| onClose={handleSearchClose} | ||
| matchIndex={safeMatchIndex} | ||
| totalMatches={totalMatches} | ||
| onPrev={handleSearchPrev} | ||
| onNext={handleSearchNext} | ||
| /> | ||
| )} | ||
| <div className="chat-messages" ref={containerRef}> | ||
| {messages.length === 0 ? ( | ||
| <ChatEmptyState onSelectSuggestion={handleSuggestion} /> | ||
|
|
@@ -559,6 +621,7 @@ function Chat({ | |
| onApprove={actions.handleApprove} | ||
| onDeny={actions.handleDeny} | ||
| onClarifyResolved={handleClarifyResolved} | ||
| searchQuery={searchOpen ? searchQuery : ""} | ||
| /> | ||
| )} | ||
| <div ref={bottomRef} /> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<HTMLInputElement>(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(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+37
to
+41
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <div className="chat-search-bar"> | ||||||||||||||||||||||
| <Search size={14} className="chat-search-icon" /> | ||||||||||||||||||||||
| <input | ||||||||||||||||||||||
| ref={inputRef} | ||||||||||||||||||||||
| type="text" | ||||||||||||||||||||||
| className="chat-search-input" | ||||||||||||||||||||||
| placeholder={t("chat.searchPlaceholder")} | ||||||||||||||||||||||
| value={query} | ||||||||||||||||||||||
| onChange={(e) => onQueryChange(e.target.value)} | ||||||||||||||||||||||
| onKeyDown={handleKeyDown} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| {query && ( | ||||||||||||||||||||||
| <span className="chat-search-count"> | ||||||||||||||||||||||
| {totalMatches > 0 ? `${matchIndex + 1}/${totalMatches}` : t("chat.searchNoMatch")} | ||||||||||||||||||||||
| </span> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| className="chat-search-nav" | ||||||||||||||||||||||
| onClick={onPrev} | ||||||||||||||||||||||
| disabled={totalMatches === 0} | ||||||||||||||||||||||
| title={t("chat.searchPrev")} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <ChevronUp size={14} /> | ||||||||||||||||||||||
| </button> | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| className="chat-search-nav" | ||||||||||||||||||||||
| onClick={onNext} | ||||||||||||||||||||||
| disabled={totalMatches === 0} | ||||||||||||||||||||||
| title={t("chat.searchNext")} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <ChevronDown size={14} /> | ||||||||||||||||||||||
| </button> | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| className="chat-search-close" | ||||||||||||||||||||||
| onClick={onClose} | ||||||||||||||||||||||
| title={t("chat.searchClose")} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <X size={14} /> | ||||||||||||||||||||||
| </button> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
searchMatchesiterates over every message (includingrole === "agent") and counts all query occurrences. However, inMessageRow, agent messages with content always take thesegments.map(...)branch (which renders throughAgentMarkdown) —highlightTextis only reached for user messages. The result is a counter that claims e.g. "8 matches" while only 3 are actually visible as highlighted spans in the UI, making the counter systematically wrong in any conversation where the agent echoes or discusses the searched term.