fix(chat): preserve explicit scroll release intent
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,46 @@ import {
|
|||||||
type TimelineScrollMode,
|
type TimelineScrollMode,
|
||||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
} 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.
|
// Chat timeline scroll ownership.
|
||||||
//
|
//
|
||||||
@@ -36,8 +76,8 @@ import {
|
|||||||
// gesture bumps a generation counter; any in-flight automatic movement compares
|
// gesture bumps a generation counter; any in-flight automatic movement compares
|
||||||
// its captured generation against the current one and aborts if they differ.
|
// its captured generation against the current one and aborts if they differ.
|
||||||
// That comparison replaces the timer windows the previous implementation needed
|
// 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
|
// to tell its own writes apart from the user's; the only timer here is the short
|
||||||
// guard/settle/entry-stick timers here.
|
// grace period for an explicit release near the live edge.
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// The subset of the list ref this hook drives. Declared structurally so the
|
// The subset of the list ref this hook drives. Declared structurally so the
|
||||||
@@ -101,6 +141,9 @@ export interface UseChatTimelineScrollResult {
|
|||||||
// Hiding is always immediate.
|
// Hiding is always immediate.
|
||||||
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
|
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
|
||||||
const SAVE_DEBOUNCE_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
|
// The anchor scroll is animated; `scrollend` is the authoritative completion
|
||||||
// signal, and this bounds the wait for browsers that drop it.
|
// signal, and this bounds the wait for browsers that drop it.
|
||||||
const ANCHOR_SETTLE_FALLBACK_MS = 750;
|
const ANCHOR_SETTLE_FALLBACK_MS = 750;
|
||||||
@@ -163,6 +206,10 @@ export const useChatTimelineScroll = ({
|
|||||||
currentSessionIdRef.current = currentSessionId;
|
currentSessionIdRef.current = currentSessionId;
|
||||||
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
||||||
currentSessionKeyRef.current = 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);
|
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(() => {
|
const hideScrollButton = React.useCallback(() => {
|
||||||
cancelShowButtonTimer();
|
cancelShowButtonTimer();
|
||||||
setShowScrollButton(false);
|
setShowScrollButton(false);
|
||||||
@@ -204,9 +265,12 @@ export const useChatTimelineScroll = ({
|
|||||||
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
|
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
|
||||||
// viewport back to the end — only the anchor machinery is disarmed.
|
// viewport back to the end — only the anchor machinery is disarmed.
|
||||||
const onManualNavigation = React.useCallback(() => {
|
const onManualNavigation = React.useCallback(() => {
|
||||||
|
clearDelayedRepin();
|
||||||
|
lastExplicitReleaseAtRef.current = null;
|
||||||
userGenerationRef.current += 1;
|
userGenerationRef.current += 1;
|
||||||
modeRef.current = 'free-scrolling';
|
modeRef.current = 'free-scrolling';
|
||||||
liveFollowGenerationRef.current = null;
|
liveFollowGenerationRef.current = null;
|
||||||
|
userOwnsScrollRef.current = true;
|
||||||
setUserOwnsScroll(true);
|
setUserOwnsScroll(true);
|
||||||
// The end may already have been left by our own movement, in which
|
// 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
|
// 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;
|
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
|
||||||
isAtEndRef.current = atEndNow;
|
isAtEndRef.current = atEndNow;
|
||||||
if (!atEndNow) {
|
if (!atEndNow) {
|
||||||
|
setIsPinned(false);
|
||||||
cancelShowButtonTimer();
|
cancelShowButtonTimer();
|
||||||
setShowScrollButton(true);
|
setShowScrollButton(true);
|
||||||
}
|
}
|
||||||
@@ -230,7 +295,12 @@ export const useChatTimelineScroll = ({
|
|||||||
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||||
anchorRestoreFrameRef.current = null;
|
anchorRestoreFrameRef.current = null;
|
||||||
}
|
}
|
||||||
}, [cancelShowButtonTimer]);
|
}, [cancelShowButtonTimer, clearDelayedRepin]);
|
||||||
|
|
||||||
|
const releaseFromUserIntent = React.useCallback(() => {
|
||||||
|
onManualNavigation();
|
||||||
|
lastExplicitReleaseAtRef.current = performance.now();
|
||||||
|
}, [onManualNavigation]);
|
||||||
|
|
||||||
const isLiveFollowActive = React.useCallback(() => (
|
const isLiveFollowActive = React.useCallback(() => (
|
||||||
liveFollowGenerationRef.current === userGenerationRef.current
|
liveFollowGenerationRef.current === userGenerationRef.current
|
||||||
@@ -292,8 +362,11 @@ export const useChatTimelineScroll = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||||
|
clearDelayedRepin();
|
||||||
|
lastExplicitReleaseAtRef.current = null;
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
setIsPinned(true);
|
setIsPinned(true);
|
||||||
|
userOwnsScrollRef.current = false;
|
||||||
setUserOwnsScroll(false);
|
setUserOwnsScroll(false);
|
||||||
modeRef.current = 'following-end';
|
modeRef.current = 'following-end';
|
||||||
// Returning to the end is an explicit opt back IN to live follow.
|
// 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 });
|
void listRef.current?.scrollToEnd({ animated: false });
|
||||||
}, delay));
|
}, 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
|
// 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.
|
// 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 anchorPositionInstantRef = React.useRef(false);
|
||||||
|
|
||||||
const scrollToBottomOnSend = React.useCallback(() => {
|
const scrollToBottomOnSend = React.useCallback(() => {
|
||||||
|
clearDelayedRepin();
|
||||||
|
lastExplicitReleaseAtRef.current = null;
|
||||||
anchorPositionInstantRef.current = !isAtEndRef.current;
|
anchorPositionInstantRef.current = !isAtEndRef.current;
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
|
userOwnsScrollRef.current = false;
|
||||||
setUserOwnsScroll(false);
|
setUserOwnsScroll(false);
|
||||||
modeRef.current = 'anchoring-new-turn';
|
modeRef.current = 'anchoring-new-turn';
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
@@ -343,7 +431,7 @@ export const useChatTimelineScroll = ({
|
|||||||
settledAnchorRef.current = null;
|
settledAnchorRef.current = null;
|
||||||
activeAnchorIndexRef.current = null;
|
activeAnchorIndexRef.current = null;
|
||||||
hideScrollButton();
|
hideScrollButton();
|
||||||
}, [hideScrollButton]);
|
}, [clearDelayedRepin, hideScrollButton]);
|
||||||
|
|
||||||
// Claim the anchor as soon as the sent row exists in the timeline. The
|
// 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
|
// 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
|
// 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.
|
// is handled by the list staying at the end, not by a timed hold.
|
||||||
|
clearDelayedRepin();
|
||||||
|
lastExplicitReleaseAtRef.current = null;
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
|
userOwnsScrollRef.current = false;
|
||||||
setUserOwnsScroll(false);
|
setUserOwnsScroll(false);
|
||||||
modeRef.current = 'following-end';
|
modeRef.current = 'following-end';
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
@@ -374,7 +465,7 @@ export const useChatTimelineScroll = ({
|
|||||||
hideScrollButton();
|
hideScrollButton();
|
||||||
void listRef.current?.scrollToEnd({ animated: false });
|
void listRef.current?.scrollToEnd({ animated: false });
|
||||||
return false;
|
return false;
|
||||||
}, [clearAnchor, hideScrollButton]);
|
}, [clearAnchor, clearDelayedRepin, hideScrollButton]);
|
||||||
|
|
||||||
// ── list callbacks ──────────────────────────────────────────────────────
|
// ── list callbacks ──────────────────────────────────────────────────────
|
||||||
const registerList = React.useCallback((list: TimelineListHandle | null) => {
|
const registerList = React.useCallback((list: TimelineListHandle | null) => {
|
||||||
@@ -385,6 +476,9 @@ export const useChatTimelineScroll = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
|
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
|
// While an automatic movement owns the viewport, leaving the end is our
|
||||||
// own doing (the anchored turn parks mid-timeline, the glide trails its
|
// own doing (the anchored turn parks mid-timeline, the glide trails its
|
||||||
// target between corrections) — not a reason to offer the pill. Only a
|
// target between corrections) — not a reason to offer the pill. Only a
|
||||||
@@ -393,6 +487,35 @@ export const useChatTimelineScroll = ({
|
|||||||
hideScrollButton();
|
hideScrollButton();
|
||||||
return;
|
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;
|
if (isAtEndRef.current === isAtEnd) return;
|
||||||
isAtEndRef.current = isAtEnd;
|
isAtEndRef.current = isAtEnd;
|
||||||
setIsPinned(isAtEnd);
|
setIsPinned(isAtEnd);
|
||||||
@@ -401,6 +524,7 @@ export const useChatTimelineScroll = ({
|
|||||||
modeRef.current = 'following-end';
|
modeRef.current = 'following-end';
|
||||||
}
|
}
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
|
userOwnsScrollRef.current = false;
|
||||||
setUserOwnsScroll(false);
|
setUserOwnsScroll(false);
|
||||||
hideScrollButton();
|
hideScrollButton();
|
||||||
} else {
|
} else {
|
||||||
@@ -409,7 +533,7 @@ export const useChatTimelineScroll = ({
|
|||||||
scheduleShowScrollButton();
|
scheduleShowScrollButton();
|
||||||
}
|
}
|
||||||
queueSave();
|
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.
|
// Park the anchored row near the top once the list has measured it.
|
||||||
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
|
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
|
||||||
@@ -716,11 +840,14 @@ export const useChatTimelineScroll = ({
|
|||||||
}, [scrollNode]);
|
}, [scrollNode]);
|
||||||
|
|
||||||
// ── gesture opt-out ─────────────────────────────────────────────────────
|
// ── gesture opt-out ─────────────────────────────────────────────────────
|
||||||
const onManualNavigationRef = React.useRef(onManualNavigation);
|
const releaseFromUserIntentRef = React.useRef(releaseFromUserIntent);
|
||||||
onManualNavigationRef.current = onManualNavigation;
|
releaseFromUserIntentRef.current = releaseFromUserIntent;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!scrollNode) return;
|
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:
|
// A gesture is meaningful when the viewport can move up AT ALL:
|
||||||
// either the real rows overflow the viewport, or there is scrolled
|
// either the real rows overflow the viewport, or there is scrolled
|
||||||
@@ -736,11 +863,11 @@ export const useChatTimelineScroll = ({
|
|||||||
return realContentOverflowsViewport(list);
|
return realContentOverflowsViewport(list);
|
||||||
};
|
};
|
||||||
const gesture = () => {
|
const gesture = () => {
|
||||||
onManualNavigationRef.current();
|
releaseFromUserIntentRef.current();
|
||||||
};
|
};
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
// Scrolling toward the end is not opting out of follow.
|
// 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
|
// Touch mirrors wheel by finger direction, not by having already left
|
||||||
// the end: while a stream keeps re-pinning the viewport, waiting for
|
// the end: while a stream keeps re-pinning the viewport, waiting for
|
||||||
@@ -757,25 +884,44 @@ export const useChatTimelineScroll = ({
|
|||||||
touchLastY = y;
|
touchLastY = y;
|
||||||
if (y === null) return;
|
if (y === null) return;
|
||||||
// A downward finger drags the content up — the touch wheel-up.
|
// A downward finger drags the content up — the touch wheel-up.
|
||||||
const draggedUp = lastY !== null && y > lastY;
|
const draggedUp = lastY !== null && y - lastY > TOUCH_FINGER_DOWN_THRESHOLD_PX;
|
||||||
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
|
if ((draggedUp || !isAtEndRef.current)
|
||||||
|
&& !nestedScrollableCanConsumeUp(scrollNode, event.target)
|
||||||
|
&& canScrollUp()) gesture();
|
||||||
};
|
};
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
touchLastY = null;
|
touchLastY = null;
|
||||||
};
|
};
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
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
|
// The scrollbar track is the scroll node itself; a tap on a row
|
||||||
// only breaks follow when the viewport already left the end.
|
// only breaks follow when the viewport already left the end.
|
||||||
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
|
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) {
|
if (isAutoFollowReleaseKey(event) && canScrollUp()) gesture();
|
||||||
gesture();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const handleScroll = () => {
|
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();
|
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('wheel', handleWheel, { passive: true });
|
||||||
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
|
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
@@ -783,8 +929,10 @@ export const useChatTimelineScroll = ({
|
|||||||
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
|
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||||
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||||||
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
||||||
|
scrollNode.addEventListener('mousedown', handleMouseDown, { passive: true });
|
||||||
scrollNode.addEventListener('keydown', handleKeyDown);
|
scrollNode.addEventListener('keydown', handleKeyDown);
|
||||||
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('pointerdown', handleOverlayScrollbarPointerDown, true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollNode.removeEventListener('wheel', handleWheel);
|
scrollNode.removeEventListener('wheel', handleWheel);
|
||||||
@@ -793,10 +941,12 @@ export const useChatTimelineScroll = ({
|
|||||||
scrollNode.removeEventListener('touchend', handleTouchEnd);
|
scrollNode.removeEventListener('touchend', handleTouchEnd);
|
||||||
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
|
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
|
||||||
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
scrollNode.removeEventListener('mousedown', handleMouseDown);
|
||||||
scrollNode.removeEventListener('keydown', handleKeyDown);
|
scrollNode.removeEventListener('keydown', handleKeyDown);
|
||||||
scrollNode.removeEventListener('scroll', handleScroll);
|
scrollNode.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('pointerdown', handleOverlayScrollbarPointerDown, true);
|
||||||
};
|
};
|
||||||
}, [queueSave, realContentOverflowsViewport, scrollNode]);
|
}, [clearDelayedRepin, queueSave, realContentOverflowsViewport, recordScrollDirection, scrollNode]);
|
||||||
|
|
||||||
// ── session lifecycle ───────────────────────────────────────────────────
|
// ── session lifecycle ───────────────────────────────────────────────────
|
||||||
const lastSessionKeyRef = React.useRef<string | null>(null);
|
const lastSessionKeyRef = React.useRef<string | null>(null);
|
||||||
@@ -808,13 +958,16 @@ export const useChatTimelineScroll = ({
|
|||||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||||
// Persist the outgoing session's position before the new one takes over.
|
// Persist the outgoing session's position before the new one takes over.
|
||||||
flushSave();
|
flushSave();
|
||||||
|
clearDelayedRepin();
|
||||||
|
lastExplicitReleaseAtRef.current = null;
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
|
userOwnsScrollRef.current = false;
|
||||||
setUserOwnsScroll(false);
|
setUserOwnsScroll(false);
|
||||||
modeRef.current = 'following-end';
|
modeRef.current = 'following-end';
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
clearAnchor();
|
clearAnchor();
|
||||||
hideScrollButton();
|
hideScrollButton();
|
||||||
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
|
}, [clearAnchor, clearDelayedRepin, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
|
||||||
|
|
||||||
// Suppress the overlay scrollbar thumb while automatic movement owns the
|
// Suppress the overlay scrollbar thumb while automatic movement owns the
|
||||||
// scroll position, so it does not jump on each correction.
|
// scroll position, so it does not jump on each correction.
|
||||||
@@ -824,12 +977,13 @@ export const useChatTimelineScroll = ({
|
|||||||
|
|
||||||
React.useEffect(() => () => {
|
React.useEffect(() => () => {
|
||||||
cancelShowButtonTimer();
|
cancelShowButtonTimer();
|
||||||
|
clearDelayedRepin();
|
||||||
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
||||||
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
|
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||||
const frames = dataChangeFramesRef.current;
|
const frames = dataChangeFramesRef.current;
|
||||||
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
||||||
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
||||||
}, [cancelShowButtonTimer]);
|
}, [cancelShowButtonTimer, clearDelayedRepin]);
|
||||||
|
|
||||||
// ── active-turn spy ─────────────────────────────────────────────────────
|
// ── active-turn spy ─────────────────────────────────────────────────────
|
||||||
// Reads turn positions straight from the DOM, so it is unaffected by which
|
// Reads turn positions straight from the DOM, so it is unaffected by which
|
||||||
|
|||||||
Reference in New Issue
Block a user