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]);
|
}, [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(() => {
|
const onTimelineDataChange = React.useCallback(() => {
|
||||||
if (widthResizingRef.current) return;
|
if (widthResizingRef.current) return;
|
||||||
|
|
||||||
@@ -649,12 +670,18 @@ export const useChatTimelineScroll = ({
|
|||||||
}
|
}
|
||||||
if (!isLiveFollowActive()) return;
|
if (!isLiveFollowActive()) return;
|
||||||
|
|
||||||
// Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content
|
// Following the end is owned here, not left to the list's
|
||||||
// growth on its own — including a tail row growing in place — and
|
// maintainScrollAtEnd. The list's animated maintain is single-flight:
|
||||||
// releases when the user scrolls away. Following the end therefore
|
// growth that lands while a glide is still in flight is dropped until
|
||||||
// needs no correction here; this handler only serves the
|
// the next trigger, and its re-pin threshold is a tenth of the
|
||||||
// anchored-turn glide below.
|
// viewport. In a narrow viewport (the VS Code sidebar) one revealed
|
||||||
if (modeRef.current === 'following-end') return;
|
// 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;
|
const frames = dataChangeFramesRef.current;
|
||||||
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
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
|
// The streaming tail grows inside one row without changing the entries
|
||||||
// array, so data-change callbacks are silent for the entire stream. The
|
// array, so data-change callbacks are silent for the entire stream. The
|
||||||
|
|||||||
Reference in New Issue
Block a user