fix(chat): preserve explicit scroll release intent

This commit is contained in:
Pascal André
2026-08-27 21:36:00 +02:00
parent 43c4cc625f
commit 9b2d02c3a7
2 changed files with 214 additions and 19 deletions
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test';
import {
isAutoFollowReleaseKey,
shouldDelayAutoFollowRepin,
shouldRepinReleasedAutoFollow,
} from './useChatTimelineScroll';
const keyEvent = (
key: string,
modifiers: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>> = {},
): Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'> => ({
altKey: false,
ctrlKey: false,
key,
metaKey: false,
shiftKey: false,
...modifiers,
});
describe('chat timeline scroll intent', () => {
test('recognizes upward navigation without stealing modified shortcuts', () => {
expect(isAutoFollowReleaseKey(keyEvent('ArrowUp'))).toBe(true);
expect(isAutoFollowReleaseKey(keyEvent('PageUp'))).toBe(true);
expect(isAutoFollowReleaseKey(keyEvent('Home'))).toBe(true);
expect(isAutoFollowReleaseKey(keyEvent(' ', { shiftKey: true }))).toBe(true);
expect(isAutoFollowReleaseKey(keyEvent('Pause'))).toBe(true);
expect(isAutoFollowReleaseKey(keyEvent('Break'))).toBe(true);
expect(isAutoFollowReleaseKey(keyEvent('ArrowUp', { ctrlKey: true }))).toBe(false);
expect(isAutoFollowReleaseKey(keyEvent(' ', { shiftKey: false }))).toBe(false);
});
test('delays re-pinning only for downward or exact-bottom movement', () => {
expect(shouldRepinReleasedAutoFollow(false, false)).toBe(false);
expect(shouldRepinReleasedAutoFollow(true, false)).toBe(true);
expect(shouldRepinReleasedAutoFollow(false, true)).toBe(true);
expect(shouldDelayAutoFollowRepin(null, 100, 1200)).toBe(false);
expect(shouldDelayAutoFollowRepin(100, 500, 1200)).toBe(true);
expect(shouldDelayAutoFollowRepin(100, 1300, 1200)).toBe(false);
});
});
+173 -19
View File
@@ -14,6 +14,46 @@ import {
type TimelineScrollMode,
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
export const isAutoFollowReleaseKey = (
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
): boolean => {
if (event.altKey || event.ctrlKey || event.metaKey) return false;
if (event.key === ' ' && event.shiftKey) return true;
return event.key === 'ArrowUp'
|| event.key === 'PageUp'
|| event.key === 'Home'
|| event.key === 'Pause'
|| event.key === 'Break';
};
const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
const nested = target.closest('[data-scrollable]');
if (!(nested instanceof HTMLElement) || nested === root) return null;
return nested;
};
export const isMiddleButtonAutoScrollIntent = (
root: HTMLElement,
event: Pick<MouseEvent, 'button' | 'target'>,
): boolean => event.button === 1 && !nestedScrollableTarget(root, event.target);
export const shouldRepinReleasedAutoFollow = (
scrollingDown: boolean,
atTrueBottom: boolean,
): boolean => scrollingDown || atTrueBottom;
const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => {
const nested = nestedScrollableTarget(root, target);
return nested !== null && nested.scrollTop > 0;
};
export const shouldDelayAutoFollowRepin = (
releasedAt: number | null,
currentTime: number,
graceMs: number,
): boolean => releasedAt !== null && currentTime - releasedAt < graceMs;
// ──────────────────────────────────────────────────────────────────────────
// Chat timeline scroll ownership.
//
@@ -36,8 +76,8 @@ import {
// gesture bumps a generation counter; any in-flight automatic movement compares
// its captured generation against the current one and aborts if they differ.
// That comparison replaces the timer windows the previous implementation needed
// to tell its own writes apart from the user's, which is why there are no
// guard/settle/entry-stick timers here.
// to tell its own writes apart from the user's; the only timer here is the short
// grace period for an explicit release near the live edge.
// ──────────────────────────────────────────────────────────────────────────
// The subset of the list ref this hook drives. Declared structurally so the
@@ -101,6 +141,9 @@ export interface UseChatTimelineScrollResult {
// Hiding is always immediate.
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
const SAVE_DEBOUNCE_MS = 150;
const TOUCH_FINGER_DOWN_THRESHOLD_PX = 2;
const AUTO_MATCH_TOLERANCE_PX = 2;
const REPIN_GRACE_AFTER_RELEASE_MS = 1200;
// The anchor scroll is animated; `scrollend` is the authoritative completion
// signal, and this bounds the wait for browsers that drop it.
const ANCHOR_SETTLE_FALLBACK_MS = 750;
@@ -163,6 +206,10 @@ export const useChatTimelineScroll = ({
currentSessionIdRef.current = currentSessionId;
const currentSessionKeyRef = React.useRef(currentSessionKey);
currentSessionKeyRef.current = currentSessionKey;
const lastScrollOffsetRef = React.useRef(0);
const lastScrollDirectionDownRef = React.useRef(false);
const delayedRepinTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const lastExplicitReleaseAtRef = React.useRef<number | null>(null);
const updateViewportAnchor = useViewportStore((state) => state.updateViewportAnchor);
@@ -173,6 +220,20 @@ export const useChatTimelineScroll = ({
}
}, []);
const clearDelayedRepin = React.useCallback(() => {
if (delayedRepinTimerRef.current !== null) {
clearTimeout(delayedRepinTimerRef.current);
delayedRepinTimerRef.current = null;
}
}, []);
const recordScrollDirection = React.useCallback((scrollOffset: number) => {
if (scrollOffset === lastScrollOffsetRef.current) return;
const previousOffset = lastScrollOffsetRef.current;
lastScrollOffsetRef.current = scrollOffset;
lastScrollDirectionDownRef.current = scrollOffset > previousOffset + 0.5;
}, []);
const hideScrollButton = React.useCallback(() => {
cancelShowButtonTimer();
setShowScrollButton(false);
@@ -204,9 +265,12 @@ export const useChatTimelineScroll = ({
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
// viewport back to the end — only the anchor machinery is disarmed.
const onManualNavigation = React.useCallback(() => {
clearDelayedRepin();
lastExplicitReleaseAtRef.current = null;
userGenerationRef.current += 1;
modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null;
userOwnsScrollRef.current = true;
setUserOwnsScroll(true);
// The end may already have been left by our own movement, in which
// case no further at-end transition will fire — and while an animated
@@ -217,6 +281,7 @@ export const useChatTimelineScroll = ({
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
isAtEndRef.current = atEndNow;
if (!atEndNow) {
setIsPinned(false);
cancelShowButtonTimer();
setShowScrollButton(true);
}
@@ -230,7 +295,12 @@ export const useChatTimelineScroll = ({
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
}, [cancelShowButtonTimer]);
}, [cancelShowButtonTimer, clearDelayedRepin]);
const releaseFromUserIntent = React.useCallback(() => {
onManualNavigation();
lastExplicitReleaseAtRef.current = performance.now();
}, [onManualNavigation]);
const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current
@@ -292,8 +362,11 @@ export const useChatTimelineScroll = ({
}, []);
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
clearDelayedRepin();
lastExplicitReleaseAtRef.current = null;
isAtEndRef.current = true;
setIsPinned(true);
userOwnsScrollRef.current = false;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
// Returning to the end is an explicit opt back IN to live follow.
@@ -316,7 +389,19 @@ export const useChatTimelineScroll = ({
void listRef.current?.scrollToEnd({ animated: false });
}, delay));
}
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]);
}, [clearAnchor, clearDelayedRepin, clearGoToBottomReasserts, hideScrollButton]);
const scheduleRepinAfterGrace = React.useCallback((delayMs: number) => {
if (delayedRepinTimerRef.current !== null) return;
const generation = userGenerationRef.current;
delayedRepinTimerRef.current = setTimeout(() => {
delayedRepinTimerRef.current = null;
if (userGenerationRef.current !== generation || modeRef.current !== 'free-scrolling') return;
const state = listRef.current?.getState();
if (!state || resolveTimelineIsAtEnd(state) !== true) return;
goToBottom('instant');
}, Math.max(0, delayMs));
}, [goToBottom]);
// Sending arms the anchor. The message id is not known here (the optimistic
// row is created by the store), so the next new user message id claims it.
@@ -328,8 +413,11 @@ export const useChatTimelineScroll = ({
const anchorPositionInstantRef = React.useRef(false);
const scrollToBottomOnSend = React.useCallback(() => {
clearDelayedRepin();
lastExplicitReleaseAtRef.current = null;
anchorPositionInstantRef.current = !isAtEndRef.current;
isAtEndRef.current = true;
userOwnsScrollRef.current = false;
setUserOwnsScroll(false);
modeRef.current = 'anchoring-new-turn';
liveFollowGenerationRef.current = userGenerationRef.current;
@@ -343,7 +431,7 @@ export const useChatTimelineScroll = ({
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
hideScrollButton();
}, [hideScrollButton]);
}, [clearDelayedRepin, hideScrollButton]);
// Claim the anchor as soon as the sent row exists in the timeline. The
// comparison is against the baseline captured when the send armed the
@@ -366,7 +454,10 @@ export const useChatTimelineScroll = ({
// Entering a session always returns to the live edge. Late async growth
// is handled by the list staying at the end, not by a timed hold.
clearDelayedRepin();
lastExplicitReleaseAtRef.current = null;
isAtEndRef.current = true;
userOwnsScrollRef.current = false;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
@@ -374,7 +465,7 @@ export const useChatTimelineScroll = ({
hideScrollButton();
void listRef.current?.scrollToEnd({ animated: false });
return false;
}, [clearAnchor, hideScrollButton]);
}, [clearAnchor, clearDelayedRepin, hideScrollButton]);
// ── list callbacks ──────────────────────────────────────────────────────
const registerList = React.useCallback((list: TimelineListHandle | null) => {
@@ -385,6 +476,9 @@ export const useChatTimelineScroll = ({
}, []);
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
const listState = listRef.current?.getState();
if (listState) recordScrollDirection(listState.scroll);
// While an automatic movement owns the viewport, leaving the end is our
// own doing (the anchored turn parks mid-timeline, the glide trails its
// target between corrections) — not a reason to offer the pill. Only a
@@ -393,6 +487,35 @@ export const useChatTimelineScroll = ({
hideScrollButton();
return;
}
if (isAtEnd && modeRef.current === 'free-scrolling') {
const releasedAt = lastExplicitReleaseAtRef.current;
const scrollNode = listRef.current?.getScrollableNode() ?? scrollRef.current;
const atTrueBottom = scrollNode !== null
&& scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= AUTO_MATCH_TOLERANCE_PX;
isAtEndRef.current = true;
setIsPinned(true);
if (releasedAt !== null) {
if (!shouldRepinReleasedAutoFollow(lastScrollDirectionDownRef.current, atTrueBottom)) {
clearDelayedRepin();
hideScrollButton();
queueSave();
return;
}
const currentTime = performance.now();
if (shouldDelayAutoFollowRepin(releasedAt, currentTime, REPIN_GRACE_AFTER_RELEASE_MS)) {
scheduleRepinAfterGrace(REPIN_GRACE_AFTER_RELEASE_MS - (currentTime - releasedAt));
hideScrollButton();
queueSave();
return;
}
lastExplicitReleaseAtRef.current = null;
}
clearDelayedRepin();
}
if (!isAtEnd) clearDelayedRepin();
if (isAtEndRef.current === isAtEnd) return;
isAtEndRef.current = isAtEnd;
setIsPinned(isAtEnd);
@@ -401,6 +524,7 @@ export const useChatTimelineScroll = ({
modeRef.current = 'following-end';
}
liveFollowGenerationRef.current = userGenerationRef.current;
userOwnsScrollRef.current = false;
setUserOwnsScroll(false);
hideScrollButton();
} else {
@@ -409,7 +533,7 @@ export const useChatTimelineScroll = ({
scheduleShowScrollButton();
}
queueSave();
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
}, [clearDelayedRepin, hideScrollButton, isLiveFollowActive, queueSave, recordScrollDirection, scheduleRepinAfterGrace, scheduleShowScrollButton]);
// Park the anchored row near the top once the list has measured it.
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
@@ -716,11 +840,14 @@ export const useChatTimelineScroll = ({
}, [scrollNode]);
// ── gesture opt-out ─────────────────────────────────────────────────────
const onManualNavigationRef = React.useRef(onManualNavigation);
onManualNavigationRef.current = onManualNavigation;
const releaseFromUserIntentRef = React.useRef(releaseFromUserIntent);
releaseFromUserIntentRef.current = releaseFromUserIntent;
React.useEffect(() => {
if (!scrollNode) return;
const initialScroll = listRef.current?.getState().scroll ?? scrollNode.scrollTop;
lastScrollOffsetRef.current = initialScroll;
lastScrollDirectionDownRef.current = false;
// A gesture is meaningful when the viewport can move up AT ALL:
// either the real rows overflow the viewport, or there is scrolled
@@ -736,11 +863,11 @@ export const useChatTimelineScroll = ({
return realContentOverflowsViewport(list);
};
const gesture = () => {
onManualNavigationRef.current();
releaseFromUserIntentRef.current();
};
const handleWheel = (event: WheelEvent) => {
// Scrolling toward the end is not opting out of follow.
if (event.deltaY < 0 && canScrollUp()) gesture();
if (event.deltaY < 0 && !nestedScrollableCanConsumeUp(scrollNode, event.target) && canScrollUp()) gesture();
};
// Touch mirrors wheel by finger direction, not by having already left
// the end: while a stream keeps re-pinning the viewport, waiting for
@@ -757,25 +884,44 @@ export const useChatTimelineScroll = ({
touchLastY = y;
if (y === null) return;
// A downward finger drags the content up — the touch wheel-up.
const draggedUp = lastY !== null && y > lastY;
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
const draggedUp = lastY !== null && y - lastY > TOUCH_FINGER_DOWN_THRESHOLD_PX;
if ((draggedUp || !isAtEndRef.current)
&& !nestedScrollableCanConsumeUp(scrollNode, event.target)
&& canScrollUp()) gesture();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 1) {
if (isMiddleButtonAutoScrollIntent(scrollNode, event) && canScrollUp()) gesture();
return;
}
// The scrollbar track is the scroll node itself; a tap on a row
// only breaks follow when the viewport already left the end.
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
};
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) {
gesture();
}
if (isAutoFollowReleaseKey(event) && canScrollUp()) gesture();
};
const handleScroll = () => {
const scrollOffset = listRef.current?.getState().scroll ?? scrollNode.scrollTop;
const previousOffset = lastScrollOffsetRef.current;
recordScrollDirection(scrollOffset);
if (scrollOffset !== previousOffset && scrollOffset <= previousOffset + 0.5) {
clearDelayedRepin();
}
queueSave();
};
const handleMouseDown = (event: MouseEvent) => {
if ('PointerEvent' in globalThis) return;
if (isMiddleButtonAutoScrollIntent(scrollNode, event) && canScrollUp()) gesture();
};
const handleOverlayScrollbarPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element) || !target.closest('[data-overlay-scrollbar-thumb]')) return;
if (canScrollUp()) gesture();
};
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
@@ -783,8 +929,10 @@ export const useChatTimelineScroll = ({
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
scrollNode.addEventListener('mousedown', handleMouseDown, { passive: true });
scrollNode.addEventListener('keydown', handleKeyDown);
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('pointerdown', handleOverlayScrollbarPointerDown, true);
return () => {
scrollNode.removeEventListener('wheel', handleWheel);
@@ -793,10 +941,12 @@ export const useChatTimelineScroll = ({
scrollNode.removeEventListener('touchend', handleTouchEnd);
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
scrollNode.removeEventListener('pointerdown', handlePointerDown);
scrollNode.removeEventListener('mousedown', handleMouseDown);
scrollNode.removeEventListener('keydown', handleKeyDown);
scrollNode.removeEventListener('scroll', handleScroll);
window.removeEventListener('pointerdown', handleOverlayScrollbarPointerDown, true);
};
}, [queueSave, realContentOverflowsViewport, scrollNode]);
}, [clearDelayedRepin, queueSave, realContentOverflowsViewport, recordScrollDirection, scrollNode]);
// ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef<string | null>(null);
@@ -808,13 +958,16 @@ export const useChatTimelineScroll = ({
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
// Persist the outgoing session's position before the new one takes over.
flushSave();
clearDelayedRepin();
lastExplicitReleaseAtRef.current = null;
isAtEndRef.current = true;
userOwnsScrollRef.current = false;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
}, [clearAnchor, clearDelayedRepin, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
// Suppress the overlay scrollbar thumb while automatic movement owns the
// scroll position, so it does not jump on each correction.
@@ -824,12 +977,13 @@ export const useChatTimelineScroll = ({
React.useEffect(() => () => {
cancelShowButtonTimer();
clearDelayedRepin();
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
if (frames.second !== null) cancelAnimationFrame(frames.second);
}, [cancelShowButtonTimer]);
}, [cancelShowButtonTimer, clearDelayedRepin]);
// ── active-turn spy ─────────────────────────────────────────────────────
// Reads turn positions straight from the DOM, so it is unaffected by which