Skip to content
Merged
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
226 changes: 191 additions & 35 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
VolumeMuteIcon,
TitleBar,
EyeIcon,
SearchIcon,
MessageItem,
ChatInput,
type ChatInputHandle,
Expand Down Expand Up @@ -476,6 +477,15 @@ const App: React.FC = () => {
const isAtBottomRef = useRef<boolean>(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<HTMLInputElement>(null);

// Voice speech hook for STT/TTS
const voiceSpeech = useVoiceSpeech();
const { isRecording } = voiceSpeech;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -5232,41 +5325,47 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO
}
}

return filteredMessages.map((message, index) => (
<React.Fragment key={message.id}>
<MessageItem
message={message}
index={index}
lastAssistantIndex={lastAssistantIndex}
isVoiceSpeaking={voiceSpeech.isSpeaking}
activeTools={activeTab?.activeTools}
activeSubagents={activeTab?.activeSubagents}
onStopSpeaking={voiceSpeech.stopSpeaking}
onImageClick={handleImageClick}
/>
{/* Show timestamp for the last assistant message (only when not processing) */}
{index === lastAssistantIndex &&
message.timestamp &&
!activeTab?.isProcessing && (
<span className="text-[10px] text-copilot-text-muted mt-1 ml-1">
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</span>
)}
{/* Show choice selector for the last assistant message when choices are detected */}
{index === lastAssistantIndex &&
!activeTab?.isProcessing &&
activeTab?.detectedChoices &&
activeTab.detectedChoices.length > 0 && (
<ChoiceSelector
choices={activeTab.detectedChoices}
onSelect={handleChoiceSelect}
/>
)}
</React.Fragment>
));
return filteredMessages.map((message, index) => {
const currentMatch = findInChatMatches[findInChatCurrentMatch];
const isHighlighted = currentMatch?.messageId === message.id;

return (
<React.Fragment key={message.id}>
<MessageItem
message={message}
index={index}
lastAssistantIndex={lastAssistantIndex}
isVoiceSpeaking={voiceSpeech.isSpeaking}
activeTools={activeTab?.activeTools}
activeSubagents={activeTab?.activeSubagents}
onStopSpeaking={voiceSpeech.stopSpeaking}
onImageClick={handleImageClick}
isHighlighted={isHighlighted}
/>
{/* Show timestamp for the last assistant message (only when not processing) */}
{index === lastAssistantIndex &&
message.timestamp &&
!activeTab?.isProcessing && (
<span className="text-[10px] text-copilot-text-muted mt-1 ml-1">
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</span>
)}
{/* Show choice selector for the last assistant message when choices are detected */}
{index === lastAssistantIndex &&
!activeTab?.isProcessing &&
activeTab?.detectedChoices &&
activeTab.detectedChoices.length > 0 && (
<ChoiceSelector
choices={activeTab.detectedChoices}
onSelect={handleChoiceSelect}
/>
)}
</React.Fragment>
);
});
}, [
activeTab?.messages,
activeTab?.isProcessing,
Expand All @@ -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 */}
Expand Down Expand Up @@ -5592,6 +5693,61 @@ Only when ALL the above are verified complete, output exactly: ${RALPH_COMPLETIO
);
})()}

{/* Find in Chat Search Box */}
{findInChatVisible && (
<div className="shrink-0 px-3 py-2 bg-copilot-surface border-t border-copilot-border">
<div className="flex items-center gap-2">
<SearchIcon size={16} className="text-copilot-text-muted shrink-0" />
<input
ref={findInChatInputRef}
type="text"
value={findInChatQuery}
onChange={(e) => 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 && (
<span className="text-xs text-copilot-text-muted shrink-0">
{findInChatCurrentMatch + 1} of {findInChatMatches.length}
</span>
)}
<button
onClick={handleFindInChatPrevious}
disabled={findInChatMatches.length === 0}
className="p-1 text-copilot-text-muted hover:text-copilot-text disabled:opacity-30 disabled:cursor-not-allowed"
title="Previous match (Shift+Enter)"
>
<ChevronRightIcon size={16} className="rotate-[-90deg]" />
</button>
<button
onClick={handleFindInChatNext}
disabled={findInChatMatches.length === 0}
className="p-1 text-copilot-text-muted hover:text-copilot-text disabled:opacity-30 disabled:cursor-not-allowed"
title="Next match (Enter)"
>
<ChevronRightIcon size={16} className="rotate-90" />
</button>
<button
onClick={handleCloseFindInChat}
className="p-1 text-copilot-text-muted hover:text-copilot-text"
title="Close (Escape)"
>
<CloseIcon size={16} />
</button>
</div>
</div>
)}

{/* Input Area */}
<div className="shrink-0 p-3 bg-copilot-surface border-t border-copilot-border">
<ChatInput
Expand Down
14 changes: 10 additions & 4 deletions src/renderer/components/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface MessageItemProps {
activeSubagents?: any[];
onStopSpeaking: () => void;
onImageClick: (src: string, alt: string) => void;
isHighlighted?: boolean;
}

export const MessageItem = memo<MessageItemProps>(
Expand All @@ -30,17 +31,21 @@ export const MessageItem = memo<MessageItemProps>(
activeSubagents,
onStopSpeaking,
onImageClick,
isHighlighted = false,
}) => {
return (
<div className={`flex flex-col ${message.role === 'user' ? 'items-end' : 'items-start'}`}>
<div
id={`message-${message.id}`}
className={`flex flex-col ${message.role === 'user' ? 'items-end' : 'items-start'}`}
>
<div
className={`max-w-[85%] rounded-lg px-4 py-2.5 overflow-hidden relative ${
className={`max-w-[85%] rounded-lg px-4 py-2.5 overflow-hidden relative transition-all ${
message.role === 'user'
? message.isPendingInjection
? 'bg-copilot-warning text-white border border-dashed border-copilot-warning/50'
: 'bg-copilot-success text-copilot-text-inverse'
: 'bg-copilot-surface text-copilot-text'
}`}
} ${isHighlighted ? 'ring-2 ring-copilot-accent ring-offset-2 ring-offset-copilot-bg' : ''}`}
>
{/* Stop speaking overlay on last assistant message */}
{message.role === 'assistant' && index === lastAssistantIndex && isVoiceSpeaking && (
Expand Down Expand Up @@ -158,7 +163,8 @@ export const MessageItem = memo<MessageItemProps>(
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
);
}
);
Expand Down
Loading