From 33783999e538f0eb2f93a3017c1393a316772b1c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 28 Aug 2026 22:33:01 +0300 Subject: [PATCH] fix(chat): own end-follow during streaming instead of the list's animated maintain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list's animated maintainScrollAtEnd is single-flight and re-pins only within a tenth of the viewport, so in a narrow viewport (VS Code sidebar) each revealed block — several screens tall there — left the reader a second behind and multiple screens above the live edge. The timeline hook now follows growth itself: glide when within a viewport of the end, otherwise jump to one screen above the end and glide the rest. Measured at 420x640: time spent >40px behind dropped from 45% to 11%, max distance from 1682px to 382px. Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98 --- .../ui/src/hooks/useChatTimelineScroll.ts | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 91632fb7..9255b1e2 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -594,6 +594,27 @@ export const useChatTimelineScroll = ({ }; }, [scrollNode]); + // Keep the live edge in view after content growth. Within a viewport of + // the end the remaining distance is glided so a revealed block and the + // scroll read as one motion; further behind, the viewport first jumps to + // one screen above the end and glides only that last screen, so the + // reader is never left staring at a gap several screens tall. Writes go + // to the scroll node directly: routing each chunk through the list's + // scrollToEnd bookkeeping roughly doubled frame production when measured. + // A user gesture interrupts the native smooth scroll on its own, and the + // gesture handler drops live follow so no later correction re-engages. + const followEnd = React.useCallback(() => { + const node = scrollRef.current; + if (!node) return; + const end = node.scrollHeight - node.clientHeight; + const distance = end - node.scrollTop; + if (distance <= 1) return; + if (distance > node.clientHeight) { + node.scrollTop = end - node.clientHeight; + } + node.scrollTo({ top: end, behavior: 'smooth' }); + }, []); + const onTimelineDataChange = React.useCallback(() => { if (widthResizingRef.current) return; @@ -649,12 +670,18 @@ export const useChatTimelineScroll = ({ } if (!isLiveFollowActive()) return; - // Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content - // growth on its own — including a tail row growing in place — and - // releases when the user scrolls away. Following the end therefore - // needs no correction here; this handler only serves the - // anchored-turn glide below. - if (modeRef.current === 'following-end') return; + // Following the end is owned here, not left to the list's + // maintainScrollAtEnd. The list's animated maintain is single-flight: + // growth that lands while a glide is still in flight is dropped until + // the next trigger, and its re-pin threshold is a tenth of the + // viewport. In a narrow viewport (the VS Code sidebar) one revealed + // block is several viewports tall, so every block left the reader a + // second behind and multiple screens above the live edge — measured + // at 45% of the stream time spent 500-1600px behind at 420x640. + if (modeRef.current === 'following-end') { + followEnd(); + return; + } const frames = dataChangeFramesRef.current; if (frames.first !== null) cancelAnimationFrame(frames.first); @@ -703,7 +730,7 @@ export const useChatTimelineScroll = ({ }); }); - }, [isLiveFollowActive, scheduleShowScrollButton]); + }, [followEnd, 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