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,