From 6c25b1b1cb1c4a3f5655aff9d509a98a97e173c8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Dec 2025 13:54:32 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20scroll=20to=20bottom=20on?= =?UTF-8?q?=20workspace=20switch=20using=20ResizeObserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous double requestAnimationFrame approach failed when async content (Shiki highlighting, Mermaid diagrams, images) rendered after the scroll completed. Now we observe the inner message container for size changes during the auto-scroll window and re-scroll as needed. Changes: - Add ResizeObserver in useAutoScroll that triggers scroll on growth - Return innerRef callback ref from hook for attaching to content div - Wire up innerRef in AIView's message container _Generated with `mux`_ --- src/browser/components/AIView.tsx | 6 +++- src/browser/hooks/useAutoScroll.ts | 55 +++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index ce6c23c122..34566b370d 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -209,6 +209,7 @@ const AIViewInner: React.FC = ({ // Use auto-scroll hook for scroll management const { contentRef, + innerRef, autoScroll, setAutoScroll, performAutoScroll, @@ -500,7 +501,10 @@ const AIViewInner: React.FC = ({ data-testid="message-window" className="h-full overflow-y-auto p-[15px] leading-[1.5] break-words whitespace-pre-wrap" > -
+
{mergedMessages.length === 0 ? (

No Messages Yet

diff --git a/src/browser/hooks/useAutoScroll.ts b/src/browser/hooks/useAutoScroll.ts index 5ac5c3706a..c1e2c3e836 100644 --- a/src/browser/hooks/useAutoScroll.ts +++ b/src/browser/hooks/useAutoScroll.ts @@ -1,7 +1,14 @@ import { useRef, useState, useCallback } from "react"; /** - * Hook to manage auto-scrolling behavior for a scrollable container + * Hook to manage auto-scrolling behavior for a scrollable container. + * + * Scroll container structure expected: + *
← scroll container (overflow-y: auto) + *
← inner content wrapper (observed for size changes) + * {children} + *
+ *
* * Auto-scroll is enabled when: * - User sends a message @@ -17,10 +24,35 @@ export function useAutoScroll() { const lastUserInteractionRef = useRef(0); // Ref to avoid stale closures in async callbacks - always holds current autoScroll value const autoScrollRef = useRef(true); + // Track the ResizeObserver so we can disconnect it when the element unmounts + const observerRef = useRef(null); // Sync ref with state to ensure callbacks always have latest value autoScrollRef.current = autoScroll; + // Callback ref for the inner content wrapper - sets up ResizeObserver when element mounts. + // ResizeObserver fires when the content size changes (Shiki highlighting, Mermaid, images, etc.), + // allowing us to scroll to bottom even when async content renders after the initial mount. + const innerRef = useCallback((element: HTMLDivElement | null) => { + // Cleanup previous observer if any + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + if (!element) return; + + const observer = new ResizeObserver(() => { + // Only auto-scroll if enabled - user may have scrolled up + if (autoScrollRef.current && contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight; + } + }); + + observer.observe(element); + observerRef.current = observer; + }, []); + const performAutoScroll = useCallback(() => { if (!contentRef.current) return; @@ -38,18 +70,14 @@ export function useAutoScroll() { }, []); // No deps - ref ensures we always check current value const jumpToBottom = useCallback(() => { - if (!contentRef.current) return; - - // Double RAF: First frame for DOM updates (async highlighting, image loads), - // second frame to scroll after layout is complete - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (contentRef.current) { - contentRef.current.scrollTop = contentRef.current.scrollHeight; - } - }); - }); + // Enable auto-scroll first so ResizeObserver will handle subsequent changes setAutoScroll(true); + autoScrollRef.current = true; + + // Immediate scroll for content that's already rendered + if (contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight; + } }, []); const handleScroll = useCallback((e: React.UIEvent) => { @@ -73,9 +101,11 @@ export function useAutoScroll() { if (isScrollingUp) { // Always disable auto-scroll when scrolling up setAutoScroll(false); + autoScrollRef.current = false; } else if (isScrollingDown && isAtBottom) { // Only enable auto-scroll if scrolling down AND reached the bottom setAutoScroll(true); + autoScrollRef.current = true; } // If scrolling down but not at bottom, auto-scroll remains disabled @@ -89,6 +119,7 @@ export function useAutoScroll() { return { contentRef, + innerRef, autoScroll, setAutoScroll, performAutoScroll,