fix(chat): release live follow on middle-button pan, Shift+Space, and nested wheel

Merge main and reshape the follow opt-out to the gestures the timeline was
missing: a middle-button press starts the platform autoscroll pan (the only
scroll gesture on wheel-less mice and tablets with a pointer), Shift+Space
scrolls up from the keyboard, and an upward wheel over a nested scroller that
still has room above stays with that scroller instead of releasing the chat.

The grace re-pin timer and scroll-direction tracking are dropped: returning to
within the end band already re-arms follow, and the mode machine is built
without timers on purpose. Pause/Break never move the viewport and are not
gestures.

Closes #1640
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:05:57 +03:00
283 changed files with 16112 additions and 1816 deletions
@@ -1,41 +0,0 @@
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);
});
});
+80 -199
View File
@@ -13,46 +13,11 @@ import {
type TimelineListMeasurementState,
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;
import {
isFollowReleaseKey,
isMiddleButtonPan,
nestedScrollableConsumesWheelUp,
} from '@/components/chat/lib/scroll/timelineScrollIntent';
// ──────────────────────────────────────────────────────────────────────────
// Chat timeline scroll ownership.
@@ -76,8 +41,8 @@ export const shouldDelayAutoFollowRepin = (
// 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; the only timer here is the short
// grace period for an explicit release near the live edge.
// to tell its own writes apart from the user's, which is why there are no
// guard/settle/entry-stick timers here.
// ──────────────────────────────────────────────────────────────────────────
// The subset of the list ref this hook drives. Declared structurally so the
@@ -141,9 +106,6 @@ 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;
@@ -206,10 +168,6 @@ 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);
@@ -220,20 +178,6 @@ 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);
@@ -265,14 +209,10 @@ 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);
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
@@ -282,7 +222,6 @@ export const useChatTimelineScroll = ({
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
isAtEndRef.current = atEndNow;
if (!atEndNow) {
setIsPinned(false);
cancelShowButtonTimer();
setShowScrollButton(true);
}
@@ -296,12 +235,7 @@ export const useChatTimelineScroll = ({
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
}, [cancelShowButtonTimer, clearDelayedRepin]);
const releaseFromUserIntent = React.useCallback(() => {
onManualNavigation();
lastExplicitReleaseAtRef.current = performance.now();
}, [onManualNavigation]);
}, [cancelShowButtonTimer]);
const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current
@@ -363,11 +297,8 @@ 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.
@@ -390,23 +321,15 @@ export const useChatTimelineScroll = ({
void listRef.current?.scrollToEnd({ animated: false });
}, delay));
}
}, [clearAnchor, clearDelayedRepin, clearGoToBottomReasserts, hideScrollButton]);
}, [clearAnchor, 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;
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]);
// User preference: with auto-follow off, streaming growth never moves the
// viewport. Sending from the live edge still parks the new message at the
// top, but no glide or end-follow correction runs afterwards; sending from
// mid-history leaves the viewport untouched.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// 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.
@@ -418,11 +341,13 @@ export const useChatTimelineScroll = ({
const anchorPositionInstantRef = React.useRef(false);
const scrollToBottomOnSend = React.useCallback(() => {
clearDelayedRepin();
lastExplicitReleaseAtRef.current = null;
// With auto-follow off, a reader who scrolled away from the end stays
// exactly where they are: the sent message is not anchored and the
// scroll-to-bottom pill (already showing) leads to it. From the live
// edge, sending anchors the new turn as usual.
if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return;
anchorPositionInstantRef.current = !isAtEndRef.current;
isAtEndRef.current = true;
userOwnsScrollRef.current = false;
setUserOwnsScroll(false);
modeRef.current = 'anchoring-new-turn';
liveFollowGenerationRef.current = userGenerationRef.current;
@@ -436,7 +361,7 @@ export const useChatTimelineScroll = ({
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
hideScrollButton();
}, [clearDelayedRepin, hideScrollButton]);
}, [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
@@ -459,10 +384,7 @@ 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;
@@ -470,7 +392,7 @@ export const useChatTimelineScroll = ({
hideScrollButton();
void listRef.current?.scrollToEnd({ animated: false });
return false;
}, [clearAnchor, clearDelayedRepin, hideScrollButton]);
}, [clearAnchor, hideScrollButton]);
// ── list callbacks ──────────────────────────────────────────────────────
const registerList = React.useCallback((list: TimelineListHandle | null) => {
@@ -481,9 +403,6 @@ 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
@@ -492,38 +411,6 @@ 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;
if (!shouldRepinReleasedAutoFollow(lastScrollDirectionDownRef.current, atTrueBottom)) {
clearDelayedRepin();
setIsPinned(false);
hideScrollButton();
queueSave();
return;
}
setIsPinned(false);
if (releasedAt !== null) {
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();
goToBottom('instant');
return;
}
if (!isAtEnd) clearDelayedRepin();
if (isAtEndRef.current === isAtEnd) return;
isAtEndRef.current = isAtEnd;
setIsPinned(isAtEnd);
@@ -532,7 +419,6 @@ export const useChatTimelineScroll = ({
modeRef.current = 'following-end';
}
liveFollowGenerationRef.current = userGenerationRef.current;
userOwnsScrollRef.current = false;
setUserOwnsScroll(false);
hideScrollButton();
} else {
@@ -541,7 +427,7 @@ export const useChatTimelineScroll = ({
scheduleShowScrollButton();
}
queueSave();
}, [clearDelayedRepin, goToBottom, hideScrollButton, isLiveFollowActive, queueSave, recordScrollDirection, scheduleRepinAfterGrace, scheduleShowScrollButton]);
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
// Park the anchored row near the top once the list has measured it.
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
@@ -672,13 +558,6 @@ export const useChatTimelineScroll = ({
first: null,
second: null,
});
// User preference: with auto-follow off, streaming growth never moves the
// viewport — the anchored user message still parks at the top on send, but
// no glide or end-follow correction runs afterwards.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// While the list width is resizing, every pinning write fights the
// per-frame row re-measure and the pinned viewport shakes. Corrections
// stand down for the whole resize and the visible content is held by the
@@ -720,6 +599,27 @@ export const useChatTimelineScroll = ({
};
}, [scrollNode]);
// Keep the live edge in view after content growth. Within a viewport of
// the end the remaining distance is glided so a revealed block and the
// scroll read as one motion; further behind, the viewport first jumps to
// one screen above the end and glides only that last screen, so the
// reader is never left staring at a gap several screens tall. Writes go
// to the scroll node directly: routing each chunk through the list's
// scrollToEnd bookkeeping roughly doubled frame production when measured.
// A user gesture interrupts the native smooth scroll on its own, and the
// gesture handler drops live follow so no later correction re-engages.
const followEnd = React.useCallback(() => {
const node = scrollRef.current;
if (!node) return;
const end = node.scrollHeight - node.clientHeight;
const distance = end - node.scrollTop;
if (distance <= 1) return;
if (distance > node.clientHeight) {
node.scrollTop = end - node.clientHeight;
}
node.scrollTo({ top: end, behavior: 'smooth' });
}, []);
const onTimelineDataChange = React.useCallback(() => {
if (widthResizingRef.current) return;
@@ -775,12 +675,18 @@ export const useChatTimelineScroll = ({
}
if (!isLiveFollowActive()) return;
// Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content
// growth on its own — including a tail row growing in place — and
// releases when the user scrolls away. Following the end therefore
// needs no correction here; this handler only serves the
// anchored-turn glide below.
if (modeRef.current === 'following-end') return;
// Following the end is owned here, not left to the list's
// maintainScrollAtEnd. The list's animated maintain is single-flight:
// growth that lands while a glide is still in flight is dropped until
// the next trigger, and its re-pin threshold is a tenth of the
// viewport. In a narrow viewport (the VS Code sidebar) one revealed
// block is several viewports tall, so every block left the reader a
// second behind and multiple screens above the live edge — measured
// at 45% of the stream time spent 500-1600px behind at 420x640.
if (modeRef.current === 'following-end') {
followEnd();
return;
}
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
@@ -829,7 +735,7 @@ export const useChatTimelineScroll = ({
});
});
}, [isLiveFollowActive, scheduleShowScrollButton]);
}, [followEnd, isLiveFollowActive, scheduleShowScrollButton]);
// The streaming tail grows inside one row without changing the entries
// array, so data-change callbacks are silent for the entire stream. The
@@ -848,14 +754,11 @@ export const useChatTimelineScroll = ({
}, [scrollNode]);
// ── gesture opt-out ─────────────────────────────────────────────────────
const releaseFromUserIntentRef = React.useRef(releaseFromUserIntent);
releaseFromUserIntentRef.current = releaseFromUserIntent;
const onManualNavigationRef = React.useRef(onManualNavigation);
onManualNavigationRef.current = onManualNavigation;
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
@@ -871,11 +774,15 @@ export const useChatTimelineScroll = ({
return realContentOverflowsViewport(list);
};
const gesture = () => {
releaseFromUserIntentRef.current();
onManualNavigationRef.current();
};
const handleWheel = (event: WheelEvent) => {
// Scrolling toward the end is not opting out of follow.
if (event.deltaY < 0 && !nestedScrollableCanConsumeUp(scrollNode, event.target) && canScrollUp()) gesture();
// Scrolling toward the end is not opting out of follow, and an
// upward wheel that a nested scroller still consumes never
// reaches the timeline.
if (event.deltaY < 0 && !nestedScrollableConsumesWheelUp(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
@@ -892,48 +799,30 @@ 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 > TOUCH_FINGER_DOWN_THRESHOLD_PX;
if ((draggedUp || !isAtEndRef.current)
&& !nestedScrollableCanConsumeUp(scrollNode, event.target)
&& canScrollUp()) gesture();
const draggedUp = lastY !== null && y > lastY;
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 1) {
if (isMiddleButtonAutoScrollIntent(scrollNode, event) && canScrollUp()) gesture();
// A middle-button pan scrolls without wheel events (and is the
// only scroll gesture for wheel-less mice), so the press is the
// opt-out. Otherwise the scrollbar track is the scroll node
// itself; a tap on a row only breaks follow when the viewport
// already left the end.
if (isMiddleButtonPan(scrollNode, event)) {
if (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 (isAutoFollowReleaseKey(event) && canScrollUp()) gesture();
if (isFollowReleaseKey(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();
}
const state = listRef.current?.getState();
if (modeRef.current === 'free-scrolling' && resolveTimelineIsAtEnd(state) === true) {
onIsAtEndChange(true);
}
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 });
@@ -941,10 +830,8 @@ 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);
@@ -953,12 +840,10 @@ 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);
};
}, [clearDelayedRepin, onIsAtEndChange, queueSave, realContentOverflowsViewport, recordScrollDirection, scrollNode]);
}, [queueSave, realContentOverflowsViewport, scrollNode]);
// ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef<string | null>(null);
@@ -970,16 +855,13 @@ 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, clearDelayedRepin, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
// Suppress the overlay scrollbar thumb while automatic movement owns the
// scroll position, so it does not jump on each correction.
@@ -989,13 +871,12 @@ 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, clearDelayedRepin]);
}, [cancelShowButtonTimer]);
// ── active-turn spy ─────────────────────────────────────────────────────
// Reads turn positions straight from the DOM, so it is unaffected by which
@@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test';
import { isRootScrollTarget, resetRootScroll } from './useRootScrollLock';
type FakeElement = EventTarget & { id: string; scrollTop: number; scrollLeft: number };
const element = (id: string): FakeElement => Object.assign(new EventTarget(), { id, scrollTop: 0, scrollLeft: 0 });
/** Installs a minimal stand-in for `document` for the duration of `run`. */
const withDocument = (setup: { root?: FakeElement }, run: () => void) => {
const fakeDocument = {
documentElement: element('html'),
body: element('body'),
getElementById: (id: string) => (setup.root && setup.root.id === id ? setup.root : null),
};
// The hook only reads documentElement/body/getElementById from `document`;
// this stand-in provides exactly those members for a DOM-less test process.
const hadDocument = 'document' in globalThis;
const previous = hadDocument ? globalThis.document : undefined;
Reflect.set(globalThis, 'document', fakeDocument);
try {
run();
} finally {
if (hadDocument) Reflect.set(globalThis, 'document', previous);
else Reflect.deleteProperty(globalThis, 'document');
}
};
describe('resetRootScroll', () => {
test('snaps every root scroll offset back to zero and reports the reset', () => {
const root = element('root');
withDocument({ root }, () => {
document.documentElement.scrollTop = 48;
document.body.scrollLeft = 12;
root.scrollTop = 200;
expect(resetRootScroll()).toBe(true);
expect(document.documentElement.scrollTop).toBe(0);
expect(document.body.scrollLeft).toBe(0);
expect(root.scrollTop).toBe(0);
});
});
test('reports nothing to do when the root is already at zero', () => {
withDocument({}, () => {
expect(resetRootScroll()).toBe(false);
});
});
});
describe('isRootScrollTarget', () => {
test('recognises the document, html and body as root scroll sources', () => {
withDocument({}, () => {
expect(isRootScrollTarget(document)).toBe(true);
expect(isRootScrollTarget(document.documentElement)).toBe(true);
expect(isRootScrollTarget(document.body)).toBe(true);
});
});
test('ignores scroll events from inner containers', () => {
withDocument({}, () => {
expect(isRootScrollTarget(element('chat-timeline'))).toBe(false);
});
});
});
@@ -0,0 +1,51 @@
import React from 'react';
/**
* The document root (`html`, `body`, `#root`) is `overflow: hidden` and must
* never scroll — every scrollable area lives in a dedicated container. Chromium
* still scrolls hidden-overflow ancestors programmatically, most visibly when
* a textarea caret moves out of view (PageUp/PageDown in the prompt box, or a
* long prompt being typed) and the browser scrolls it into view. Once that
* happens the whole app shifts up, hides the title bar, and nothing the user
* does with the wheel or keyboard can scroll it back.
*
* Snap every root scroll straight back to zero.
*/
const rootScrollTargets = (): HTMLElement[] => {
const targets = [document.documentElement, document.body];
const appRoot = document.getElementById('root');
if (appRoot) targets.push(appRoot);
return targets;
};
export const resetRootScroll = (): boolean => {
let reset = false;
for (const target of rootScrollTargets()) {
if (target.scrollTop !== 0) {
target.scrollTop = 0;
reset = true;
}
if (target.scrollLeft !== 0) {
target.scrollLeft = 0;
reset = true;
}
}
return reset;
};
export const isRootScrollTarget = (target: EventTarget | null): boolean =>
target === document || rootScrollTargets().some((element) => element === target);
export const useRootScrollLock = (): void => {
React.useEffect(() => {
const handleScroll = (event: Event) => {
if (isRootScrollTarget(event.target)) resetRootScroll();
};
// Capture: the root's own scroll events don't bubble to inner listeners,
// and scroll events from inner containers are filtered out above.
document.addEventListener('scroll', handleScroll, { capture: true, passive: true });
resetRootScroll();
return () => document.removeEventListener('scroll', handleScroll, { capture: true });
}, []);
};