diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index e43762a074b3..d784d5f762b3 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -313,11 +313,15 @@ function BaseChatContent({ }; // Callback to handle scroll to bottom from ProgressiveMessageList const handleScrollToBottom = useCallback(() => { - setTimeout(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - }, 100); + // Only auto-scroll if user is not actively typing + const isUserTyping = document.activeElement?.id === 'dynamic-textarea'; + if (!isUserTyping) { + setTimeout(() => { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + }, 100); + } }, []); return ( diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 92b832d0bbed..e97767478f62 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -239,6 +239,7 @@ export default function ChatInput({ const [isInGlobalHistory, setIsInGlobalHistory] = useState(false); const [hasUserTyped, setHasUserTyped] = useState(false); const textAreaRef = useRef(null); + const timeoutRefsRef = useRef>>(new Set()); // Use shared file drop hook for ChatInput const { @@ -441,25 +442,50 @@ export default function ChatInput({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]); + // Cleanup effect for component unmount - prevent memory leaks + useEffect(() => { + return () => { + // Clear any pending timeouts from image processing + setPastedImages((currentImages) => { + currentImages.forEach((img) => { + if (img.filePath) { + try { + window.electron.deleteTempFile(img.filePath); + } catch (error) { + console.error('Error deleting temp file:', error); + } + } + }); + return []; + }); + + // Clear all tracked timeouts + // eslint-disable-next-line react-hooks/exhaustive-deps + const timeouts = timeoutRefsRef.current; + timeouts.forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + timeouts.clear(); + + // Clear alerts to prevent memory leaks + clearAlerts(); + }; + }, [clearAlerts]); + const maxHeight = 10 * 24; - // Debounced function to update actual value - const debouncedSetValue = useMemo( - () => - debounce((value: string) => { - setValue(value); - }, 150), - [setValue] - ); + // Immediate function to update actual value - no debounce for better responsiveness + const updateValue = React.useCallback((value: string) => { + setValue(value); + }, []); - // Debounced autosize function const debouncedAutosize = useMemo( () => debounce((element: HTMLTextAreaElement) => { element.style.height = '0px'; // Reset height const scrollHeight = element.scrollHeight; element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; - }, 150), + }, 50), [maxHeight] ); @@ -481,7 +507,7 @@ export default function ChatInput({ const cursorPosition = evt.target.selectionStart; setDisplayValue(val); // Update display immediately - debouncedSetValue(val); // Debounce the actual state update + updateValue(val); // Update actual value immediately for better responsiveness debouncedSaveDraft(val); // Save draft with debounce // Mark that the user has typed something setHasUserTyped(true); @@ -544,10 +570,12 @@ export default function ChatInput({ }, ]); - // Remove the error message after 5 seconds - setTimeout(() => { + // Remove the error message after 5 seconds with cleanup tracking + const timeoutId = setTimeout(() => { setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-'))); + timeoutRefsRef.current.delete(timeoutId); }, 5000); + timeoutRefsRef.current.add(timeoutId); return; } @@ -568,10 +596,12 @@ export default function ChatInput({ error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.`, }); - // Remove the error message after 5 seconds - setTimeout(() => { + // Remove the error message after 5 seconds with cleanup tracking + const timeoutId = setTimeout(() => { setPastedImages((prev) => prev.filter((img) => img.id !== errorId)); + timeoutRefsRef.current.delete(timeoutId); }, 5000); + timeoutRefsRef.current.add(timeoutId); continue; } @@ -636,11 +666,10 @@ export default function ChatInput({ // Cleanup debounced functions on unmount useEffect(() => { return () => { - debouncedSetValue.cancel?.(); debouncedAutosize.cancel?.(); debouncedSaveDraft.cancel?.(); }; - }, [debouncedSetValue, debouncedAutosize, debouncedSaveDraft]); + }, [debouncedAutosize, debouncedSaveDraft]); // Handlers for composition events, which are crucial for proper IME behavior const handleCompositionStart = () => { diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 4386f38e6518..13277706fcf6 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -47,9 +47,9 @@ export default function ProgressiveMessageList({ appendMessage = () => {}, isUserMessage, onScrollToBottom, - batchSize = 15, // Render 15 messages per batch (reduced for better UX) - batchDelay = 30, // 30ms delay between batches (faster) - showLoadingThreshold = 30, // Only show progressive loading for 30+ messages (lower threshold) + batchSize = 20, + batchDelay = 20, + showLoadingThreshold = 50, renderMessage, // Custom render function isStreamingMessage = false, // Whether messages are currently being streamed }: ProgressiveMessageListProps) { diff --git a/ui/desktop/src/hooks/useFileDrop.ts b/ui/desktop/src/hooks/useFileDrop.ts index 60c4da37a83d..d4f0b7df5f98 100644 --- a/ui/desktop/src/hooks/useFileDrop.ts +++ b/ui/desktop/src/hooks/useFileDrop.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useState, useRef, useEffect } from 'react'; export interface DroppedFile { id: string; @@ -13,6 +13,24 @@ export interface DroppedFile { export const useFileDrop = () => { const [droppedFiles, setDroppedFiles] = useState([]); + const activeReadersRef = useRef>(new Set()); + + // Cleanup effect to prevent memory leaks + useEffect(() => { + return () => { + // Abort any active FileReaders on unmount + // eslint-disable-next-line react-hooks/exhaustive-deps + const readers = activeReadersRef.current; + readers.forEach((reader) => { + try { + reader.abort(); + } catch (error) { + // Reader might already be done, ignore errors + } + }); + readers.clear(); + }; + }, []); const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); @@ -56,12 +74,16 @@ export const useFileDrop = () => { // For images, generate a preview (only if successfully processed) if (droppedFile.isImage && !droppedFile.error) { const reader = new FileReader(); + activeReadersRef.current.add(reader); + reader.onload = (event) => { const dataUrl = event.target?.result as string; setDroppedFiles((prev) => prev.map((f) => (f.id === droppedFile.id ? { ...f, dataUrl, isLoading: false } : f)) ); + activeReadersRef.current.delete(reader); }; + reader.onerror = () => { console.error('Failed to generate preview for:', file.name); setDroppedFiles((prev) => @@ -71,7 +93,13 @@ export const useFileDrop = () => { : f ) ); + activeReadersRef.current.delete(reader); }; + + reader.onabort = () => { + activeReadersRef.current.delete(reader); + }; + reader.readAsDataURL(file); } } diff --git a/ui/desktop/src/hooks/useWhisper.ts b/ui/desktop/src/hooks/useWhisper.ts index 199ce192494e..a5c39a172a79 100644 --- a/ui/desktop/src/hooks/useWhisper.ts +++ b/ui/desktop/src/hooks/useWhisper.ts @@ -199,13 +199,29 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp } // Close audio context - if (audioContext) { - audioContext.close(); + if (audioContext && audioContext.state !== 'closed') { + audioContext.close().catch(console.error); setAudioContext(null); setAnalyser(null); } }, [audioContext]); + // Cleanup effect to prevent memory leaks + useEffect(() => { + return () => { + // Cleanup on unmount + if (durationIntervalRef.current) { + clearInterval(durationIntervalRef.current); + } + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + if (audioContext && audioContext.state !== 'closed') { + audioContext.close().catch(console.error); + } + }; + }, [audioContext]); + const startRecording = useCallback(async () => { if (!dictationSettings) { onError?.(new Error('Dictation settings not loaded'));