2026-08-04 12:09:37 +03:00
|
|
|
import React from 'react';
|
|
|
|
|
|
|
|
|
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
|
|
|
|
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
|
|
|
|
import { useViewportStore } from '@/sync/viewport-store';
|
2026-08-25 14:53:43 +03:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2026-08-29 21:17:28 +03:00
|
|
|
import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate';
|
2026-08-04 12:09:37 +03:00
|
|
|
import {
|
2026-08-26 16:42:42 +03:00
|
|
|
getRowBottom,
|
2026-08-29 01:51:47 +03:00
|
|
|
resolveRealContentEndOffset,
|
2026-08-25 18:16:49 +03:00
|
|
|
resolveTimelineIsAtEnd,
|
2026-09-07 13:26:30 +03:00
|
|
|
resolveFollowRearmThresholdPx,
|
2026-08-04 12:09:37 +03:00
|
|
|
type TimelineListMeasurementState,
|
|
|
|
|
type TimelineScrollMode,
|
|
|
|
|
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
2026-08-28 23:05:57 +03:00
|
|
|
import {
|
|
|
|
|
isFollowReleaseKey,
|
|
|
|
|
isMiddleButtonPan,
|
|
|
|
|
nestedScrollableConsumesWheelUp,
|
|
|
|
|
} from '@/components/chat/lib/scroll/timelineScrollIntent';
|
2026-08-27 21:36:00 +02:00
|
|
|
|
2026-08-04 12:09:37 +03:00
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Chat timeline scroll ownership.
|
|
|
|
|
//
|
|
|
|
|
// The virtualized list owns the scroll position; this hook only decides which
|
2026-09-07 16:07:45 +03:00
|
|
|
// of two mutually exclusive modes is active and, when a mode calls for it,
|
2026-08-04 12:09:37 +03:00
|
|
|
// issues ONE deterministic scroll command:
|
|
|
|
|
//
|
|
|
|
|
// • `following-end` — pinned to the live edge. The list keeps us there
|
|
|
|
|
// through `maintainScrollAtEnd`; we only re-assert after a data change.
|
|
|
|
|
// • `free-scrolling` — the user took over. Nothing moves until they opt
|
|
|
|
|
// back in by returning to the end.
|
|
|
|
|
//
|
|
|
|
|
// Opting out of automatic movement is driven by REAL gestures (wheel /
|
|
|
|
|
// touchmove / pointerdown), not by inferring intent from scroll positions. Each
|
|
|
|
|
// 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.
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// The subset of the list ref this hook drives. Declared structurally so the
|
|
|
|
|
// hook stays testable without a renderer and does not hard-depend on the list
|
|
|
|
|
// implementation.
|
|
|
|
|
export interface TimelineListHandle {
|
2026-08-25 01:54:52 +03:00
|
|
|
getState: () => TimelineListMeasurementState & {
|
|
|
|
|
readonly scroll: number;
|
|
|
|
|
readonly listen?: (
|
|
|
|
|
listenerType: 'totalSize',
|
|
|
|
|
callback: (value: number) => void,
|
|
|
|
|
) => () => void;
|
|
|
|
|
};
|
2026-08-04 12:09:37 +03:00
|
|
|
getScrollableNode: () => HTMLElement | null;
|
|
|
|
|
scrollToEnd: (options?: { animated?: boolean }) => unknown;
|
|
|
|
|
scrollToOffset: (params: { offset: number; animated?: boolean }) => unknown;
|
|
|
|
|
scrollToIndex: (params: {
|
|
|
|
|
index: number;
|
|
|
|
|
animated?: boolean;
|
|
|
|
|
viewPosition?: number;
|
|
|
|
|
viewOffset?: number;
|
|
|
|
|
}) => unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface UseChatTimelineScrollOptions {
|
|
|
|
|
currentSessionId: string | null;
|
|
|
|
|
currentSessionKey: string | null;
|
|
|
|
|
sessionMessageCount: number;
|
|
|
|
|
composerOverlayHeight: number;
|
2026-08-29 21:17:28 +03:00
|
|
|
// True while the session is producing output. Follow corrections glide
|
|
|
|
|
// only then. Outside a live stream — entering a session, a tab becoming
|
|
|
|
|
// active, rows re-measuring after a switch — the viewport must land on
|
|
|
|
|
// the end instantly: an animated catch-up scrolls visibly through the
|
|
|
|
|
// conversation and gets cut short by the next measurement.
|
|
|
|
|
sessionIsWorking: boolean;
|
|
|
|
|
// Reveal gate of the session being opened. Held until the viewport is
|
|
|
|
|
// pinned to the end, so the session is never shown scrolled to the top.
|
|
|
|
|
revealGate?: TimelineRevealGate | null;
|
2026-08-04 12:09:37 +03:00
|
|
|
onActiveTurnChange?: (turnId: string | null) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 21:17:28 +03:00
|
|
|
|
|
|
|
|
|
2026-08-04 12:09:37 +03:00
|
|
|
export interface UseChatTimelineScrollResult {
|
|
|
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
|
|
|
// The live scroll element, as state, so effects that must re-bind when the
|
|
|
|
|
// list remounts (session switch) can depend on it.
|
|
|
|
|
scrollNode: HTMLDivElement | null;
|
|
|
|
|
isPinned: boolean;
|
|
|
|
|
registerList: (list: TimelineListHandle | null) => void;
|
|
|
|
|
onIsAtEndChange: (isAtEnd: boolean) => void;
|
2026-09-07 13:26:30 +03:00
|
|
|
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
|
2026-08-04 12:09:37 +03:00
|
|
|
onManualNavigation: () => void;
|
|
|
|
|
onTimelineDataChange: () => void;
|
|
|
|
|
showScrollButton: boolean;
|
2026-08-25 12:35:12 +03:00
|
|
|
/** A real gesture took the scroll; flips back on any explicit opt-in. */
|
|
|
|
|
userOwnsScroll: boolean;
|
2026-08-04 12:09:37 +03:00
|
|
|
isFollowingProgrammatically: boolean;
|
|
|
|
|
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
|
|
|
|
scrollToBottomOnSend: () => void;
|
|
|
|
|
saveSnapshotNow: () => void;
|
|
|
|
|
restoreSnapshot: () => Promise<boolean>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Showing the pill is debounced so it does not flash while a thread switch
|
|
|
|
|
// settles (the list reports isAtEnd=false until its initial end-scroll lands).
|
|
|
|
|
// Hiding is always immediate.
|
|
|
|
|
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
|
|
|
|
|
const SAVE_DEBOUNCE_MS = 150;
|
|
|
|
|
|
|
|
|
|
export const useChatTimelineScroll = ({
|
|
|
|
|
currentSessionId,
|
|
|
|
|
currentSessionKey,
|
|
|
|
|
sessionMessageCount,
|
|
|
|
|
composerOverlayHeight,
|
2026-08-29 21:17:28 +03:00
|
|
|
sessionIsWorking,
|
|
|
|
|
revealGate = null,
|
2026-08-04 12:09:37 +03:00
|
|
|
onActiveTurnChange,
|
|
|
|
|
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
|
2026-08-29 21:17:28 +03:00
|
|
|
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
|
|
|
|
|
sessionIsWorkingRef.current = sessionIsWorking;
|
2026-08-04 12:09:37 +03:00
|
|
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const listRef = React.useRef<TimelineListHandle | null>(null);
|
|
|
|
|
|
|
|
|
|
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
|
|
|
|
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
|
|
|
|
// "Pinned" is the live edge, which history pagination uses to decide whether
|
|
|
|
|
// it may load older pages without disturbing the read position.
|
|
|
|
|
const [isPinned, setIsPinned] = React.useState(true);
|
|
|
|
|
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
2026-08-25 01:35:45 +03:00
|
|
|
// True after a real gesture until an explicit opt back in; drives the
|
|
|
|
|
// overlay scrollbar suppression instead of the anchor's mere existence.
|
|
|
|
|
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
|
2026-08-26 16:42:42 +03:00
|
|
|
const userOwnsScrollRef = React.useRef(userOwnsScroll);
|
|
|
|
|
userOwnsScrollRef.current = userOwnsScroll;
|
2026-08-04 12:09:37 +03:00
|
|
|
|
|
|
|
|
const modeRef = React.useRef<TimelineScrollMode>('following-end');
|
|
|
|
|
const isAtEndRef = React.useRef(true);
|
|
|
|
|
// Incremented by every real user gesture. Automatic movement is only valid
|
|
|
|
|
// while `liveFollowGenerationRef` still equals it.
|
|
|
|
|
const userGenerationRef = React.useRef(0);
|
|
|
|
|
const liveFollowGenerationRef = React.useRef<number | null>(0);
|
|
|
|
|
const showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
|
|
|
|
|
composerOverlayHeightRef.current = composerOverlayHeight;
|
2026-09-07 13:26:30 +03:00
|
|
|
// Size of the list footer, reported by the list as it is measured; the
|
|
|
|
|
// real content end sits below the last row by this much.
|
|
|
|
|
const listFooterSizeRef = React.useRef(0);
|
|
|
|
|
const onListMetricsChange = React.useCallback((metrics: { readonly footerSize: number }) => {
|
|
|
|
|
listFooterSizeRef.current = Number.isFinite(metrics.footerSize) ? metrics.footerSize : 0;
|
|
|
|
|
}, []);
|
2026-08-04 12:09:37 +03:00
|
|
|
const sessionMessageCountRef = React.useRef(sessionMessageCount);
|
|
|
|
|
sessionMessageCountRef.current = sessionMessageCount;
|
|
|
|
|
const currentSessionIdRef = React.useRef(currentSessionId);
|
|
|
|
|
currentSessionIdRef.current = currentSessionId;
|
|
|
|
|
const currentSessionKeyRef = React.useRef(currentSessionKey);
|
|
|
|
|
currentSessionKeyRef.current = currentSessionKey;
|
|
|
|
|
|
|
|
|
|
const updateViewportAnchor = useViewportStore((state) => state.updateViewportAnchor);
|
|
|
|
|
|
|
|
|
|
const cancelShowButtonTimer = React.useCallback(() => {
|
|
|
|
|
if (showButtonTimerRef.current !== null) {
|
|
|
|
|
clearTimeout(showButtonTimerRef.current);
|
|
|
|
|
showButtonTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const hideScrollButton = React.useCallback(() => {
|
|
|
|
|
cancelShowButtonTimer();
|
|
|
|
|
setShowScrollButton(false);
|
|
|
|
|
}, [cancelShowButtonTimer]);
|
|
|
|
|
|
|
|
|
|
const scheduleShowScrollButton = React.useCallback(() => {
|
|
|
|
|
if (showButtonTimerRef.current !== null) return;
|
|
|
|
|
showButtonTimerRef.current = setTimeout(() => {
|
|
|
|
|
showButtonTimerRef.current = null;
|
|
|
|
|
setShowScrollButton(true);
|
|
|
|
|
}, SHOW_SCROLL_BUTTON_DELAY_MS);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-25 01:35:45 +03:00
|
|
|
// A real gesture: stop every automatic movement until the user opts back
|
2026-09-07 16:07:45 +03:00
|
|
|
// in.
|
2026-08-04 12:09:37 +03:00
|
|
|
const onManualNavigation = React.useCallback(() => {
|
|
|
|
|
userGenerationRef.current += 1;
|
|
|
|
|
modeRef.current = 'free-scrolling';
|
|
|
|
|
liveFollowGenerationRef.current = null;
|
2026-08-25 01:35:45 +03:00
|
|
|
setUserOwnsScroll(true);
|
2026-08-25 11:49:52 +03:00
|
|
|
// The end may already have been left by our own movement, in which
|
2026-08-25 18:16:49 +03:00
|
|
|
// case no further at-end transition will fire — and while an animated
|
|
|
|
|
// follow glide trails the live edge, isAtEndRef is deliberately not
|
|
|
|
|
// updated, so measure the real distance instead of trusting it. This
|
|
|
|
|
// is an explicit gesture — show the pill immediately, no debounce.
|
|
|
|
|
const listState = listRef.current?.getState();
|
|
|
|
|
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
|
|
|
|
|
isAtEndRef.current = atEndNow;
|
|
|
|
|
if (!atEndNow) {
|
2026-08-25 12:35:12 +03:00
|
|
|
cancelShowButtonTimer();
|
|
|
|
|
setShowScrollButton(true);
|
|
|
|
|
}
|
|
|
|
|
}, [cancelShowButtonTimer]);
|
2026-08-04 12:09:37 +03:00
|
|
|
|
|
|
|
|
const isLiveFollowActive = React.useCallback(() => (
|
|
|
|
|
liveFollowGenerationRef.current === userGenerationRef.current
|
|
|
|
|
), []);
|
|
|
|
|
|
|
|
|
|
// ── snapshot persistence ────────────────────────────────────────────────
|
|
|
|
|
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
|
|
|
|
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
const flushSave = React.useCallback(() => {
|
|
|
|
|
if (saveTimerRef.current !== null) {
|
|
|
|
|
clearTimeout(saveTimerRef.current);
|
|
|
|
|
saveTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
const pending = pendingSaveRef.current;
|
|
|
|
|
if (!pending) return;
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) {
|
|
|
|
|
pendingSaveRef.current = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
updateViewportAnchor(pending.sessionId, pending.anchor, {
|
|
|
|
|
scrollTop: container.scrollTop,
|
|
|
|
|
scrollHeight: container.scrollHeight,
|
|
|
|
|
clientHeight: container.clientHeight,
|
|
|
|
|
});
|
|
|
|
|
pendingSaveRef.current = null;
|
|
|
|
|
}, [updateViewportAnchor]);
|
|
|
|
|
|
|
|
|
|
const queueSave = React.useCallback(() => {
|
|
|
|
|
const sessionId = currentSessionIdRef.current;
|
|
|
|
|
if (!sessionId) return;
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
|
|
|
const anchorRatio = scrollHeight > 0
|
|
|
|
|
? (scrollTop + clientHeight / 2) / scrollHeight
|
|
|
|
|
: 0;
|
|
|
|
|
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
|
|
|
|
|
|
|
|
|
|
pendingSaveRef.current = { sessionId, anchor };
|
|
|
|
|
if (saveTimerRef.current !== null) return;
|
|
|
|
|
saveTimerRef.current = setTimeout(() => {
|
|
|
|
|
saveTimerRef.current = null;
|
|
|
|
|
flushSave();
|
|
|
|
|
}, SAVE_DEBOUNCE_MS);
|
|
|
|
|
}, [flushSave]);
|
|
|
|
|
|
|
|
|
|
const saveSnapshotNow = React.useCallback(() => {
|
|
|
|
|
flushSave();
|
|
|
|
|
}, [flushSave]);
|
|
|
|
|
|
|
|
|
|
// ── scroll commands ─────────────────────────────────────────────────────
|
2026-08-25 23:54:00 +03:00
|
|
|
const goToBottomReassertTimersRef = React.useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
|
|
|
|
const clearGoToBottomReasserts = React.useCallback(() => {
|
|
|
|
|
for (const timer of goToBottomReassertTimersRef.current) clearTimeout(timer);
|
|
|
|
|
goToBottomReassertTimersRef.current = [];
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-04 12:09:37 +03:00
|
|
|
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
|
|
|
|
isAtEndRef.current = true;
|
|
|
|
|
setIsPinned(true);
|
2026-08-25 01:35:45 +03:00
|
|
|
setUserOwnsScroll(false);
|
2026-08-04 12:09:37 +03:00
|
|
|
modeRef.current = 'following-end';
|
|
|
|
|
// Returning to the end is an explicit opt back IN to live follow.
|
|
|
|
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
|
|
|
|
hideScrollButton();
|
|
|
|
|
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
|
2026-08-25 23:54:00 +03:00
|
|
|
// While a stream is growing the content, a single jump lands on the
|
|
|
|
|
// end as of that moment and the list's own follow may not have
|
|
|
|
|
// re-armed yet — re-assert a few times until the edge holds, then the
|
|
|
|
|
// library follows onward. A new user gesture invalidates the window.
|
|
|
|
|
clearGoToBottomReasserts();
|
|
|
|
|
const generation = userGenerationRef.current;
|
|
|
|
|
for (const delay of [150, 400, 800]) {
|
|
|
|
|
goToBottomReassertTimersRef.current.push(setTimeout(() => {
|
|
|
|
|
if (userGenerationRef.current !== generation) return;
|
|
|
|
|
if (modeRef.current !== 'following-end') return;
|
|
|
|
|
const state = listRef.current?.getState();
|
|
|
|
|
if (state && resolveTimelineIsAtEnd(state) === true) return;
|
|
|
|
|
void listRef.current?.scrollToEnd({ animated: false });
|
|
|
|
|
}, delay));
|
|
|
|
|
}
|
2026-09-07 16:07:45 +03:00
|
|
|
}, [clearGoToBottomReasserts, hideScrollButton]);
|
2026-08-27 21:36:00 +02:00
|
|
|
|
2026-08-28 19:39:05 +03:00
|
|
|
// User preference: with auto-follow off, streaming growth never moves the
|
2026-09-07 16:07:45 +03:00
|
|
|
// viewport. Sending from the live edge still lands on the end; sending
|
|
|
|
|
// from mid-history leaves the viewport untouched.
|
2026-08-28 19:39:05 +03:00
|
|
|
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
|
|
|
|
|
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
|
|
|
|
|
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
|
2026-08-04 12:09:37 +03:00
|
|
|
|
2026-09-07 16:07:45 +03:00
|
|
|
// Sending is an explicit return to the live edge: the sent row and the
|
|
|
|
|
// reply that follows it stay in view through ordinary end-follow.
|
2026-08-04 12:09:37 +03:00
|
|
|
const scrollToBottomOnSend = React.useCallback(() => {
|
2026-08-28 19:39:05 +03:00
|
|
|
// With auto-follow off, a reader who scrolled away from the end stays
|
2026-09-07 16:07:45 +03:00
|
|
|
// exactly where they are; the scroll-to-bottom pill (already showing)
|
|
|
|
|
// leads to the sent message.
|
2026-08-28 19:39:05 +03:00
|
|
|
if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return;
|
2026-09-07 16:07:45 +03:00
|
|
|
goToBottom('instant');
|
|
|
|
|
}, [goToBottom]);
|
2026-08-04 12:09:37 +03:00
|
|
|
|
|
|
|
|
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
|
|
|
|
|
const sessionKey = currentSessionKeyRef.current;
|
|
|
|
|
if (!sessionKey) return false;
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
isAtEndRef.current = true;
|
2026-08-25 01:35:45 +03:00
|
|
|
setUserOwnsScroll(false);
|
2026-08-04 12:09:37 +03:00
|
|
|
modeRef.current = 'following-end';
|
|
|
|
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
|
|
|
|
hideScrollButton();
|
|
|
|
|
void listRef.current?.scrollToEnd({ animated: false });
|
|
|
|
|
return false;
|
2026-09-07 16:07:45 +03:00
|
|
|
}, [hideScrollButton]);
|
2026-08-04 12:09:37 +03:00
|
|
|
|
|
|
|
|
// ── list callbacks ──────────────────────────────────────────────────────
|
|
|
|
|
const registerList = React.useCallback((list: TimelineListHandle | null) => {
|
|
|
|
|
listRef.current = list;
|
|
|
|
|
const node = (list?.getScrollableNode() as HTMLDivElement | null) ?? null;
|
|
|
|
|
scrollRef.current = node;
|
|
|
|
|
setScrollNode(node);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
|
|
|
|
|
// While an automatic movement owns the viewport, leaving the end is our
|
2026-09-07 16:07:45 +03:00
|
|
|
// own doing (the glide trails its target between corrections) — not a
|
|
|
|
|
// reason to offer the pill. Only a
|
2026-08-25 11:49:52 +03:00
|
|
|
// real gesture (free-scrolling) shows it.
|
2026-08-04 12:09:37 +03:00
|
|
|
if (!isAtEnd && isLiveFollowActive()) {
|
|
|
|
|
hideScrollButton();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (isAtEndRef.current === isAtEnd) return;
|
|
|
|
|
isAtEndRef.current = isAtEnd;
|
|
|
|
|
setIsPinned(isAtEnd);
|
|
|
|
|
if (isAtEnd) {
|
2026-09-07 16:07:45 +03:00
|
|
|
modeRef.current = 'following-end';
|
2026-08-04 12:09:37 +03:00
|
|
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
2026-08-25 01:35:45 +03:00
|
|
|
setUserOwnsScroll(false);
|
2026-08-04 12:09:37 +03:00
|
|
|
hideScrollButton();
|
|
|
|
|
} else {
|
|
|
|
|
modeRef.current = 'free-scrolling';
|
|
|
|
|
liveFollowGenerationRef.current = null;
|
|
|
|
|
scheduleShowScrollButton();
|
|
|
|
|
}
|
|
|
|
|
queueSave();
|
|
|
|
|
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
|
|
|
|
|
|
2026-09-07 16:07:45 +03:00
|
|
|
// Whether the real rows are tall enough to scroll at all.
|
2026-08-04 12:09:37 +03:00
|
|
|
const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => {
|
|
|
|
|
const state = list.getState();
|
|
|
|
|
if (state.data.length === 0) return false;
|
|
|
|
|
|
|
|
|
|
const lastIndex = state.data.length - 1;
|
|
|
|
|
const lastTop = state.positionAtIndex(lastIndex);
|
|
|
|
|
const lastHeight = state.sizeAtIndex(lastIndex);
|
|
|
|
|
if (
|
|
|
|
|
typeof lastTop !== 'number'
|
|
|
|
|
|| typeof lastHeight !== 'number'
|
|
|
|
|
|| !Number.isFinite(lastTop)
|
|
|
|
|
|| !Number.isFinite(lastHeight)
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const realContentBottom = lastTop + Math.max(1, lastHeight);
|
2026-09-07 16:07:45 +03:00
|
|
|
const visibleScrollLength = Math.max(0, state.scrollLength - composerOverlayHeightRef.current);
|
2026-08-04 12:09:37 +03:00
|
|
|
return realContentBottom > visibleScrollLength;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-09-07 13:26:30 +03:00
|
|
|
// While the list width is resizing every row re-wraps, and the list's
|
|
|
|
|
// total content length lags a frame behind the rows it contains: it
|
|
|
|
|
// still carries pre-wrap row sizes, so any end computed from it (the
|
|
|
|
|
// list's own maintainScrollAtEnd, the scroll node's scrollHeight) lands
|
|
|
|
|
// on a blank tail or short of the real end and the viewport bounces.
|
|
|
|
|
// A pinned reader — streaming or idle — stays on the end throughout: the
|
|
|
|
|
// pinned-end observer below re-asserts the MEASURED end of the last real
|
|
|
|
|
// row on every layout write, and once the resize settles the end is
|
|
|
|
|
// asserted one last time against the same measurement. An unpinned
|
|
|
|
|
// reader is held in place by the list's size compensation instead and
|
|
|
|
|
// is never scrolled.
|
2026-08-25 15:54:39 +03:00
|
|
|
const widthResizingRef = React.useRef(false);
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
|
|
|
|
|
let lastWidth: number | null = null;
|
|
|
|
|
let quietTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
const observer = new ResizeObserver((observerEntries) => {
|
|
|
|
|
const width = observerEntries[observerEntries.length - 1]?.contentRect.width;
|
|
|
|
|
if (typeof width !== 'number') return;
|
|
|
|
|
if (lastWidth === null) {
|
|
|
|
|
lastWidth = width;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (Math.abs(width - lastWidth) < 1) return;
|
|
|
|
|
lastWidth = width;
|
|
|
|
|
widthResizingRef.current = true;
|
|
|
|
|
if (quietTimer !== null) clearTimeout(quietTimer);
|
|
|
|
|
quietTimer = setTimeout(() => {
|
|
|
|
|
quietTimer = null;
|
|
|
|
|
widthResizingRef.current = false;
|
2026-09-07 16:07:45 +03:00
|
|
|
if (!isAtEndRef.current) return;
|
2026-09-07 13:26:30 +03:00
|
|
|
if (userOwnsScrollRef.current || modeRef.current !== 'following-end') return;
|
|
|
|
|
const list = listRef.current;
|
|
|
|
|
const state = list?.getState();
|
|
|
|
|
const offset = state
|
|
|
|
|
? resolveRealContentEndOffset({
|
|
|
|
|
state,
|
|
|
|
|
composerOverlayHeight: composerOverlayHeightRef.current,
|
|
|
|
|
footerSize: listFooterSizeRef.current,
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
if (list && offset !== null) {
|
|
|
|
|
void list.scrollToOffset({ offset, animated: false });
|
|
|
|
|
} else {
|
|
|
|
|
void list?.scrollToEnd({ animated: false });
|
2026-08-26 16:42:42 +03:00
|
|
|
}
|
2026-08-25 15:54:39 +03:00
|
|
|
}, 350);
|
|
|
|
|
});
|
|
|
|
|
observer.observe(scrollNode);
|
|
|
|
|
return () => {
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
if (quietTimer !== null) clearTimeout(quietTimer);
|
|
|
|
|
};
|
2026-09-07 13:26:30 +03:00
|
|
|
}, [scrollNode]);
|
2026-08-25 15:54:39 +03:00
|
|
|
|
2026-08-28 22:33:01 +03:00
|
|
|
// 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;
|
2026-08-29 21:17:28 +03:00
|
|
|
if (!sessionIsWorkingRef.current) {
|
|
|
|
|
node.scrollTop = end;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-28 22:33:01 +03:00
|
|
|
if (distance > node.clientHeight) {
|
|
|
|
|
node.scrollTop = end - node.clientHeight;
|
|
|
|
|
}
|
|
|
|
|
node.scrollTo({ top: end, behavior: 'smooth' });
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-04 12:09:37 +03:00
|
|
|
const onTimelineDataChange = React.useCallback(() => {
|
2026-08-25 15:54:39 +03:00
|
|
|
if (widthResizingRef.current) return;
|
2026-08-26 16:42:42 +03:00
|
|
|
|
|
|
|
|
// Stranded-viewport rescue, independent of any follow mode or
|
|
|
|
|
// preference: when off-screen size estimates settle smaller than
|
|
|
|
|
// estimated, the measured content can end ABOVE the viewport while
|
|
|
|
|
// the scroll offset stays at the stale end — the reader faces a blank
|
|
|
|
|
// phantom tail with every row out of reach above. That state is never
|
|
|
|
|
// intentional, so it is corrected even when auto-follow is off. Only
|
|
|
|
|
// a fully blank viewport qualifies; partial visibility is left alone.
|
|
|
|
|
if (!userOwnsScrollRef.current) {
|
|
|
|
|
const list = listRef.current;
|
|
|
|
|
if (list) {
|
|
|
|
|
const state = list.getState();
|
|
|
|
|
const lastIndex = state.data.length - 1;
|
|
|
|
|
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
|
|
|
|
if (lastBottom !== null && state.scroll > lastBottom) {
|
2026-08-29 01:51:47 +03:00
|
|
|
const offset = resolveRealContentEndOffset({
|
|
|
|
|
state,
|
|
|
|
|
composerOverlayHeight: composerOverlayHeightRef.current,
|
2026-09-07 13:26:30 +03:00
|
|
|
footerSize: listFooterSizeRef.current,
|
2026-08-26 16:42:42 +03:00
|
|
|
});
|
2026-08-29 01:51:47 +03:00
|
|
|
if (offset !== null) {
|
|
|
|
|
void list.scrollToOffset({ offset, animated: false });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-26 16:42:42 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 16:49:21 +03:00
|
|
|
if (!streamingAutoFollowEnabledRef.current) {
|
|
|
|
|
// With auto-follow off nothing moves the viewport, so a growing
|
|
|
|
|
// reply slides below the visible area without a single scroll
|
|
|
|
|
// event — and the at-end transition that offers the pill never
|
|
|
|
|
// fires. Content growth is the signal here: once the real last
|
|
|
|
|
// row extends past what the composer leaves visible, the reader
|
|
|
|
|
// is factually behind and the pill must say so.
|
|
|
|
|
const list = listRef.current;
|
|
|
|
|
if (list && isAtEndRef.current) {
|
|
|
|
|
const state = list.getState();
|
|
|
|
|
const lastIndex = state.data.length - 1;
|
|
|
|
|
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
|
|
|
|
|
if (lastBottom !== null) {
|
|
|
|
|
const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current;
|
2026-09-07 13:26:30 +03:00
|
|
|
if (lastBottom - visibleBottom > resolveFollowRearmThresholdPx(state.scrollLength)) {
|
2026-08-26 16:49:21 +03:00
|
|
|
isAtEndRef.current = false;
|
|
|
|
|
setIsPinned(false);
|
|
|
|
|
scheduleShowScrollButton();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-04 12:09:37 +03:00
|
|
|
if (!isLiveFollowActive()) return;
|
|
|
|
|
|
2026-08-28 22:33:01 +03:00
|
|
|
// 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.
|
2026-09-07 16:07:45 +03:00
|
|
|
if (modeRef.current !== 'following-end') return;
|
|
|
|
|
followEnd();
|
2026-08-28 22:33:01 +03:00
|
|
|
}, [followEnd, isLiveFollowActive, scheduleShowScrollButton]);
|
2026-08-04 12:09:37 +03:00
|
|
|
|
2026-08-25 01:54:52 +03:00
|
|
|
// The streaming tail grows inside one row without changing the entries
|
|
|
|
|
// array, so data-change callbacks are silent for the entire stream. The
|
|
|
|
|
// list's total content size is the authoritative growth signal; every
|
|
|
|
|
// change re-runs the same guarded correction.
|
|
|
|
|
const onTimelineDataChangeRef = React.useRef(onTimelineDataChange);
|
|
|
|
|
onTimelineDataChangeRef.current = onTimelineDataChange;
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!scrollNode) return;
|
|
|
|
|
const listen = listRef.current?.getState().listen;
|
|
|
|
|
if (!listen) return;
|
|
|
|
|
const unsubscribe = listen('totalSize', () => {
|
|
|
|
|
onTimelineDataChangeRef.current();
|
|
|
|
|
});
|
|
|
|
|
return unsubscribe;
|
|
|
|
|
}, [scrollNode]);
|
|
|
|
|
|
2026-08-04 12:09:37 +03:00
|
|
|
// ── gesture opt-out ─────────────────────────────────────────────────────
|
|
|
|
|
const onManualNavigationRef = React.useRef(onManualNavigation);
|
|
|
|
|
onManualNavigationRef.current = onManualNavigation;
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!scrollNode) return;
|
|
|
|
|
|
2026-08-25 12:35:12 +03:00
|
|
|
// A gesture is meaningful when the viewport can move up AT ALL:
|
|
|
|
|
// either the real rows overflow the viewport, or there is scrolled
|
2026-09-07 16:07:45 +03:00
|
|
|
// history above.
|
2026-08-25 12:35:12 +03:00
|
|
|
const canScrollUp = () => {
|
2026-08-25 01:35:45 +03:00
|
|
|
const list = listRef.current;
|
2026-08-25 12:35:12 +03:00
|
|
|
if (!list) return false;
|
|
|
|
|
if (list.getState().scroll > 1) return true;
|
|
|
|
|
return realContentOverflowsViewport(list);
|
2026-08-25 01:35:45 +03:00
|
|
|
};
|
|
|
|
|
const gesture = () => {
|
2026-08-04 12:09:37 +03:00
|
|
|
onManualNavigationRef.current();
|
|
|
|
|
};
|
2026-08-25 01:35:45 +03:00
|
|
|
const handleWheel = (event: WheelEvent) => {
|
2026-08-28 23:05:57 +03:00
|
|
|
// 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();
|
|
|
|
|
}
|
2026-08-25 01:35:45 +03:00
|
|
|
};
|
2026-08-25 15:06:31 +03:00
|
|
|
// Touch mirrors wheel by finger direction, not by having already left
|
|
|
|
|
// the end: while a stream keeps re-pinning the viewport, waiting for
|
|
|
|
|
// an at-end transition means the drag never registers — the user
|
|
|
|
|
// cannot scroll, the pill never appears, and live-follow stays armed
|
|
|
|
|
// under a viewport they are fighting for.
|
|
|
|
|
let touchLastY: number | null = null;
|
|
|
|
|
const handleTouchStart = (event: TouchEvent) => {
|
|
|
|
|
touchLastY = event.touches[0]?.clientY ?? null;
|
|
|
|
|
};
|
|
|
|
|
const handleTouchMove = (event: TouchEvent) => {
|
|
|
|
|
const y = event.touches[0]?.clientY ?? null;
|
|
|
|
|
const lastY = touchLastY;
|
|
|
|
|
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 handleTouchEnd = () => {
|
|
|
|
|
touchLastY = null;
|
2026-08-25 01:35:45 +03:00
|
|
|
};
|
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
2026-08-28 23:05:57 +03:00
|
|
|
// 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();
|
2026-08-27 21:36:00 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-08-25 12:35:12 +03:00
|
|
|
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
|
2026-08-25 01:35:45 +03:00
|
|
|
};
|
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
2026-08-28 23:05:57 +03:00
|
|
|
if (isFollowReleaseKey(event) && canScrollUp()) gesture();
|
2026-08-25 01:35:45 +03:00
|
|
|
};
|
2026-08-04 12:09:37 +03:00
|
|
|
const handleScroll = () => {
|
|
|
|
|
queueSave();
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-25 01:35:45 +03:00
|
|
|
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
|
2026-08-25 15:06:31 +03:00
|
|
|
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
|
2026-08-25 01:35:45 +03:00
|
|
|
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
|
2026-08-25 15:06:31 +03:00
|
|
|
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
|
|
|
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
2026-08-25 01:35:45 +03:00
|
|
|
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
|
|
|
|
scrollNode.addEventListener('keydown', handleKeyDown);
|
2026-08-04 12:09:37 +03:00
|
|
|
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-08-25 01:35:45 +03:00
|
|
|
scrollNode.removeEventListener('wheel', handleWheel);
|
2026-08-25 15:06:31 +03:00
|
|
|
scrollNode.removeEventListener('touchstart', handleTouchStart);
|
2026-08-25 01:35:45 +03:00
|
|
|
scrollNode.removeEventListener('touchmove', handleTouchMove);
|
2026-08-25 15:06:31 +03:00
|
|
|
scrollNode.removeEventListener('touchend', handleTouchEnd);
|
|
|
|
|
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
|
2026-08-25 01:35:45 +03:00
|
|
|
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
|
|
|
|
scrollNode.removeEventListener('keydown', handleKeyDown);
|
2026-08-04 12:09:37 +03:00
|
|
|
scrollNode.removeEventListener('scroll', handleScroll);
|
|
|
|
|
};
|
2026-08-25 01:35:45 +03:00
|
|
|
}, [queueSave, realContentOverflowsViewport, scrollNode]);
|
2026-08-04 12:09:37 +03:00
|
|
|
|
2026-08-29 21:17:28 +03:00
|
|
|
// ── entry pin ───────────────────────────────────────────────────────────
|
|
|
|
|
// An opened session is shown once, already at its end: the reveal gate is
|
|
|
|
|
// held until the viewport sits on the end, and the pin is one instant
|
|
|
|
|
// write. The list lays its rows out before the first frame, so this
|
|
|
|
|
// resolves within a frame; the gate's own cap bounds the wait.
|
|
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
if (!currentSessionKey || !scrollNode) return;
|
|
|
|
|
const releaseReveal = revealGate?.hold() ?? null;
|
|
|
|
|
let frame: number | null = null;
|
|
|
|
|
const settle = () => {
|
|
|
|
|
frame = null;
|
|
|
|
|
if (!userOwnsScrollRef.current && modeRef.current === 'following-end') {
|
|
|
|
|
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
|
|
|
|
|
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
|
|
|
|
|
}
|
|
|
|
|
releaseReveal?.();
|
|
|
|
|
};
|
|
|
|
|
frame = requestAnimationFrame(settle);
|
|
|
|
|
return () => {
|
|
|
|
|
if (frame !== null) cancelAnimationFrame(frame);
|
|
|
|
|
releaseReveal?.();
|
|
|
|
|
};
|
|
|
|
|
}, [currentSessionKey, revealGate, scrollNode]);
|
|
|
|
|
|
|
|
|
|
// ── pinned end ──────────────────────────────────────────────────────────
|
|
|
|
|
// "At the end" is an invariant, not a one-time scroll: while the reader
|
|
|
|
|
// sits on the end of a session that is not producing output, any growth
|
|
|
|
|
// of the content (a footer that decides to render, a row re-measured)
|
|
|
|
|
// keeps the end in view with one instant write. Output growth belongs to
|
2026-09-07 13:26:30 +03:00
|
|
|
// followEnd, which glides. A width resize is the one case handled for a
|
|
|
|
|
// streaming reader as well — see the resize observer above.
|
2026-08-29 21:17:28 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!scrollNode || typeof MutationObserver === 'undefined') return;
|
|
|
|
|
const content = scrollNode.firstElementChild;
|
|
|
|
|
if (!content) return;
|
|
|
|
|
const pin = () => {
|
|
|
|
|
if (userOwnsScrollRef.current || !isAtEndRef.current || modeRef.current !== 'following-end') return;
|
2026-09-07 13:26:30 +03:00
|
|
|
if (widthResizingRef.current) {
|
|
|
|
|
// Re-wrapping rows: the scroll node's scrollHeight carries the
|
|
|
|
|
// list's stale total, so the end is the measured bottom of the
|
|
|
|
|
// last real row. Held for a streaming reader too — output
|
|
|
|
|
// growth is not what moves the viewport during a resize.
|
|
|
|
|
const state = listRef.current?.getState();
|
|
|
|
|
const offset = state
|
|
|
|
|
? resolveRealContentEndOffset({
|
|
|
|
|
state,
|
|
|
|
|
composerOverlayHeight: composerOverlayHeightRef.current,
|
|
|
|
|
footerSize: listFooterSizeRef.current,
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
if (offset !== null && Math.abs(offset - scrollNode.scrollTop) > 1) {
|
|
|
|
|
scrollNode.scrollTop = offset;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (sessionIsWorkingRef.current) return;
|
2026-08-29 21:17:28 +03:00
|
|
|
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
|
|
|
|
|
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
|
|
|
|
|
};
|
|
|
|
|
// A MutationObserver runs as a microtask right after the list writes
|
|
|
|
|
// its layout (row positions, container height), before the frame is
|
|
|
|
|
// painted, so the pin lands in the same frame as the growth. A
|
|
|
|
|
// ResizeObserver would only see the container a rendering step later
|
|
|
|
|
// and let one frame paint with the end out of view.
|
|
|
|
|
const mutations = new MutationObserver(pin);
|
|
|
|
|
mutations.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
|
|
|
|
|
const resizes = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(pin);
|
|
|
|
|
resizes?.observe(content);
|
|
|
|
|
return () => {
|
|
|
|
|
mutations.disconnect();
|
|
|
|
|
resizes?.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [scrollNode]);
|
|
|
|
|
|
2026-08-04 12:09:37 +03:00
|
|
|
// ── session lifecycle ───────────────────────────────────────────────────
|
|
|
|
|
const lastSessionKeyRef = React.useRef<string | null>(null);
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
lastSessionKeyRef.current = currentSessionKey;
|
|
|
|
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
|
|
|
|
// Persist the outgoing session's position before the new one takes over.
|
|
|
|
|
flushSave();
|
|
|
|
|
isAtEndRef.current = true;
|
2026-08-25 01:35:45 +03:00
|
|
|
setUserOwnsScroll(false);
|
2026-08-04 12:09:37 +03:00
|
|
|
modeRef.current = 'following-end';
|
|
|
|
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
|
|
|
|
hideScrollButton();
|
2026-09-07 16:07:45 +03:00
|
|
|
}, [currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
|
2026-08-04 12:09:37 +03:00
|
|
|
|
|
|
|
|
// Suppress the overlay scrollbar thumb while automatic movement owns the
|
|
|
|
|
// scroll position, so it does not jump on each correction.
|
|
|
|
|
React.useEffect(() => {
|
2026-08-25 01:35:45 +03:00
|
|
|
setIsFollowingProgrammatically(!showScrollButton && !userOwnsScroll);
|
|
|
|
|
}, [showScrollButton, userOwnsScroll]);
|
2026-08-04 12:09:37 +03:00
|
|
|
|
|
|
|
|
React.useEffect(() => () => {
|
|
|
|
|
cancelShowButtonTimer();
|
|
|
|
|
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
|
|
|
|
|
}, [cancelShowButtonTimer]);
|
|
|
|
|
|
|
|
|
|
// ── active-turn spy ─────────────────────────────────────────────────────
|
|
|
|
|
// Reads turn positions straight from the DOM, so it is unaffected by which
|
|
|
|
|
// list implementation owns the container. Rows mounting and unmounting
|
|
|
|
|
// during virtualized scrolling are tracked through the mutation observer.
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!onActiveTurnChange) return;
|
|
|
|
|
const container = scrollNode;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
let lastActiveTurnId: string | null = null;
|
|
|
|
|
const spy = createScrollSpy({
|
|
|
|
|
onActive: (turnId) => {
|
|
|
|
|
if (turnId === lastActiveTurnId) return;
|
|
|
|
|
lastActiveTurnId = turnId;
|
|
|
|
|
onActiveTurnChange(turnId);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
spy.setContainer(container);
|
|
|
|
|
|
|
|
|
|
const elementByTurnId = new Map<string, HTMLElement>();
|
|
|
|
|
const registerTurnNode = (node: HTMLElement) => {
|
|
|
|
|
const turnId = node.dataset.turnId;
|
|
|
|
|
if (!turnId) return false;
|
|
|
|
|
elementByTurnId.set(turnId, node);
|
|
|
|
|
spy.register(node, turnId);
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
const unregisterTurnNode = (node: HTMLElement) => {
|
|
|
|
|
const turnId = node.dataset.turnId;
|
|
|
|
|
if (!turnId) return false;
|
|
|
|
|
if (elementByTurnId.get(turnId) !== node) return false;
|
|
|
|
|
elementByTurnId.delete(turnId);
|
|
|
|
|
spy.unregister(turnId);
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
const collectTurnNodes = (node: Node): HTMLElement[] => {
|
|
|
|
|
if (!(node instanceof HTMLElement)) return [];
|
|
|
|
|
const collected: HTMLElement[] = [];
|
|
|
|
|
if (node.matches('[data-turn-id]')) collected.push(node);
|
|
|
|
|
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
|
|
|
|
|
return collected;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
|
|
|
|
|
spy.markDirty();
|
|
|
|
|
|
|
|
|
|
const mutationObserver = new MutationObserver((records) => {
|
|
|
|
|
let changed = false;
|
|
|
|
|
records.forEach((record) => {
|
|
|
|
|
record.removedNodes.forEach((node) => {
|
|
|
|
|
collectTurnNodes(node).forEach((turnNode) => {
|
|
|
|
|
if (unregisterTurnNode(turnNode)) changed = true;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
record.addedNodes.forEach((node) => {
|
|
|
|
|
collectTurnNodes(node).forEach((turnNode) => {
|
|
|
|
|
if (registerTurnNode(turnNode)) changed = true;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
if (changed) spy.markDirty();
|
|
|
|
|
});
|
|
|
|
|
mutationObserver.observe(container, { subtree: true, childList: true });
|
|
|
|
|
|
|
|
|
|
const onScroll = () => spy.onScroll();
|
|
|
|
|
container.addEventListener('scroll', onScroll, { passive: true });
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
container.removeEventListener('scroll', onScroll);
|
|
|
|
|
mutationObserver.disconnect();
|
|
|
|
|
spy.destroy();
|
|
|
|
|
};
|
|
|
|
|
}, [onActiveTurnChange, scrollNode]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
scrollRef,
|
|
|
|
|
scrollNode,
|
|
|
|
|
isPinned,
|
|
|
|
|
registerList,
|
|
|
|
|
onIsAtEndChange,
|
2026-09-07 13:26:30 +03:00
|
|
|
onListMetricsChange,
|
2026-08-04 12:09:37 +03:00
|
|
|
onManualNavigation,
|
|
|
|
|
onTimelineDataChange,
|
|
|
|
|
showScrollButton,
|
2026-08-25 12:35:12 +03:00
|
|
|
userOwnsScroll,
|
2026-08-04 12:09:37 +03:00
|
|
|
isFollowingProgrammatically,
|
|
|
|
|
goToBottom,
|
|
|
|
|
scrollToBottomOnSend,
|
|
|
|
|
saveSnapshotNow,
|
|
|
|
|
restoreSnapshot,
|
|
|
|
|
};
|
|
|
|
|
};
|