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 [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,
|
||||||
@@ -187,14 +143,10 @@ 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);
|
||||||
@@ -275,12 +227,23 @@ function BaseChatContent({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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)
|
// Auto-scroll when messages are loaded (for session resuming)
|
||||||
const handleRenderingComplete = React.useCallback(() => {
|
const handleRenderingComplete = React.useCallback(() => {
|
||||||
if (scrollRef.current?.scrollToBottom) {
|
// Only force scroll on the very first render
|
||||||
scrollRef.current.scrollToBottom();
|
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
|
// Handle submit
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -441,7 +404,12 @@ function BaseChatContent({
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
await handleManualCompaction(messages, setMessages, append, chat.sessionId);
|
await handleManualCompaction(
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
append,
|
||||||
|
chat.sessionId
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Summarize Conversation
|
Summarize Conversation
|
||||||
|
|||||||
@@ -166,7 +166,12 @@ describe('ContextManager', () => {
|
|||||||
const { result } = renderContextManager();
|
const { result } = renderContextManager();
|
||||||
|
|
||||||
await act(async () => {
|
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({
|
expect(mockManageContextFromBackend).toHaveBeenCalledWith({
|
||||||
@@ -226,7 +231,12 @@ describe('ContextManager', () => {
|
|||||||
const { result } = renderContextManager();
|
const { result } = renderContextManager();
|
||||||
|
|
||||||
await act(async () => {
|
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');
|
expect(result.current.compactionError).toBe('Backend error');
|
||||||
@@ -257,7 +267,12 @@ describe('ContextManager', () => {
|
|||||||
|
|
||||||
// Start compaction
|
// Start compaction
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleAutoCompaction(mockMessages, mockSetMessages, mockAppend, "test-session-id");
|
result.current.handleAutoCompaction(
|
||||||
|
mockMessages,
|
||||||
|
mockSetMessages,
|
||||||
|
mockAppend,
|
||||||
|
'test-session-id'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should be compacting
|
// Should be compacting
|
||||||
@@ -307,7 +322,12 @@ describe('ContextManager', () => {
|
|||||||
const { result } = renderContextManager();
|
const { result } = renderContextManager();
|
||||||
|
|
||||||
await act(async () => {
|
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
|
// No server messages -> setMessages called with empty list
|
||||||
@@ -370,7 +390,12 @@ describe('ContextManager', () => {
|
|||||||
const { result } = renderContextManager();
|
const { result } = renderContextManager();
|
||||||
|
|
||||||
await act(async () => {
|
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({
|
expect(mockManageContextFromBackend).toHaveBeenCalledWith({
|
||||||
@@ -483,7 +508,12 @@ describe('ContextManager', () => {
|
|||||||
const { result } = renderContextManager();
|
const { result } = renderContextManager();
|
||||||
|
|
||||||
await act(async () => {
|
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
|
// Verify all three messages are set
|
||||||
@@ -510,7 +540,12 @@ describe('ContextManager', () => {
|
|||||||
const { result } = renderContextManager();
|
const { result } = renderContextManager();
|
||||||
|
|
||||||
await act(async () => {
|
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');
|
expect(result.current.compactionError).toBe('Unknown error during compaction');
|
||||||
@@ -541,7 +576,12 @@ describe('ContextManager', () => {
|
|||||||
const { result } = renderContextManager();
|
const { result } = renderContextManager();
|
||||||
|
|
||||||
await act(async () => {
|
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
|
// Should complete without error even if content is not text
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import { cn } from '../../utils';
|
|||||||
export interface ScrollAreaHandle {
|
export interface ScrollAreaHandle {
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
scrollToPosition: (options: { top: number; behavior?: ScrollBehavior }) => 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> {
|
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||||
autoScroll?: boolean;
|
autoScroll?: boolean;
|
||||||
|
onScrollChange?: (isAtBottom: boolean) => void;
|
||||||
/* padding needs to be passed into the container inside ScrollArea to avoid pushing the scrollbar out */
|
/* padding needs to be passed into the container inside ScrollArea to avoid pushing the scrollbar out */
|
||||||
paddingX?: number;
|
paddingX?: number;
|
||||||
paddingY?: number;
|
paddingY?: number;
|
||||||
@@ -24,6 +28,7 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
autoScroll = false,
|
autoScroll = false,
|
||||||
|
onScrollChange,
|
||||||
paddingX,
|
paddingX,
|
||||||
paddingY,
|
paddingY,
|
||||||
handleScroll: handleScrollProp,
|
handleScroll: handleScrollProp,
|
||||||
@@ -36,18 +41,35 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
|
|||||||
const viewportEndRef = React.useRef<HTMLDivElement>(null);
|
const viewportEndRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [isFollowing, setIsFollowing] = React.useState(true);
|
const [isFollowing, setIsFollowing] = React.useState(true);
|
||||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
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(() => {
|
const scrollToBottom = React.useCallback(() => {
|
||||||
if (viewportEndRef.current) {
|
if (viewportRef.current) {
|
||||||
viewportEndRef.current.scrollIntoView({
|
viewportRef.current.scrollTo({
|
||||||
|
top: viewportRef.current.scrollHeight,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'end',
|
|
||||||
inline: 'nearest',
|
|
||||||
});
|
});
|
||||||
// When explicitly scrolling to bottom, reset the following state
|
// When explicitly scrolling to bottom, reset the following state
|
||||||
setIsFollowing(true);
|
setIsFollowing(true);
|
||||||
|
userScrolledUpRef.current = false;
|
||||||
|
onScrollChange?.(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [onScrollChange]);
|
||||||
|
|
||||||
const scrollToPosition = React.useCallback(
|
const scrollToPosition = React.useCallback(
|
||||||
({ top, behavior = 'smooth' }: { top: number; behavior?: ScrollBehavior }) => {
|
({ top, behavior = 'smooth' }: { top: number; behavior?: ScrollBehavior }) => {
|
||||||
@@ -67,53 +89,107 @@ const ScrollArea = React.forwardRef<ScrollAreaHandle, ScrollAreaProps>(
|
|||||||
() => ({
|
() => ({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollToPosition,
|
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
|
// Handle scroll events to update isFollowing state
|
||||||
const handleScroll = React.useCallback(() => {
|
const handleScroll = React.useCallback(() => {
|
||||||
if (!viewportRef.current) return;
|
if (!viewportRef.current) return;
|
||||||
|
|
||||||
const viewport = viewportRef.current;
|
const viewport = viewportRef.current;
|
||||||
const { scrollHeight, scrollTop, clientHeight } = viewport;
|
const { scrollTop } = viewport;
|
||||||
|
const currentIsAtBottom = isAtBottom();
|
||||||
|
|
||||||
const scrollBottom = scrollTop + clientHeight;
|
// detect if this is a user-initiated scroll (position changed from last known position)
|
||||||
const isAtBottom = scrollHeight - scrollBottom <= 10;
|
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);
|
setIsScrolled(scrollTop > 0);
|
||||||
|
|
||||||
if (handleScrollProp) {
|
if (handleScrollProp) {
|
||||||
handleScrollProp(viewport);
|
handleScrollProp(viewport);
|
||||||
}
|
}
|
||||||
}, [handleScrollProp]);
|
}, [isAtBottom, isFollowing, onScrollChange, handleScrollProp]);
|
||||||
|
|
||||||
// Track previous scroll height to detect content changes
|
|
||||||
const prevScrollHeightRef = React.useRef<number>(0);
|
|
||||||
|
|
||||||
|
// Auto-scroll when content changes and user is following
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!autoScroll || !isFollowing || !viewportRef.current) return;
|
if (!autoScroll || !viewportRef.current) return;
|
||||||
|
|
||||||
const viewport = viewportRef.current;
|
const viewport = viewportRef.current;
|
||||||
const currentScrollHeight = viewport.scrollHeight;
|
const currentScrollHeight = viewport.scrollHeight;
|
||||||
|
|
||||||
// Only auto-scroll if content has actually grown (new content added)
|
// Only auto-scroll if:
|
||||||
// and we were already following (at the bottom)
|
// 1. Content has actually grown (new content added)
|
||||||
if (currentScrollHeight > prevScrollHeightRef.current) {
|
// 2. User was following (at the bottom)
|
||||||
scrollToBottom();
|
// 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;
|
lastScrollHeightRef.current = currentScrollHeight;
|
||||||
}, [children, autoScroll, isFollowing, scrollToBottom]);
|
}, [children, autoScroll, isFollowing]);
|
||||||
|
|
||||||
// Add scroll event listener
|
// Add scroll event listener
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const viewport = viewportRef.current;
|
const viewport = viewportRef.current;
|
||||||
if (!viewport) return;
|
if (!viewport) return;
|
||||||
|
|
||||||
viewport.addEventListener('scroll', handleScroll);
|
viewport.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
return () => viewport.removeEventListener('scroll', handleScroll);
|
return () => {
|
||||||
|
viewport.removeEventListener('scroll', handleScroll);
|
||||||
|
if (scrollTimeoutRef.current) {
|
||||||
|
clearTimeout(scrollTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user