|
|
|
@@ -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
|
|
|
|
|