From 66a1cc211222e0bf781e9efc0e0bb8da6e8f1a42 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 8 Sep 2026 18:05:41 +0300 Subject: [PATCH] fix(chat): restore the 40px end-follow threshold The half-viewport band made return-to-end detection too broad. Restore the fixed 40px threshold without changing gesture handling or follow transitions. Testing: 14 focused scroll tests pass; UI type-check passes; UI lint has one unrelated existing warning; dead-code report inspected. Live chat behavior not validated. --- .../lib/scroll/timelineScrollAnchoring.test.ts | 8 ++++---- .../chat/lib/scroll/timelineScrollAnchoring.ts | 15 +++------------ packages/ui/src/hooks/useChatTimelineScroll.ts | 4 ++-- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts index 1377bec2..01670482 100644 --- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts @@ -94,11 +94,11 @@ describe('resolveRealContentEndOffset', () => { }); describe('resolveTimelineIsAtEnd', () => { - test('counts half a viewport from the full content length as the end', () => { + test('uses a 40px band regardless of viewport height', () => { expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true); - expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1100, scrollLength: 600 })).toBe(true); - expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1099, scrollLength: 600 })).toBe(false); - // Tiny viewports keep a 40px floor. + expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1360, scrollLength: 600 })).toBe(true); + expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1359, scrollLength: 600 })).toBe(false); + expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1100, scrollLength: 600 })).toBe(false); expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1900, scrollLength: 60 })).toBe(true); expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1899, scrollLength: 60 })).toBe(false); }); diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts index 1a6ebd2a..cdcdee5a 100644 --- a/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts @@ -61,17 +61,8 @@ export const resolveRealContentEndOffset = ({ return Math.max(0, lastBottom + Math.max(0, footerSize) - visibleLength); }; -// "At the end" for follow purposes is half a viewport. Leaving the end is -// only ever decided by a real gesture, so this band never yanks a reader who -// is still on the end; what it decides is how close to the live edge a reader -// who scrolled away must come back before follow re-arms and the pill hides. -// Half a screen reads as "I am back at the bottom" without having to land on -// the last pixel, and stray row growth or late measurements cannot push a -// pinned reader out of it. Distance is measured against the full content -// length. -const FOLLOW_REARM_MIN_THRESHOLD_PX = 40; -export const resolveFollowRearmThresholdPx = (scrollLength: number): number => - Math.max(FOLLOW_REARM_MIN_THRESHOLD_PX, scrollLength / 2); +// Keep return-to-end detection in a tight band, rather than half a viewport. +export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; export const resolveTimelineIsAtEnd = ( state: { @@ -90,7 +81,7 @@ export const resolveTimelineIsAtEnd = ( && typeof scrollLength === 'number' && Number.isFinite(contentLength) ) { - return contentLength - (scroll + scrollLength) <= resolveFollowRearmThresholdPx(scrollLength); + return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } return state.isNearEnd ?? state.isAtEnd; }; diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index b6f34e22..23703827 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -9,7 +9,7 @@ import { getRowBottom, resolveRealContentEndOffset, resolveTimelineIsAtEnd, - resolveFollowRearmThresholdPx, + TIMELINE_FOLLOW_REARM_THRESHOLD_PX, type TimelineListMeasurementState, type TimelineScrollMode, } from '@/components/chat/lib/scroll/timelineScrollAnchoring'; @@ -496,7 +496,7 @@ export const useChatTimelineScroll = ({ const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null; if (lastBottom !== null) { const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current; - if (lastBottom - visibleBottom > resolveFollowRearmThresholdPx(state.scrollLength)) { + if (lastBottom - visibleBottom > TIMELINE_FOLLOW_REARM_THRESHOLD_PX) { isAtEndRef.current = false; setIsPinned(false); scheduleShowScrollButton();