diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx index 1c732932..fb9d137e 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -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(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) {