fix(ai-chat): preserve scroll position during agent message streaming

The transcript force-scrolled to the bottom on every streamed delta because
the auto-scroll effect ran unconditionally whenever the messages array identity
changed. Scrolling up to read earlier messages was impossible — each token
yanked the view back down.

Implement a "stick to bottom" pattern in MessageList:
- track whether the viewport is pinned to the bottom via a scroll listener
  (pinnedToBottomRef, BOTTOM_THRESHOLD = 40px);
- only auto-scroll while pinned; a freshly sent user message always re-pins;
- attach the scroll listener via a [hasScrollArea] dependency so a brand-new
  empty chat (whose ScrollArea mounts only after the first message) wires it up;
- guard the effect's own scrollTop write (programmaticScrollRef) so it is not
  misread as a user scroll.
This commit is contained in:
vvzvlad
2026-06-18 19:54:40 +03:00
parent f96df1c540
commit 9b8ac430b2

View File

@@ -16,6 +16,10 @@ function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
// Distance (px) from the bottom within which the viewport still counts as
// "pinned" — absorbs sub-pixel rounding and small content jitter.
const BOTTOM_THRESHOLD = 40;
/**
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the
* gap between sending and the first streamed content, so it shows only while a
@@ -37,18 +41,68 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole
}
/**
* Scrollable transcript. Auto-scrolls to the newest message as it streams in
* (re-runs whenever the message count, the streaming flag, or the messages array
* identity changes — the latter updates on every streamed delta).
* Scrollable transcript. Auto-scrolls to the newest message as it streams in,
* but only while the user is pinned to the bottom — if they scrolled up to read
* earlier messages, streamed deltas no longer yank them back down.
*/
export default function MessageList({ messages, isStreaming }: MessageListProps) {
const { t } = useTranslation();
const viewportRef = useRef<HTMLDivElement>(null);
// Whether the viewport is currently pinned to the bottom. Starts true so the
// first render scrolls down; flips to false as soon as the user scrolls up,
// which suppresses the streaming auto-scroll until they return to the bottom.
const pinnedToBottomRef = useRef(true);
// Guards the auto-scroll effect's own scrollTop write from being misread as a
// user scroll by the listener below. Armed only when we actually move the
// viewport, so it always pairs with exactly one resulting scroll event.
const programmaticScrollRef = useRef(false);
const typing = showTypingIndicator(messages, isStreaming);
// The ScrollArea is only mounted once there is something to show; track that so
// the scroll listener below re-attaches when the viewport first appears. Without
// this dependency, a brand-new chat that starts empty would never wire up the
// listener (the empty-state branch renders no ScrollArea, so viewportRef is null
// on first mount and the [] effect never re-runs).
const hasScrollArea = messages.length > 0 || typing;
// Track the user's scroll position so streaming updates only follow the newest
// content while the user is at the bottom. Mantine's ScrollArea exposes the
// inner viewport via viewportRef; listen to its scroll events directly.
useEffect(() => {
const el = viewportRef.current;
if (el) el.scrollTop = el.scrollHeight;
if (!el) return;
const onScroll = () => {
// Ignore the single scroll event our own auto-scroll write triggers, so it
// can't be misread as the user leaving the bottom.
if (programmaticScrollRef.current) {
programmaticScrollRef.current = false;
return;
}
const distanceFromBottom =
el.scrollHeight - el.scrollTop - el.clientHeight;
pinnedToBottomRef.current = distanceFromBottom <= BOTTOM_THRESHOLD;
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [hasScrollArea]);
// Auto-scroll to the newest content as it streams in, but ONLY while pinned to
// the bottom. If the user scrolled up to read earlier messages, leave their
// position untouched so streamed deltas don't yank them back down. A freshly
// sent user message always re-pins, so sending always brings the view down.
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const last = messages[messages.length - 1];
if (last?.role === "user") pinnedToBottomRef.current = true;
if (!pinnedToBottomRef.current) return;
const target = el.scrollHeight - el.clientHeight;
// Only write (and arm the guard) when we'd actually move; assigning the same
// value fires no scroll event and would otherwise leave the guard armed and
// swallow the user's next real scroll.
if (el.scrollTop < target) {
programmaticScrollRef.current = true;
el.scrollTop = target;
}
}, [messages.length, isStreaming, messages, typing]);
if (messages.length === 0 && !typing) {