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.
This commit is contained in:
Bohdan Triapitsyn
2026-09-08 19:16:55 +03:00
parent ea392a26cc
commit 66a1cc2112
3 changed files with 9 additions and 18 deletions
@@ -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);
});
@@ -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;
};
@@ -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();