improve auto scroll to bottom + detection (#4504)
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user