Fix slow typing in chat input with long running sessions (#3722)
This commit is contained in:
@@ -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 (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user