Fix auto scroll to bottom during chat (#4923)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user