Fix slow typing in chat input with long running sessions (#3722)

This commit is contained in:
Zane
2025-07-30 10:42:13 -07:00
committed by GitHub
parent f47836bbe8
commit be6c599063
5 changed files with 105 additions and 28 deletions
+9 -5
View File
@@ -313,11 +313,15 @@ function BaseChatContent({
}; };
// Callback to handle scroll to bottom from ProgressiveMessageList // Callback to handle scroll to bottom from ProgressiveMessageList
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
setTimeout(() => { // Only auto-scroll if user is not actively typing
if (scrollRef.current?.scrollToBottom) { const isUserTyping = document.activeElement?.id === 'dynamic-textarea';
scrollRef.current.scrollToBottom(); if (!isUserTyping) {
} setTimeout(() => {
}, 100); if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 100);
}
}, []); }, []);
return ( return (
+46 -17
View File
@@ -239,6 +239,7 @@ export default function ChatInput({
const [isInGlobalHistory, setIsInGlobalHistory] = useState(false); const [isInGlobalHistory, setIsInGlobalHistory] = useState(false);
const [hasUserTyped, setHasUserTyped] = useState(false); const [hasUserTyped, setHasUserTyped] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null); const textAreaRef = useRef<HTMLTextAreaElement>(null);
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
// Use shared file drop hook for ChatInput // Use shared file drop hook for ChatInput
const { const {
@@ -441,25 +442,50 @@ export default function ChatInput({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]); }, [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; const maxHeight = 10 * 24;
// Debounced function to update actual value // Immediate function to update actual value - no debounce for better responsiveness
const debouncedSetValue = useMemo( const updateValue = React.useCallback((value: string) => {
() => setValue(value);
debounce((value: string) => { }, []);
setValue(value);
}, 150),
[setValue]
);
// Debounced autosize function
const debouncedAutosize = useMemo( const debouncedAutosize = useMemo(
() => () =>
debounce((element: HTMLTextAreaElement) => { debounce((element: HTMLTextAreaElement) => {
element.style.height = '0px'; // Reset height element.style.height = '0px'; // Reset height
const scrollHeight = element.scrollHeight; const scrollHeight = element.scrollHeight;
element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; element.style.height = Math.min(scrollHeight, maxHeight) + 'px';
}, 150), }, 50),
[maxHeight] [maxHeight]
); );
@@ -481,7 +507,7 @@ export default function ChatInput({
const cursorPosition = evt.target.selectionStart; const cursorPosition = evt.target.selectionStart;
setDisplayValue(val); // Update display immediately 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 debouncedSaveDraft(val); // Save draft with debounce
// Mark that the user has typed something // Mark that the user has typed something
setHasUserTyped(true); setHasUserTyped(true);
@@ -544,10 +570,12 @@ export default function ChatInput({
}, },
]); ]);
// Remove the error message after 5 seconds // Remove the error message after 5 seconds with cleanup tracking
setTimeout(() => { const timeoutId = setTimeout(() => {
setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-'))); setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-')));
timeoutRefsRef.current.delete(timeoutId);
}, 5000); }, 5000);
timeoutRefsRef.current.add(timeoutId);
return; 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.`, 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 // Remove the error message after 5 seconds with cleanup tracking
setTimeout(() => { const timeoutId = setTimeout(() => {
setPastedImages((prev) => prev.filter((img) => img.id !== errorId)); setPastedImages((prev) => prev.filter((img) => img.id !== errorId));
timeoutRefsRef.current.delete(timeoutId);
}, 5000); }, 5000);
timeoutRefsRef.current.add(timeoutId);
continue; continue;
} }
@@ -636,11 +666,10 @@ export default function ChatInput({
// Cleanup debounced functions on unmount // Cleanup debounced functions on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
debouncedSetValue.cancel?.();
debouncedAutosize.cancel?.(); debouncedAutosize.cancel?.();
debouncedSaveDraft.cancel?.(); debouncedSaveDraft.cancel?.();
}; };
}, [debouncedSetValue, debouncedAutosize, debouncedSaveDraft]); }, [debouncedAutosize, debouncedSaveDraft]);
// Handlers for composition events, which are crucial for proper IME behavior // Handlers for composition events, which are crucial for proper IME behavior
const handleCompositionStart = () => { const handleCompositionStart = () => {
@@ -47,9 +47,9 @@ export default function ProgressiveMessageList({
appendMessage = () => {}, appendMessage = () => {},
isUserMessage, isUserMessage,
onScrollToBottom, onScrollToBottom,
batchSize = 15, // Render 15 messages per batch (reduced for better UX) batchSize = 20,
batchDelay = 30, // 30ms delay between batches (faster) batchDelay = 20,
showLoadingThreshold = 30, // Only show progressive loading for 30+ messages (lower threshold) showLoadingThreshold = 50,
renderMessage, // Custom render function renderMessage, // Custom render function
isStreamingMessage = false, // Whether messages are currently being streamed isStreamingMessage = false, // Whether messages are currently being streamed
}: ProgressiveMessageListProps) { }: ProgressiveMessageListProps) {
+29 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useState, useRef, useEffect } from 'react';
export interface DroppedFile { export interface DroppedFile {
id: string; id: string;
@@ -13,6 +13,24 @@ export interface DroppedFile {
export const useFileDrop = () => { export const useFileDrop = () => {
const [droppedFiles, setDroppedFiles] = useState<DroppedFile[]>([]); const [droppedFiles, setDroppedFiles] = useState<DroppedFile[]>([]);
const activeReadersRef = useRef<Set<FileReader>>(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<HTMLDivElement>) => { const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
@@ -56,12 +74,16 @@ export const useFileDrop = () => {
// For images, generate a preview (only if successfully processed) // For images, generate a preview (only if successfully processed)
if (droppedFile.isImage && !droppedFile.error) { if (droppedFile.isImage && !droppedFile.error) {
const reader = new FileReader(); const reader = new FileReader();
activeReadersRef.current.add(reader);
reader.onload = (event) => { reader.onload = (event) => {
const dataUrl = event.target?.result as string; const dataUrl = event.target?.result as string;
setDroppedFiles((prev) => setDroppedFiles((prev) =>
prev.map((f) => (f.id === droppedFile.id ? { ...f, dataUrl, isLoading: false } : f)) prev.map((f) => (f.id === droppedFile.id ? { ...f, dataUrl, isLoading: false } : f))
); );
activeReadersRef.current.delete(reader);
}; };
reader.onerror = () => { reader.onerror = () => {
console.error('Failed to generate preview for:', file.name); console.error('Failed to generate preview for:', file.name);
setDroppedFiles((prev) => setDroppedFiles((prev) =>
@@ -71,7 +93,13 @@ export const useFileDrop = () => {
: f : f
) )
); );
activeReadersRef.current.delete(reader);
}; };
reader.onabort = () => {
activeReadersRef.current.delete(reader);
};
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
} }
+18 -2
View File
@@ -199,13 +199,29 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp
} }
// Close audio context // Close audio context
if (audioContext) { if (audioContext && audioContext.state !== 'closed') {
audioContext.close(); audioContext.close().catch(console.error);
setAudioContext(null); setAudioContext(null);
setAnalyser(null); setAnalyser(null);
} }
}, [audioContext]); }, [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 () => { const startRecording = useCallback(async () => {
if (!dictationSettings) { if (!dictationSettings) {
onError?.(new Error('Dictation settings not loaded')); onError?.(new Error('Dictation settings not loaded'));