From aa2d127de4019e366651a0c616ba9d5272c41371 Mon Sep 17 00:00:00 2001 From: goatoush <46036451+goatoush@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:45:12 -0400 Subject: [PATCH] fix(frontend): remove jitter when scrolling up during chat autoscroll (#646) The chat area re-armed autoscroll whenever the user was within 100px of the bottom, so scrolling up during a streaming response fought the incoming content ticks and produced jitter. Autoscroll now disengages on any upward scroll (direction-based, no distance threshold), re-engages when scrolled back within 2px of the bottom (tolerating sub-pixel rounding at fractional zoom levels, where the at-bottom residual can reach 1px), and ignores sub-1px upward movement so macOS elastic-bounce settling does not disengage it. Sending a message pins the view to the bottom even if the user had scrolled up to read earlier messages. --- frontend/src/components/Chat/ChatArea.tsx | 25 +++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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;