From dea14241f861e620e3876c7de14130f274735378 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 30 Sep 2025 18:30:30 -0700 Subject: [PATCH 1/2] fix scroll behavior when user scrolls up from bottom --- ui/desktop/src/components/BaseChat.tsx | 65 +++----------- ui/desktop/src/components/ui/scroll-area.tsx | 95 +++++++++++++++----- 2 files changed, 85 insertions(+), 75 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 66aeb5cfe75b..ac3c35293f24 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) => { diff --git a/ui/desktop/src/components/ui/scroll-area.tsx b/ui/desktop/src/components/ui/scroll-area.tsx index e116e94b5ebf..37b25692b89c 100644 --- a/ui/desktop/src/components/ui/scroll-area.tsx +++ b/ui/desktop/src/components/ui/scroll-area.tsx @@ -8,34 +8,56 @@ 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; } const ScrollArea = React.forwardRef( - ({ className, children, autoScroll = false, paddingX, paddingY, ...props }, ref) => { + ( + { className, children, autoScroll = false, onScrollChange, paddingX, paddingY, ...props }, + ref + ) => { const rootRef = React.useRef>(null); const viewportRef = React.useRef(null); 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 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 }) => { @@ -55,8 +77,11 @@ const ScrollArea = React.forwardRef( () => ({ scrollToBottom, scrollToPosition, + isAtBottom, + isFollowing, + viewportRef, }), - [scrollToBottom, scrollToPosition] + [scrollToBottom, scrollToPosition, isAtBottom, isFollowing] ); // Handle scroll events to update isFollowing state @@ -64,39 +89,61 @@ const ScrollArea = React.forwardRef( if (!viewportRef.current) return; const viewport = viewportRef.current; - const { scrollHeight, scrollTop, clientHeight } = viewport; - - const scrollBottom = scrollTop + clientHeight; - const isAtBottom = scrollHeight - scrollBottom <= 10; + const { scrollTop } = viewport; + const currentIsAtBottom = isAtBottom(); + + // 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); - }, []); - - // Track previous scroll height to detect content changes - const prevScrollHeightRef = React.useRef(0); + }, [isAtBottom, isFollowing, onScrollChange]); + // 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 + if ( + currentScrollHeight > lastScrollHeightRef.current && + isFollowing && + !userScrolledUpRef.current + ) { + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + if (viewportRef.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); + viewport.addEventListener('scroll', handleScroll, { passive: true }); return () => viewport.removeEventListener('scroll', handleScroll); }, [handleScroll]); From f550ae768e117a07217e75d37eaec1ed737e7a96 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 30 Sep 2025 18:49:53 -0700 Subject: [PATCH 2/2] ignore auto scroll if user is actively scrolling --- ui/desktop/src/components/ui/scroll-area.tsx | 37 ++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/components/ui/scroll-area.tsx b/ui/desktop/src/components/ui/scroll-area.tsx index 37b25692b89c..47da66a15186 100644 --- a/ui/desktop/src/components/ui/scroll-area.tsx +++ b/ui/desktop/src/components/ui/scroll-area.tsx @@ -33,6 +33,8 @@ const ScrollArea = React.forwardRef( 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; @@ -84,6 +86,9 @@ const ScrollArea = React.forwardRef( [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; @@ -92,6 +97,25 @@ const ScrollArea = React.forwardRef( 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); + } + + // 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 @@ -119,14 +143,16 @@ const ScrollArea = React.forwardRef( // 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 + !userScrolledUpRef.current && + !isActivelyScrollingRef.current ) { // Use requestAnimationFrame to ensure DOM has updated requestAnimationFrame(() => { - if (viewportRef.current) { + if (viewportRef.current && !isActivelyScrollingRef.current) { viewportRef.current.scrollTo({ top: viewportRef.current.scrollHeight, behavior: 'smooth', @@ -144,7 +170,12 @@ const ScrollArea = React.forwardRef( if (!viewport) return; viewport.addEventListener('scroll', handleScroll, { passive: true }); - return () => viewport.removeEventListener('scroll', handleScroll); + return () => { + viewport.removeEventListener('scroll', handleScroll); + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; }, [handleScroll]); return (