Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
65 changes: 64 additions & 1 deletion src/renderer/src/screens/Chat/Chat.tsx
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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Comment on lines +483 to +498

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Match counter includes agent messages that are never highlighted

searchMatches iterates over every message (including role === "agent") and counts all query occurrences. However, in MessageRow, agent messages with content always take the segments.map(...) branch (which renders through AgentMarkdown) — highlightText is 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.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Navigation changes the counter only — no scroll-to-match behavior

safeMatchIndex is passed to ChatSearchBar purely for the N/total display string. The searchMatches array carries { msgId, index } per occurrence (clearly intended to power scrolling) but it is never read back after construction — searchMatches[safeMatchIndex] is never accessed anywhere. Clicking Prev/Next or pressing Enter/Shift+Enter therefore only increments a number; the viewport does not move and the "active" match is not visually distinguished from other matches. A user with 20 matches across a long conversation has no way to locate the currently-indicated match.


const handleSearchQueryChange = useCallback((q: string) => {
setSearchQuery(q);
setSearchMatchIndex(0);
}, []);

const handlePickFolder = useCallback(async () => {
const path = await window.hermesAPI.selectFolder();
if (path) setContextFolder(path);
Expand Down Expand Up @@ -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} />
Expand All @@ -559,6 +621,7 @@ function Chat({
onApprove={actions.handleApprove}
onDeny={actions.handleDeny}
onClarifyResolved={handleClarifyResolved}
searchQuery={searchOpen ? searchQuery : ""}
/>
)}
<div ref={bottomRef} />
Expand Down
89 changes: 89 additions & 0 deletions src/renderer/src/screens/Chat/ChatSearchBar.tsx
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When there are no matches and the user presses Shift+Enter, onPrev() is called (keyboard handlers don't check totalMatches, unlike the nav buttons). handleSearchPrev then sets searchMatchIndex to totalMatches - 1 = -1. If a new message arrives while the search bar is still open, totalMatches becomes non-zero but safeMatchIndex = Math.min(-1, …) = -1, causing the counter to display 0/N instead of 1/N. Guard the keyboard path the same way the buttons are guarded.

Suggested change
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) onPrev();
else onNext();
}
if (e.key === "Enter" && totalMatches > 0) {
e.preventDefault();
if (e.shiftKey) onPrev();
else onNext();
}

}

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>
);
});
5 changes: 4 additions & 1 deletion src/renderer/src/screens/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -154,6 +156,7 @@ export const MessageList = memo(function MessageList({
onApprove={onApprove}
onDeny={onDeny}
showAvatar={showAvatar}
searchQuery={searchQuery}
/>,
);
}
Expand Down
Loading
Loading