From 9b2d02c3a725f7fc55ffb9a8db9e7d3f9ced02fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 27 Aug 2026 21:36:00 +0200 Subject: [PATCH 1/2] fix(chat): preserve explicit scroll release intent --- .../src/hooks/useChatTimelineScroll.test.ts | 41 ++++ .../ui/src/hooks/useChatTimelineScroll.ts | 192 ++++++++++++++++-- 2 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/hooks/useChatTimelineScroll.test.ts diff --git a/packages/ui/src/hooks/useChatTimelineScroll.test.ts b/packages/ui/src/hooks/useChatTimelineScroll.test.ts new file mode 100644 index 00000000..80f5ee19 --- /dev/null +++ b/packages/ui/src/hooks/useChatTimelineScroll.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; + +import { + isAutoFollowReleaseKey, + shouldDelayAutoFollowRepin, + shouldRepinReleasedAutoFollow, +} from './useChatTimelineScroll'; + +const keyEvent = ( + key: string, + modifiers: Partial> = {}, +): Pick => ({ + 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); + }); +}); diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 46da713f..b82a0a40 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -14,6 +14,46 @@ import { type TimelineScrollMode, } from '@/components/chat/lib/scroll/timelineScrollAnchoring'; +export const isAutoFollowReleaseKey = ( + event: Pick, +): 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, +): 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 | null>(null); + const lastExplicitReleaseAtRef = React.useRef(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(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 From 03a76c9de061dedf6921990e7ae0a20d6e9d00bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 27 Aug 2026 21:51:10 +0200 Subject: [PATCH 2/2] fix(chat): rearm follow after returning to end --- .../ui/src/hooks/useChatTimelineScroll.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index b82a0a40..d3b20c83 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -272,6 +272,7 @@ export const useChatTimelineScroll = ({ liveFollowGenerationRef.current = null; userOwnsScrollRef.current = true; setUserOwnsScroll(true); + setIsPinned(false); // 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 // follow glide trails the live edge, isAtEndRef is deliberately not @@ -399,6 +400,10 @@ export const useChatTimelineScroll = ({ if (userGenerationRef.current !== generation || modeRef.current !== 'free-scrolling') return; const state = listRef.current?.getState(); if (!state || resolveTimelineIsAtEnd(state) !== true) return; + const scrollNode = listRef.current?.getScrollableNode() ?? scrollRef.current; + const atTrueBottom = scrollNode !== null + && scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= AUTO_MATCH_TOLERANCE_PX; + if (!shouldRepinReleasedAutoFollow(lastScrollDirectionDownRef.current, atTrueBottom)) return; goToBottom('instant'); }, Math.max(0, delayMs)); }, [goToBottom]); @@ -495,14 +500,15 @@ export const useChatTimelineScroll = ({ && scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= AUTO_MATCH_TOLERANCE_PX; isAtEndRef.current = true; - setIsPinned(true); + if (!shouldRepinReleasedAutoFollow(lastScrollDirectionDownRef.current, atTrueBottom)) { + clearDelayedRepin(); + setIsPinned(false); + hideScrollButton(); + queueSave(); + return; + } + setIsPinned(false); 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)); @@ -513,6 +519,8 @@ export const useChatTimelineScroll = ({ lastExplicitReleaseAtRef.current = null; } clearDelayedRepin(); + goToBottom('instant'); + return; } if (!isAtEnd) clearDelayedRepin(); @@ -533,7 +541,7 @@ export const useChatTimelineScroll = ({ scheduleShowScrollButton(); } queueSave(); - }, [clearDelayedRepin, hideScrollButton, isLiveFollowActive, queueSave, recordScrollDirection, scheduleRepinAfterGrace, scheduleShowScrollButton]); + }, [clearDelayedRepin, goToBottom, 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) => { @@ -911,6 +919,10 @@ export const useChatTimelineScroll = ({ if (scrollOffset !== previousOffset && scrollOffset <= previousOffset + 0.5) { clearDelayedRepin(); } + const state = listRef.current?.getState(); + if (modeRef.current === 'free-scrolling' && resolveTimelineIsAtEnd(state) === true) { + onIsAtEndChange(true); + } queueSave(); }; const handleMouseDown = (event: MouseEvent) => { @@ -946,7 +958,7 @@ export const useChatTimelineScroll = ({ scrollNode.removeEventListener('scroll', handleScroll); window.removeEventListener('pointerdown', handleOverlayScrollbarPointerDown, true); }; - }, [clearDelayedRepin, queueSave, realContentOverflowsViewport, recordScrollDirection, scrollNode]); + }, [clearDelayedRepin, onIsAtEndChange, queueSave, realContentOverflowsViewport, recordScrollDirection, scrollNode]); // ── session lifecycle ─────────────────────────────────────────────────── const lastSessionKeyRef = React.useRef(null);