Fix auto scroll to bottom during chat (#4923)

This commit is contained in:
Zane
2025-10-01 14:23:03 -07:00
committed by GitHub
parent 8cb6eb4185
commit 345cc65bd3
3 changed files with 167 additions and 83 deletions
+20 -52
View File
@@ -117,50 +117,6 @@ function BaseChatContent({
const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
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
const {
messages,
@@ -187,14 +143,10 @@ function BaseChatContent({
chat,
setChat,
onMessageStreamFinish: () => {
conditionalAutoScroll();
// Call the original callback if provided
onMessageStreamFinish?.();
},
onMessageSent: () => {
wasFollowingRef.current = isNearBottom();
// Mark that user has started using the recipe
if (recipeConfig) {
setHasStartedUsingRecipe(true);
@@ -275,12 +227,23 @@ function BaseChatContent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Track if this is the initial render for session resuming
const initialRenderRef = useRef(true);
// Auto-scroll when messages are loaded (for session resuming)
const handleRenderingComplete = React.useCallback(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
// Only force scroll on the very first render
if (initialRenderRef.current && messages.length > 0) {
initialRenderRef.current = false;
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
} else if (scrollRef.current?.isFollowing) {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}
}, []);
}, [messages.length]);
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
@@ -441,7 +404,12 @@ function BaseChatContent({
onClick={async () => {
clearError();
await handleManualCompaction(messages, setMessages, append, chat.sessionId);
await handleManualCompaction(
messages,
setMessages,
append,
chat.sessionId
);
}}
>
Summarize Conversation
@@ -166,7 +166,12 @@ describe('ContextManager', () => {
const { result } = renderContextManager();
await act(async () => {
await result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend, 'test-session-id');
await result.current.handleAutoCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
expect(mockManageContextFromBackend).toHaveBeenCalledWith({
@@ -226,7 +231,12 @@ describe('ContextManager', () => {
const { result } = renderContextManager();
await act(async () => {
await result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend, "test-session-id");
await result.current.handleAutoCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
expect(result.current.compactionError).toBe('Backend error');
@@ -257,7 +267,12 @@ describe('ContextManager', () => {
// Start compaction
act(() => {
result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend, "test-session-id");
result.current.handleAutoCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
// Should be compacting
@@ -307,7 +322,12 @@ describe('ContextManager', () => {
const { result } = renderContextManager();
await act(async () => {
await result.current.handleAutoCompaction(messages, mockSetMessages, mockAppend, "test-session-id");
await result.current.handleAutoCompaction(
messages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
// No server messages -> setMessages called with empty list
@@ -370,7 +390,12 @@ describe('ContextManager', () => {
const { result } = renderContextManager();
await act(async () => {
await result.current.handleManualCompaction(mockMessages, mockSetMessages, mockAppend, 'test-session-id');
await result.current.handleManualCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
expect(mockManageContextFromBackend).toHaveBeenCalledWith({
@@ -483,7 +508,12 @@ describe('ContextManager', () => {
const { result } = renderContextManager();
await act(async () => {
await result.current.handleManualCompaction(mockMessages, mockSetMessages, mockAppend, 'test-session-id');
await result.current.handleManualCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
// Verify all three messages are set
@@ -510,7 +540,12 @@ describe('ContextManager', () => {
const { result } = renderContextManager();
await act(async () => {
await result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend, "test-session-id");
await result.current.handleAutoCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
expect(result.current.compactionError).toBe('Unknown error during compaction');
@@ -541,7 +576,12 @@ describe('ContextManager', () => {
const { result } = renderContextManager();
await act(async () => {
await result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend, "test-session-id");
await result.current.handleAutoCompaction(
mockMessages,
mockSetMessages,
mockAppend,
'test-session-id'
);
});
// Should complete without error even if content is not text
+99 -23
View File
@@ -8,10 +8,14 @@ import { cn } from '../../utils';
export interface ScrollAreaHandle {
scrollToBottom: () => void;
scrollToPosition: (options: { top: number; behavior?: ScrollBehavior }) => void;
isAtBottom: () => boolean;
isFollowing: boolean;
viewportRef: React.RefObject<HTMLDivElement | null>;
}
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
autoScroll?: boolean;
onScrollChange?: (isAtBottom: boolean) => void;
/* padding needs to be passed into the container inside ScrollArea to avoid pushing the scrollbar out */
paddingX?: number;
paddingY?: number;
@@ -24,6 +28,7 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
className,
children,
autoScroll = false,
onScrollChange,
paddingX,
paddingY,
handleScroll: handleScrollProp,
@@ -36,18 +41,35 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
const viewportEndRef = React.useRef<HTMLDivElement>(null);
const [isFollowing, setIsFollowing] = React.useState(true);
const [isScrolled, setIsScrolled] = React.useState(false);
const userScrolledUpRef = React.useRef(false);
const lastScrollHeightRef = React.useRef(0);
const isActivelyScrollingRef = React.useRef(false);
const scrollTimeoutRef = React.useRef<number | null>(null);
const BOTTOM_SCROLL_THRESHOLD = 100;
const isAtBottom = React.useCallback(() => {
if (!viewportRef.current) return false;
const viewport = viewportRef.current;
const { scrollHeight, scrollTop, clientHeight } = viewport;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom <= BOTTOM_SCROLL_THRESHOLD;
}, []);
const scrollToBottom = React.useCallback(() => {
if (viewportEndRef.current) {
viewportEndRef.current.scrollIntoView({
if (viewportRef.current) {
viewportRef.current.scrollTo({
top: viewportRef.current.scrollHeight,
behavior: 'smooth',
block: 'end',
inline: 'nearest',
});
// When explicitly scrolling to bottom, reset the following state
setIsFollowing(true);
userScrolledUpRef.current = false;
onScrollChange?.(true);
}
}, []);
}, [onScrollChange]);
const scrollToPosition = React.useCallback(
({ top, behavior = 'smooth' }: { top: number; behavior?: ScrollBehavior }) => {
@@ -67,53 +89,107 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
() => ({
scrollToBottom,
scrollToPosition,
isAtBottom,
isFollowing,
viewportRef,
}),
[scrollToBottom, scrollToPosition]
[scrollToBottom, scrollToPosition, isAtBottom, isFollowing]
);
// track last scroll position to detect user-initiated scrolling
const lastScrollTopRef = React.useRef(0);
// Handle scroll events to update isFollowing state
const handleScroll = React.useCallback(() => {
if (!viewportRef.current) return;
const viewport = viewportRef.current;
const { scrollHeight, scrollTop, clientHeight } = viewport;
const { scrollTop } = viewport;
const currentIsAtBottom = isAtBottom();
const scrollBottom = scrollTop + clientHeight;
const isAtBottom = scrollHeight - scrollBottom <= 10;
// detect if this is a user-initiated scroll (position changed from last known position)
const scrollDelta = Math.abs(scrollTop - lastScrollTopRef.current);
if (scrollDelta > 0) {
// Mark that user is actively scrolling immediately
isActivelyScrollingRef.current = true;
// clear any existing timeout and set a new one
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// mark as not actively scrolling
scrollTimeoutRef.current = window.setTimeout(() => {
isActivelyScrollingRef.current = false;
}, 100);
}
lastScrollTopRef.current = scrollTop;
// Detect if user manually scrolled up from the bottom
if (!currentIsAtBottom && isFollowing) {
// user scrolled up, disabling auto-scroll
userScrolledUpRef.current = true;
setIsFollowing(false);
onScrollChange?.(false);
} else if (currentIsAtBottom && userScrolledUpRef.current) {
// user scrolled back to bottom
userScrolledUpRef.current = false;
setIsFollowing(true);
onScrollChange?.(true);
}
setIsFollowing(isAtBottom);
setIsScrolled(scrollTop > 0);
if (handleScrollProp) {
handleScrollProp(viewport);
}
}, [handleScrollProp]);
// Track previous scroll height to detect content changes
const prevScrollHeightRef = React.useRef<number>(0);
}, [isAtBottom, isFollowing, onScrollChange, handleScrollProp]);
// Auto-scroll when content changes and user is following
React.useEffect(() => {
if (!autoScroll || !isFollowing || !viewportRef.current) return;
if (!autoScroll || !viewportRef.current) return;
const viewport = viewportRef.current;
const currentScrollHeight = viewport.scrollHeight;
// Only auto-scroll if content has actually grown (new content added)
// and we were already following (at the bottom)
if (currentScrollHeight > prevScrollHeightRef.current) {
scrollToBottom();
// Only auto-scroll if:
// 1. Content has actually grown (new content added)
// 2. User was following (at the bottom)
// 3. User hasn't manually scrolled up
// 4. User is not actively scrolling
if (
currentScrollHeight > lastScrollHeightRef.current &&
isFollowing &&
!userScrolledUpRef.current &&
!isActivelyScrollingRef.current
) {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
if (viewportRef.current && !isActivelyScrollingRef.current) {
viewportRef.current.scrollTo({
top: viewportRef.current.scrollHeight,
behavior: 'smooth',
});
}
});
}
prevScrollHeightRef.current = currentScrollHeight;
}, [children, autoScroll, isFollowing, scrollToBottom]);
lastScrollHeightRef.current = currentScrollHeight;
}, [children, autoScroll, isFollowing]);
// Add scroll event listener
React.useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
viewport.addEventListener('scroll', handleScroll);
return () => viewport.removeEventListener('scroll', handleScroll);
viewport.addEventListener('scroll', handleScroll, { passive: true });
return () => {
viewport.removeEventListener('scroll', handleScroll);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [handleScroll]);
return (