fix(chat): mode-driven scroll pill — no crossfade mush with the status row

The animated glide trails its target between corrections, so the
distance-based at-end signal flapped during streaming and the pill and
the status row crossfaded into overlapping half-transparent mush. The
pill now shows only in free-scrolling (a real gesture), which also
keeps the status row's visibility stable; a gesture taken after our own
movement already left the end offers the pill immediately.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 11:49:52 +03:00
parent d48de26070
commit 5685e9a6a3
@@ -220,6 +220,9 @@ export const useChatTimelineScroll = ({
modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null;
setUserOwnsScroll(true);
// The end may already have been left by our own movement, in which
// case no further at-end transition will fire — offer the way back now.
if (!isAtEndRef.current) scheduleShowScrollButton();
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
@@ -230,7 +233,7 @@ export const useChatTimelineScroll = ({
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
}, []);
}, [scheduleShowScrollButton]);
const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current
@@ -357,8 +360,9 @@ export const useChatTimelineScroll = ({
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
// While an automatic movement owns the viewport, leaving the end is our
// own doing (the anchored turn parks mid-timeline) — not a reason to
// offer the user a scroll-to-bottom pill.
// own doing (the anchored turn parks mid-timeline, the glide trails its
// target between corrections) — not a reason to offer the pill. Only a
// real gesture (free-scrolling) shows it.
if (!isAtEnd && isLiveFollowActive()) {
hideScrollButton();
return;