fix(chat): own end-follow during streaming instead of the list's animated maintain
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
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user