Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
// Use auto-scroll hook for scroll management
const {
contentRef,
innerRef,
autoScroll,
setAutoScroll,
performAutoScroll,
Expand Down Expand Up @@ -500,7 +501,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
data-testid="message-window"
className="h-full overflow-y-auto p-[15px] leading-[1.5] break-words whitespace-pre-wrap"
>
<div className={cn("max-w-4xl mx-auto", mergedMessages.length === 0 && "h-full")}>
<div
ref={innerRef}
className={cn("max-w-4xl mx-auto", mergedMessages.length === 0 && "h-full")}
>
{mergedMessages.length === 0 ? (
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
<h3>No Messages Yet</h3>
Expand Down
55 changes: 43 additions & 12 deletions src/browser/hooks/useAutoScroll.ts
Original file line number Diff line number Diff line change
@@ -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:
* <div ref={contentRef}> ← scroll container (overflow-y: auto)
* <div ref={innerRef}> ← inner content wrapper (observed for size changes)
* {children}
* </div>
* </div>
*
* Auto-scroll is enabled when:
* - User sends a message
Expand All @@ -17,10 +24,35 @@ export function useAutoScroll() {
const lastUserInteractionRef = useRef<number>(0);
// Ref to avoid stale closures in async callbacks - always holds current autoScroll value
const autoScrollRef = useRef<boolean>(true);
// Track the ResizeObserver so we can disconnect it when the element unmounts
const observerRef = useRef<ResizeObserver | null>(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;

Expand All @@ -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<HTMLDivElement>) => {
Expand All @@ -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

Expand All @@ -89,6 +119,7 @@ export function useAutoScroll() {

return {
contentRef,
innerRef,
autoScroll,
setAutoScroll,
performAutoScroll,
Expand Down