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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user