improve auto scroll to bottom + detection (#4504)

This commit is contained in:
Zane
2025-09-09 08:27:00 -07:00
committed by GitHub
parent 32fb6e846f
commit 208999ee64
2 changed files with 76 additions and 6 deletions
+58 -5
View File
@@ -112,15 +112,55 @@ function BaseChatContent({
const location = useLocation(); const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null); const scrollRef = useRef<ScrollAreaHandle>(null);
// Get disableAnimation from location state
const disableAnimation = location.state?.disableAnimation || false; const disableAnimation = location.state?.disableAnimation || false;
// Track if user has started using the current recipe
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null); const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
const { isCompacting, handleManualCompaction } = useContextManager(); const { isCompacting, handleManualCompaction } = useContextManager();
// Timeout ref for debouncing auto-scroll
const autoScrollTimeoutRef = useRef<number | null>(null);
// Track if user was following when agent started responding
const wasFollowingRef = useRef<boolean>(true);
const isNearBottom = React.useCallback(() => {
if (!scrollRef.current) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const viewport = scrollRef.current as any;
if (!viewport.viewportRef?.current) return false;
const viewportElement = viewport.viewportRef.current;
const { scrollHeight, scrollTop, clientHeight } = viewportElement;
const scrollBottom = scrollTop + clientHeight;
const distanceFromBottom = scrollHeight - scrollBottom;
return distanceFromBottom <= 100;
}, []);
// Function to auto-scroll if user was following when agent started
const conditionalAutoScroll = React.useCallback(() => {
// Clear any existing timeout
if (autoScrollTimeoutRef.current) {
clearTimeout(autoScrollTimeoutRef.current);
}
// Debounce the auto-scroll to prevent jumpy behavior and prevent multiple rapid scrolls
autoScrollTimeoutRef.current = window.setTimeout(() => {
// Only auto-scroll if user was following when the agent started responding
if (wasFollowingRef.current && scrollRef.current) {
scrollRef.current.scrollToBottom();
}
}, 150);
}, []);
useEffect(() => {
return () => {
if (autoScrollTimeoutRef.current) {
clearTimeout(autoScrollTimeoutRef.current);
}
};
}, []);
// Use shared chat engine // Use shared chat engine
const { const {
messages, messages,
@@ -148,10 +188,14 @@ function BaseChatContent({
chat, chat,
setChat, setChat,
onMessageStreamFinish: () => { onMessageStreamFinish: () => {
conditionalAutoScroll();
// Call the original callback if provided // Call the original callback if provided
onMessageStreamFinish?.(); onMessageStreamFinish?.();
}, },
onMessageSent: () => { onMessageSent: () => {
wasFollowingRef.current = isNearBottom();
// Mark that user has started using the recipe // Mark that user has started using the recipe
if (recipeConfig) { if (recipeConfig) {
setHasStartedUsingRecipe(true); setHasStartedUsingRecipe(true);
@@ -229,7 +273,14 @@ function BaseChatContent({
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2) 'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
); );
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array means this runs once on mount }, []);
// Auto-scroll when messages are loaded (for session resuming)
const handleRenderingComplete = React.useCallback(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, []);
// Handle submit // Handle submit
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -361,6 +412,7 @@ function BaseChatContent({
isUserMessage={isUserMessage} isUserMessage={isUserMessage}
isStreamingMessage={chatState !== ChatState.Idle} isStreamingMessage={chatState !== ChatState.Idle}
onMessageUpdate={onMessageUpdate} onMessageUpdate={onMessageUpdate}
onRenderingComplete={handleRenderingComplete}
/> />
) : ( ) : (
// Render messages with SearchView wrapper when search is enabled // Render messages with SearchView wrapper when search is enabled
@@ -377,6 +429,7 @@ function BaseChatContent({
isUserMessage={isUserMessage} isUserMessage={isUserMessage}
isStreamingMessage={chatState !== ChatState.Idle} isStreamingMessage={chatState !== ChatState.Idle}
onMessageUpdate={onMessageUpdate} onMessageUpdate={onMessageUpdate}
onRenderingComplete={handleRenderingComplete}
/> />
</SearchView> </SearchView>
)} )}
@@ -38,6 +38,7 @@ interface ProgressiveMessageListProps {
renderMessage?: (message: Message, index: number) => React.ReactNode | null; renderMessage?: (message: Message, index: number) => React.ReactNode | null;
isStreamingMessage?: boolean; // Whether messages are currently being streamed isStreamingMessage?: boolean; // Whether messages are currently being streamed
onMessageUpdate?: (messageId: string, newContent: string) => void; onMessageUpdate?: (messageId: string, newContent: string) => void;
onRenderingComplete?: () => void; // Callback when all messages are rendered
} }
export default function ProgressiveMessageList({ export default function ProgressiveMessageList({
@@ -53,6 +54,7 @@ export default function ProgressiveMessageList({
renderMessage, // Custom render function renderMessage, // Custom render function
isStreamingMessage = false, // Whether messages are currently being streamed isStreamingMessage = false, // Whether messages are currently being streamed
onMessageUpdate, onMessageUpdate,
onRenderingComplete,
}: ProgressiveMessageListProps) { }: ProgressiveMessageListProps) {
const [renderedCount, setRenderedCount] = useState(() => { const [renderedCount, setRenderedCount] = useState(() => {
// Initialize with either all messages (if small) or first batch (if large) // Initialize with either all messages (if small) or first batch (if large)
@@ -83,6 +85,10 @@ export default function ProgressiveMessageList({
if (messages.length <= showLoadingThreshold) { if (messages.length <= showLoadingThreshold) {
setRenderedCount(messages.length); setRenderedCount(messages.length);
setIsLoading(false); setIsLoading(false);
// For small lists, call completion callback immediately
if (onRenderingComplete) {
setTimeout(() => onRenderingComplete(), 50);
}
return; return;
} }
@@ -93,6 +99,10 @@ export default function ProgressiveMessageList({
if (nextCount >= messages.length) { if (nextCount >= messages.length) {
setIsLoading(false); setIsLoading(false);
// Call the completion callback after a brief delay to ensure DOM is updated
if (onRenderingComplete) {
setTimeout(() => onRenderingComplete(), 50);
}
} else { } else {
// Schedule next batch // Schedule next batch
timeoutRef.current = window.setTimeout(loadNextBatch, batchDelay); timeoutRef.current = window.setTimeout(loadNextBatch, batchDelay);
@@ -111,7 +121,14 @@ export default function ProgressiveMessageList({
timeoutRef.current = null; timeoutRef.current = null;
} }
}; };
}, [messages.length, batchSize, batchDelay, showLoadingThreshold, renderedCount]); }, [
messages.length,
batchSize,
batchDelay,
showLoadingThreshold,
renderedCount,
onRenderingComplete,
]);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {