From bba7974d7df97a1b13ade1f8422812ec3ae5ec58 Mon Sep 17 00:00:00 2001 From: Ido Frizler Date: Thu, 19 Feb 2026 12:00:50 +0200 Subject: [PATCH] feat(chat): add find-in-chat with keyboard shortcut and message highlighting --- src/renderer/App.tsx | 226 ++++++++++++++++++++---- src/renderer/components/MessageItem.tsx | 14 +- tests/e2e/find-in-chat.spec.ts | 81 +++++++++ 3 files changed, 282 insertions(+), 39 deletions(-) create mode 100644 tests/e2e/find-in-chat.spec.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 932ee93..c440052 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -55,6 +55,7 @@ import { VolumeMuteIcon, TitleBar, EyeIcon, + SearchIcon, MessageItem, ChatInput, type ChatInputHandle, @@ -476,6 +477,15 @@ const App: React.FC = () => { const isAtBottomRef = useRef(true); const [showScrollToBottom, setShowScrollToBottom] = useState(false); + // Find in chat state + const [findInChatVisible, setFindInChatVisible] = useState(false); + const [findInChatQuery, setFindInChatQuery] = useState(''); + const [findInChatMatches, setFindInChatMatches] = useState< + { messageId: string; index: number }[] + >([]); + const [findInChatCurrentMatch, setFindInChatCurrentMatch] = useState(0); + const findInChatInputRef = useRef(null); + // Voice speech hook for STT/TTS const voiceSpeech = useVoiceSpeech(); const { isRecording } = voiceSpeech; @@ -515,6 +525,28 @@ const App: React.FC = () => { return () => window.removeEventListener('keydown', handler); }, []); + // Find in chat keyboard shortcut (Ctrl/Cmd+F) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { + e.preventDefault(); + setFindInChatVisible(true); + // Focus input on next tick + setTimeout(() => findInChatInputRef.current?.focus(), 0); + } + // Escape to close search + if (e.key === 'Escape' && findInChatVisible) { + e.preventDefault(); + setFindInChatVisible(false); + setFindInChatQuery(''); + setFindInChatMatches([]); + setFindInChatCurrentMatch(0); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [findInChatVisible]); + useEffect(() => { if (!window.electronAPI?.window?.onZoomChanged) return; return window.electronAPI.window.onZoomChanged((data) => { @@ -2792,6 +2824,47 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO handleSendMessageRef.current = handleSendMessage; }, [handleSendMessage]); + // Find in chat: Update matches when query or messages change + useEffect(() => { + if (!findInChatQuery.trim() || !activeTab) { + setFindInChatMatches([]); + setFindInChatCurrentMatch(0); + return; + } + + const query = findInChatQuery.toLowerCase(); + const matches: { messageId: string; index: number }[] = []; + + // Filter messages (same filter as display) + const filteredMessages = activeTab.messages + .filter((m) => m.role !== 'system') + .filter((m) => m.role === 'user' || m.content.trim()); + + for (let index = filteredMessages.length - 1; index >= 0; index--) { + const msg = filteredMessages[index]; + if (msg.content.toLowerCase().includes(query)) { + matches.push({ messageId: msg.id, index }); + } + } + + setFindInChatMatches(matches); + setFindInChatCurrentMatch(matches.length > 0 ? 0 : -1); + }, [findInChatQuery, activeTab?.messages, activeTab?.id]); + + // Find in chat: Scroll to current match + useEffect(() => { + if (findInChatMatches.length === 0 || findInChatCurrentMatch < 0) return; + + const currentMatch = findInChatMatches[findInChatCurrentMatch]; + if (!currentMatch) return; + + // Find the message element and scroll to it + const messageElement = document.getElementById(`message-${currentMatch.messageId}`); + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [findInChatCurrentMatch, findInChatMatches]); + // Handle sending terminal output to the agent const handleSendTerminalOutput = useCallback( (output: string, lineCount: number, lastCommandStart?: number) => { @@ -2836,6 +2909,26 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO chatInputRef.current?.focus(); }, []); + // Find in chat handlers + const handleFindInChatNext = useCallback(() => { + if (findInChatMatches.length === 0) return; + setFindInChatCurrentMatch((prev) => (prev + 1) % findInChatMatches.length); + }, [findInChatMatches.length]); + + const handleFindInChatPrevious = useCallback(() => { + if (findInChatMatches.length === 0) return; + setFindInChatCurrentMatch( + (prev) => (prev - 1 + findInChatMatches.length) % findInChatMatches.length + ); + }, [findInChatMatches.length]); + + const handleCloseFindInChat = useCallback(() => { + setFindInChatVisible(false); + setFindInChatQuery(''); + setFindInChatMatches([]); + setFindInChatCurrentMatch(0); + }, []); + // Cancel a specific pending injection by index, or all if index not provided // Get model capabilities (with caching) const getModelCapabilitiesForModel = useCallback( @@ -5232,41 +5325,47 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO } } - return filteredMessages.map((message, index) => ( - - - {/* Show timestamp for the last assistant message (only when not processing) */} - {index === lastAssistantIndex && - message.timestamp && - !activeTab?.isProcessing && ( - - {new Date(message.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - )} - {/* Show choice selector for the last assistant message when choices are detected */} - {index === lastAssistantIndex && - !activeTab?.isProcessing && - activeTab?.detectedChoices && - activeTab.detectedChoices.length > 0 && ( - - )} - - )); + return filteredMessages.map((message, index) => { + const currentMatch = findInChatMatches[findInChatCurrentMatch]; + const isHighlighted = currentMatch?.messageId === message.id; + + return ( + + + {/* Show timestamp for the last assistant message (only when not processing) */} + {index === lastAssistantIndex && + message.timestamp && + !activeTab?.isProcessing && ( + + {new Date(message.timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + )} + {/* Show choice selector for the last assistant message when choices are detected */} + {index === lastAssistantIndex && + !activeTab?.isProcessing && + activeTab?.detectedChoices && + activeTab.detectedChoices.length > 0 && ( + + )} + + ); + }); }, [ activeTab?.messages, activeTab?.isProcessing, @@ -5277,6 +5376,8 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO voiceSpeech.stopSpeaking, handleImageClick, handleChoiceSelect, + findInChatMatches, + findInChatCurrentMatch, ])} {/* Thinking indicator when processing but no streaming content yet */} @@ -5592,6 +5693,61 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO ); })()} + {/* Find in Chat Search Box */} + {findInChatVisible && ( +
+
+ + setFindInChatQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (e.shiftKey) { + handleFindInChatPrevious(); + } else { + handleFindInChatNext(); + } + } + }} + placeholder="Find in chat..." + className="flex-1 px-2 py-1 text-sm bg-copilot-bg text-copilot-text border border-copilot-border rounded focus:outline-none focus:ring-1 focus:ring-copilot-accent" + /> + {findInChatMatches.length > 0 && ( + + {findInChatCurrentMatch + 1} of {findInChatMatches.length} + + )} + + + +
+
+ )} + {/* Input Area */}
void; onImageClick: (src: string, alt: string) => void; + isHighlighted?: boolean; } export const MessageItem = memo( @@ -30,17 +31,21 @@ export const MessageItem = memo( activeSubagents, onStopSpeaking, onImageClick, + isHighlighted = false, }) => { return ( -
+
{/* Stop speaking overlay on last assistant message */} {message.role === 'assistant' && index === lastAssistantIndex && isVoiceSpeaking && ( @@ -158,7 +163,8 @@ export const MessageItem = memo( prevProps.message.tools === nextProps.message.tools && prevProps.message.subagents === nextProps.message.subagents && prevProps.message.imageAttachments === nextProps.message.imageAttachments && - prevProps.message.fileAttachments === nextProps.message.fileAttachments + prevProps.message.fileAttachments === nextProps.message.fileAttachments && + prevProps.isHighlighted === nextProps.isHighlighted ); } ); diff --git a/tests/e2e/find-in-chat.spec.ts b/tests/e2e/find-in-chat.spec.ts new file mode 100644 index 0000000..f6b9f07 --- /dev/null +++ b/tests/e2e/find-in-chat.spec.ts @@ -0,0 +1,81 @@ +import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import path from 'path'; + +let electronApp: ElectronApplication; +let window: Page; + +test.beforeAll(async () => { + electronApp = await electron.launch({ + args: [path.join(__dirname, '../../out/main/index.js')], + env: { + ...process.env, + NODE_ENV: 'test', + }, + }); + + window = await electronApp.firstWindow(); + await window.setViewportSize({ width: 1280, height: 800 }); + await window.waitForLoadState('domcontentloaded'); + + await window.evaluate(() => { + (window as any).__ENABLE_TEST_HELPERS__ = true; + }); + await window.waitForFunction(() => typeof (window as any).__TEST_HELPERS__ !== 'undefined'); +}); + +test.afterAll(async () => { + await electronApp?.close(); +}); + +const getHighlightedMessageId = async (window: Page) => + window.evaluate(() => { + const highlighted = document.querySelector('div.ring-2.ring-copilot-accent'); + const container = highlighted?.closest('[id^="message-"]') as HTMLElement | null; + return container?.id ?? null; + }); + +test('find in chat navigates newest to oldest', async () => { + const injected = await window.evaluate(() => { + const helpers = (window as any).__TEST_HELPERS__; + if (!helpers?.injectMessages) return false; + + const baseTime = Date.now() - 60000; + helpers.injectMessages([ + { + id: 'find-oldest', + role: 'assistant', + content: 'needle in oldest message', + timestamp: baseTime, + }, + { + id: 'find-middle', + role: 'assistant', + content: 'needle in middle message', + timestamp: baseTime + 1000, + }, + { + id: 'find-newest', + role: 'assistant', + content: 'needle in newest message', + timestamp: baseTime + 2000, + }, + ]); + return true; + }); + + expect(injected).toBe(true); + await window.waitForTimeout(500); + + await window.keyboard.press(process.platform === 'darwin' ? 'Meta+F' : 'Control+F'); + const findInput = window.locator('input[placeholder="Find in chat..."]'); + await expect(findInput).toBeVisible(); + await findInput.fill('needle'); + + await expect.poll(() => getHighlightedMessageId(window)).toBe('message-find-newest'); + + await findInput.press('Enter'); + await expect.poll(() => getHighlightedMessageId(window)).toBe('message-find-middle'); + + await findInput.press('Shift+Enter'); + await expect.poll(() => getHighlightedMessageId(window)).toBe('message-find-newest'); +});