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
14 changes: 9 additions & 5 deletions ui/desktop/src/components/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
63 changes: 46 additions & 17 deletions ui/desktop/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export default function ChatInput({
const [isInGlobalHistory, setIsInGlobalHistory] = useState(false);
const [hasUserTyped, setHasUserTyped] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());

// Use shared file drop hook for ChatInput
const {
Expand Down Expand Up @@ -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]
);

Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 = () => {
Expand Down
6 changes: 3 additions & 3 deletions ui/desktop/src/components/ProgressiveMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 29 additions & 1 deletion ui/desktop/src/hooks/useFileDrop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useState, useRef, useEffect } from 'react';

export interface DroppedFile {
id: string;
Expand All @@ -13,6 +13,24 @@ export interface DroppedFile {

export const useFileDrop = () => {
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>) => {
e.preventDefault();
Expand Down Expand Up @@ -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) =>
Expand All @@ -71,7 +93,13 @@ export const useFileDrop = () => {
: f
)
);
activeReadersRef.current.delete(reader);
};

reader.onabort = () => {
activeReadersRef.current.delete(reader);
};

reader.readAsDataURL(file);
}
}
Expand Down
20 changes: 18 additions & 2 deletions ui/desktop/src/hooks/useWhisper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
Loading