diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index e267ffab26a7..caf710fbd4c8 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -117,50 +117,6 @@ function BaseChatContent({ const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState(null); const { isCompacting, handleManualCompaction } = useContextManager(); - // Timeout ref for debouncing auto-scroll - const autoScrollTimeoutRef = useRef(null); - // Track if user was following when agent started responding - const wasFollowingRef = useRef(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 diff --git a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx index 1ef01070d9cb..53ac40b7a07d 100644 --- a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx @@ -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 diff --git a/ui/desktop/src/components/ui/scroll-area.tsx b/ui/desktop/src/components/ui/scroll-area.tsx index bcd5938fa8a0..43ce277339b4 100644 --- a/ui/desktop/src/components/ui/scroll-area.tsx +++ b/ui/desktop/src/components/ui/scroll-area.tsx @@ -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; } interface ScrollAreaProps extends React.ComponentPropsWithoutRef { 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( className, children, autoScroll = false, + onScrollChange, paddingX, paddingY, handleScroll: handleScrollProp, @@ -36,18 +41,35 @@ const ScrollArea = React.forwardRef( const viewportEndRef = React.useRef(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(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( () => ({ 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(); + + // 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); + } - const scrollBottom = scrollTop + clientHeight; - const isAtBottom = scrollHeight - scrollBottom <= 10; + // 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(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 (