From 5685e9a6a3cd89ced6e6430cf13c6c321ef684f7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 11:49:52 +0300 Subject: [PATCH] =?UTF-8?q?fix(chat):=20mode-driven=20scroll=20pill=20?= =?UTF-8?q?=E2=80=94=20no=20crossfade=20mush=20with=20the=20status=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/hooks/useChatTimelineScroll.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index e034e688..969197f2 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -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;