From 2d8571c6f2ec2c0bb3b01cd5f8e7fcd2c9f64fa3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 23:54:00 +0300 Subject: [PATCH] fix(chat): animate end following only during a live stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The animated maintainScrollAtEnd also ran outside streaming, so opening a historical session glided visibly through the whole conversation as late row measurements corrected the end position, and an in-flight glide could supersede explicit navigation. Corrections are instant unless the session is actively working. Returning to the bottom during a stream also lands on the end as of that moment, and the list's own follow may not have re-armed after the user's earlier gestures — the queued-send jump then fell behind the growing reply. goToBottom now re-asserts the edge a few times (150/400/800ms) until it holds; a new user gesture cancels the window. --- .../ui/src/components/chat/MessageList.tsx | 15 ++++++++---- .../ui/src/hooks/useChatTimelineScroll.ts | 23 ++++++++++++++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 0673e786..4197f067 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1071,10 +1071,17 @@ const TimelineList = React.memo(({ // resize settles. maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased ? false - // Animated: with block-level reveal the content grows in - // paragraph/line steps, and the animated follow turns each - // step into a glide — reveal and scroll read as one motion. - : { animated: true, on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true } }} + // Animated only while the session actively streams: there + // the block-step growth turns each correction into a glide + // and reveal + scroll read as one motion. Outside of a live + // stream — opening a historical session, late measurements — + // corrections must be instant: an animated catch-up scrolls + // visibly through the whole conversation on open, and an + // in-flight glide can supersede explicit navigation. + : { + animated: rowContext.sessionIsWorking, + on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true }, + }} // Prepending older history must not move what the user is // reading. Size restoration applies only during a width // resize — see the observer above. diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index fb732efd..161d9eca 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -281,6 +281,12 @@ export const useChatTimelineScroll = ({ }, [flushSave]); // ── scroll commands ───────────────────────────────────────────────────── + const goToBottomReassertTimersRef = React.useRef>>([]); + const clearGoToBottomReasserts = React.useCallback(() => { + for (const timer of goToBottomReassertTimersRef.current) clearTimeout(timer); + goToBottomReassertTimersRef.current = []; + }, []); + const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => { isAtEndRef.current = true; setIsPinned(true); @@ -291,7 +297,22 @@ export const useChatTimelineScroll = ({ clearAnchor(); hideScrollButton(); void listRef.current?.scrollToEnd({ animated: mode === 'smooth' }); - }, [clearAnchor, hideScrollButton]); + // While a stream is growing the content, a single jump lands on the + // end as of that moment and the list's own follow may not have + // re-armed yet — re-assert a few times until the edge holds, then the + // library follows onward. A new user gesture invalidates the window. + clearGoToBottomReasserts(); + const generation = userGenerationRef.current; + for (const delay of [150, 400, 800]) { + goToBottomReassertTimersRef.current.push(setTimeout(() => { + if (userGenerationRef.current !== generation) return; + if (modeRef.current !== 'following-end') return; + const state = listRef.current?.getState(); + if (state && resolveTimelineIsAtEnd(state) === true) return; + void listRef.current?.scrollToEnd({ animated: false }); + }, delay)); + } + }, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]); // Sending arms the anchor. The message id is not known here (the optimistic // row is created by the store), so the next new user message id claims it.