From 04cd6861890851b84f449deb30a7ec10d36246db Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Wed, 3 Sep 2025 14:47:49 -0700 Subject: [PATCH 1/4] improve auto scroll to bottom + detection --- ui/desktop/src/components/BaseChat.tsx | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 85497bbaf33c..427c0d851b1b 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -121,6 +121,53 @@ function BaseChatContent({ 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); + + // Function to check if user is currently near bottom + 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 + 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); // 150ms debounce to prevent multiple rapid scrolls + }, []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (autoScrollTimeoutRef.current) { + clearTimeout(autoScrollTimeoutRef.current); + } + }; + }, []); + // Use shared chat engine const { messages, @@ -148,10 +195,14 @@ 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); @@ -231,6 +282,20 @@ function BaseChatContent({ // 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) + useEffect(() => { + if (messages.length > 0 && !loadingChat) { + const scrollTimeout = setTimeout(() => { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + }, 500); // delay to ensure content is fully loaded + + return () => clearTimeout(scrollTimeout); + } + return; + }, [messages.length, loadingChat]); + // Handle submit const handleSubmit = (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; From 3d57c0326825e9464ba3ea655f4fe1b8ac5ba95c Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 4 Sep 2025 13:29:59 -0700 Subject: [PATCH 2/4] clean up comments --- ui/desktop/src/components/BaseChat.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 427c0d851b1b..697072470d4d 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -112,22 +112,16 @@ function BaseChatContent({ const location = useLocation(); const scrollRef = useRef(null); - // Get disableAnimation from location state const disableAnimation = location.state?.disableAnimation || false; - - // Track if user has started using the current recipe const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); 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); - // Function to check if user is currently near bottom const isNearBottom = React.useCallback(() => { if (!scrollRef.current) return false; @@ -150,16 +144,15 @@ function BaseChatContent({ clearTimeout(autoScrollTimeoutRef.current); } - // Debounce the auto-scroll to prevent jumpy behavior + // 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); // 150ms debounce to prevent multiple rapid scrolls + }, 150); }, []); - // Cleanup timeout on unmount useEffect(() => { return () => { if (autoScrollTimeoutRef.current) { From 8511da0ea81eb3a55de13c38e2cdc312e125f113 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 4 Sep 2025 13:31:36 -0700 Subject: [PATCH 3/4] clean up comments --- ui/desktop/src/components/BaseChat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 697072470d4d..75bf5e919bd7 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -273,7 +273,7 @@ function BaseChatContent({ 'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2) ); // 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) useEffect(() => { From e84ce2fddaf8c9af12099853a95359f7d5c11f14 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 8 Sep 2025 15:08:08 -0700 Subject: [PATCH 4/4] move session scroll to bottom logic to rendering complete callback --- ui/desktop/src/components/BaseChat.tsx | 17 ++++++----------- .../src/components/ProgressiveMessageList.tsx | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 75bf5e919bd7..f074969dce83 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -276,18 +276,11 @@ function BaseChatContent({ }, []); // Auto-scroll when messages are loaded (for session resuming) - useEffect(() => { - if (messages.length > 0 && !loadingChat) { - const scrollTimeout = setTimeout(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - }, 500); // delay to ensure content is fully loaded - - return () => clearTimeout(scrollTimeout); + const handleRenderingComplete = React.useCallback(() => { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); } - return; - }, [messages.length, loadingChat]); + }, []); // Handle submit const handleSubmit = (e: React.FormEvent) => { @@ -419,6 +412,7 @@ function BaseChatContent({ isUserMessage={isUserMessage} isStreamingMessage={chatState !== ChatState.Idle} onMessageUpdate={onMessageUpdate} + onRenderingComplete={handleRenderingComplete} /> ) : ( // Render messages with SearchView wrapper when search is enabled @@ -435,6 +429,7 @@ function BaseChatContent({ isUserMessage={isUserMessage} isStreamingMessage={chatState !== ChatState.Idle} onMessageUpdate={onMessageUpdate} + onRenderingComplete={handleRenderingComplete} /> )} diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 2d6ed51a275b..0b4e87f6dc04 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -38,6 +38,7 @@ interface ProgressiveMessageListProps { renderMessage?: (message: Message, index: number) => React.ReactNode | null; isStreamingMessage?: boolean; // Whether messages are currently being streamed onMessageUpdate?: (messageId: string, newContent: string) => void; + onRenderingComplete?: () => void; // Callback when all messages are rendered } export default function ProgressiveMessageList({ @@ -53,6 +54,7 @@ export default function ProgressiveMessageList({ renderMessage, // Custom render function isStreamingMessage = false, // Whether messages are currently being streamed onMessageUpdate, + onRenderingComplete, }: ProgressiveMessageListProps) { const [renderedCount, setRenderedCount] = useState(() => { // Initialize with either all messages (if small) or first batch (if large) @@ -83,6 +85,10 @@ export default function ProgressiveMessageList({ if (messages.length <= showLoadingThreshold) { setRenderedCount(messages.length); setIsLoading(false); + // For small lists, call completion callback immediately + if (onRenderingComplete) { + setTimeout(() => onRenderingComplete(), 50); + } return; } @@ -93,6 +99,10 @@ export default function ProgressiveMessageList({ if (nextCount >= messages.length) { setIsLoading(false); + // Call the completion callback after a brief delay to ensure DOM is updated + if (onRenderingComplete) { + setTimeout(() => onRenderingComplete(), 50); + } } else { // Schedule next batch timeoutRef.current = window.setTimeout(loadNextBatch, batchDelay); @@ -111,7 +121,14 @@ export default function ProgressiveMessageList({ timeoutRef.current = null; } }; - }, [messages.length, batchSize, batchDelay, showLoadingThreshold, renderedCount]); + }, [ + messages.length, + batchSize, + batchDelay, + showLoadingThreshold, + renderedCount, + onRenderingComplete, + ]); // Cleanup on unmount useEffect(() => {