diff --git a/frontend/src/components/Chat/ChatArea.tsx b/frontend/src/components/Chat/ChatArea.tsx index 5df8b08c..59ff9833 100644 --- a/frontend/src/components/Chat/ChatArea.tsx +++ b/frontend/src/components/Chat/ChatArea.tsx @@ -22,6 +22,8 @@ export function ChatArea() { const navigate = useNavigate(); const listRef = useRef(null); const shouldAutoScroll = useRef(true); + const wasStreaming = useRef(false); + const lastScrollTop = useRef(0); // Check if any data sources are connected const [hasConnectedSources, setHasConnectedSources] = useState(null); @@ -34,15 +36,34 @@ export function ChatArea() { }, []); useEffect(() => { + // Sending a message always pins the view to the bottom, even if the + // user had scrolled up to read earlier messages. + if (streamState.isStreaming && !wasStreaming.current) { + shouldAutoScroll.current = true; + } + wasStreaming.current = streamState.isStreaming; if (shouldAutoScroll.current && listRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight; } - }, [messages, streamState.content]); + }, [messages, streamState.content, streamState.isStreaming]); const handleScroll = () => { if (!listRef.current) return; const { scrollTop, scrollHeight, clientHeight } = listRef.current; - shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100; + const distance = scrollHeight - scrollTop - clientHeight; + const scrolledUp = scrollTop < lastScrollTop.current; + lastScrollTop.current = scrollTop; + if (scrolledUp && distance >= 1) { + // Any upward scroll away from the bottom stops autoscroll immediately, + // so streaming content never fights the user (no jitter). Sub-1px + // upward movement (elastic bounce settling at the bottom) is ignored. + shouldAutoScroll.current = false; + } else if (!scrolledUp) { + // Re-engage when scrolled back to the bottom. < 2 rather than < 1: + // at fractional zoom levels the at-bottom residual can reach 1px, + // which would otherwise leave autoscroll permanently disengaged. + shouldAutoScroll.current = distance < 2; + } }; const isEmpty = messages.length === 0 && !streamState.isStreaming;