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'; modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null; liveFollowGenerationRef.current = null;
setUserOwnsScroll(true); 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; armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null; pendingAnchorRef.current = null;
positionedAnchorRef.current = null; positionedAnchorRef.current = null;
@@ -230,7 +233,7 @@ export const useChatTimelineScroll = ({
cancelAnimationFrame(anchorRestoreFrameRef.current); cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null; anchorRestoreFrameRef.current = null;
} }
}, []); }, [scheduleShowScrollButton]);
const isLiveFollowActive = React.useCallback(() => ( const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current liveFollowGenerationRef.current === userGenerationRef.current
@@ -357,8 +360,9 @@ export const useChatTimelineScroll = ({
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => { const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
// While an automatic movement owns the viewport, leaving the end is our // While an automatic movement owns the viewport, leaving the end is our
// own doing (the anchored turn parks mid-timeline) — not a reason to // own doing (the anchored turn parks mid-timeline, the glide trails its
// offer the user a scroll-to-bottom pill. // target between corrections) — not a reason to offer the pill. Only a
// real gesture (free-scrolling) shows it.
if (!isAtEnd && isLiveFollowActive()) { if (!isAtEnd && isLiveFollowActive()) {
hideScrollButton(); hideScrollButton();
return; return;