From ba3a604e9ea2cb5048b308e25f0efdfdda4dec83 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 16:49:21 +0300 Subject: [PATCH] fix(chat): offer the scroll pill when growth leaves the reader behind With streaming auto-follow off nothing moves the viewport, so a growing reply slides below the composer without a single scroll event and the at-end transition that shows the pill never fires. Content growth now doubles as the signal: once the measured last row extends past the visible area by the follow re-arm threshold, the end state clears and the pill is scheduled. --- .../ui/src/hooks/useChatTimelineScroll.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 544d02af..46da713f 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -9,6 +9,7 @@ import { getAnchoredTurnMetrics, getRowBottom, resolveTimelineIsAtEnd, + TIMELINE_FOLLOW_REARM_THRESHOLD_PX, type TimelineListMeasurementState, type TimelineScrollMode, } from '@/components/chat/lib/scroll/timelineScrollAnchoring'; @@ -617,7 +618,29 @@ export const useChatTimelineScroll = ({ } } - if (!streamingAutoFollowEnabledRef.current) return; + if (!streamingAutoFollowEnabledRef.current) { + // With auto-follow off nothing moves the viewport, so a growing + // reply slides below the visible area without a single scroll + // event — and the at-end transition that offers the pill never + // fires. Content growth is the signal here: once the real last + // row extends past what the composer leaves visible, the reader + // is factually behind and the pill must say so. + const list = listRef.current; + if (list && isAtEndRef.current) { + const state = list.getState(); + const lastIndex = state.data.length - 1; + const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null; + if (lastBottom !== null) { + const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current; + if (lastBottom - visibleBottom > TIMELINE_FOLLOW_REARM_THRESHOLD_PX) { + isAtEndRef.current = false; + setIsPinned(false); + scheduleShowScrollButton(); + } + } + } + return; + } if (!isLiveFollowActive()) return; // Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content @@ -674,7 +697,7 @@ export const useChatTimelineScroll = ({ }); }); - }, [isLiveFollowActive]); + }, [isLiveFollowActive, scheduleShowScrollButton]); // The streaming tail grows inside one row without changing the entries // array, so data-change callbacks are silent for the entire stream. The