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';
|
|
|
|
|
import {
|
|
|
|
|
CHAT_LIST_ANCHOR_OFFSET,
|
|
|
|
|
getAnchoredTurnMetrics,
|
|
|
|
|
type TimelineListMeasurementState,
|
|
|
|
|
type TimelineScrollMode,
|
|
|
|
|
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
|
|
|
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Chat timeline scroll ownership.
|
|
|
|
|
//
|
|
|
|
|
// The virtualized list owns the scroll position; this hook only decides which
|
|
|
|
|
// of three mutually exclusive modes is active and, when a mode calls for it,
|
|
|
|
|
// 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.
|
|
|
|
|
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
|
|
|
|
// of the viewport and the reply streams into the reserved end space below
|
|
|
|
|
// it. The viewport does NOT move while the turn still fits; once the turn
|
|
|
|
|
// outgrows the usable viewport we scroll by the exact delta needed to keep
|
|
|
|
|
// its end visible.
|
|
|
|
|
// • `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.
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// Kept for source compatibility with message parts that report content growth.
|
|
|
|
|
// Growth no longer drives scrolling — the list handles it — so these are inert,
|
|
|
|
|
// but the prop threads through many part components and removing the contract
|
|
|
|
|
// is a separate change.
|
|
|
|
|
export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
|
|
|
|
|
|
|
|
|
|
export interface AnimationHandlers {
|
|
|
|
|
onChunk: () => void;
|
|
|
|
|
onComplete: () => void;
|
|
|
|
|
onStreamingCandidate?: () => void;
|
|
|
|
|
onAnimationStart?: () => void;
|
|
|
|
|
onReservationCancelled?: () => void;
|
|
|
|
|
onReasoningBlock?: () => void;
|
|
|
|
|
onAnimatedHeightChange?: (height: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
// Id of the newest user message in the rendered timeline. When a send has
|
|
|
|
|
// armed the anchor, the next new id here becomes the anchored row.
|
|
|
|
|
lastUserMessageId: string | null;
|
|
|
|
|
onActiveTurnChange?: (turnId: string | null) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
anchorMessageId: string | null;
|
|
|
|
|
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
|
|
|
|
onAnchorSizeChanged: (messageId: string) => void;
|
|
|
|
|
onIsAtEndChange: (isAtEnd: boolean) => void;
|
|
|
|
|
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;
|
|
|
|
|
notifyContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
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;
|
|
|
|
|
// 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;
|
|
|
|
|
// Re-running the anchor positioning while the list is still mounting rows.
|
|
|
|
|
const ANCHOR_POSITION_ATTEMPTS = 12;
|
|
|
|
|
// Anchor restores only correct sub-pixel drift; anything larger is the user or
|
|
|
|
|
// a genuine relayout and must not be undone.
|
|
|
|
|
const ANCHOR_RESTORE_TOLERANCE_PX = 2;
|
|
|
|
|
|
|
|
|
|
const NOOP = (): void => {};
|
|
|
|
|
|
|
|
|
|
export const useChatTimelineScroll = ({
|
|
|
|
|
currentSessionId,
|
|
|
|
|
currentSessionKey,
|
|
|
|
|
sessionMessageCount,
|
|
|
|
|
composerOverlayHeight,
|
|
|
|
|
lastUserMessageId,
|
|
|
|
|
onActiveTurnChange,
|
|
|
|
|
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
|
|
|
|
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const listRef = React.useRef<TimelineListHandle | null>(null);
|
|
|
|
|
|
|
|
|
|
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
|
|
|
|
|
const [anchorMessageId, setAnchorMessageId] = React.useState<string | 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-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);
|
|
|
|
|
// Anchor lifecycle: armed on send → pending until the row exists → positioned
|
|
|
|
|
// while the animated scroll runs → settled once it has come to rest.
|
|
|
|
|
const armedForNextUserMessageRef = React.useRef(false);
|
|
|
|
|
const pendingAnchorRef = React.useRef<string | null>(null);
|
|
|
|
|
const positionedAnchorRef = React.useRef<string | null>(null);
|
|
|
|
|
const settledAnchorRef = React.useRef<string | null>(null);
|
|
|
|
|
const activeAnchorIndexRef = React.useRef<number | null>(null);
|
|
|
|
|
const pendingAnchorRestoreRef = React.useRef<{
|
|
|
|
|
readonly messageId: string;
|
|
|
|
|
readonly offset: number;
|
|
|
|
|
readonly userGeneration: number;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
const anchorRestoreFrameRef = React.useRef<number | null>(null);
|
|
|
|
|
const showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
|
|
|
|
|
composerOverlayHeightRef.current = composerOverlayHeight;
|
|
|
|
|
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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const clearAnchor = React.useCallback(() => {
|
|
|
|
|
armedForNextUserMessageRef.current = false;
|
|
|
|
|
pendingAnchorRef.current = null;
|
|
|
|
|
positionedAnchorRef.current = null;
|
|
|
|
|
settledAnchorRef.current = null;
|
|
|
|
|
activeAnchorIndexRef.current = null;
|
|
|
|
|
pendingAnchorRestoreRef.current = null;
|
|
|
|
|
if (anchorRestoreFrameRef.current !== null) {
|
|
|
|
|
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
|
|
|
|
anchorRestoreFrameRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
setAnchorMessageId(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-25 01:35:45 +03:00
|
|
|
// A real gesture: stop every automatic movement until the user opts back
|
|
|
|
|
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
|
|
|
|
|
// viewport back to the end — only the anchor machinery is disarmed.
|
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 12:35:12 +03:00
|
|
|
// case no further at-end transition will fire. This is an explicit
|
|
|
|
|
// gesture — show the pill immediately, no debounce.
|
|
|
|
|
if (!isAtEndRef.current) {
|
|
|
|
|
cancelShowButtonTimer();
|
|
|
|
|
setShowScrollButton(true);
|
|
|
|
|
}
|
2026-08-25 01:35:45 +03:00
|
|
|
armedForNextUserMessageRef.current = false;
|
|
|
|
|
pendingAnchorRef.current = null;
|
|
|
|
|
positionedAnchorRef.current = null;
|
|
|
|
|
settledAnchorRef.current = null;
|
|
|
|
|
activeAnchorIndexRef.current = null;
|
|
|
|
|
pendingAnchorRestoreRef.current = null;
|
|
|
|
|
if (anchorRestoreFrameRef.current !== null) {
|
|
|
|
|
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
|
|
|
|
anchorRestoreFrameRef.current = null;
|
|
|
|
|
}
|
2026-08-25 12:35:12 +03:00
|
|
|
}, [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 ─────────────────────────────────────────────────────
|
|
|
|
|
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;
|
|
|
|
|
clearAnchor();
|
|
|
|
|
hideScrollButton();
|
|
|
|
|
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
|
|
|
|
|
}, [clearAnchor, hideScrollButton]);
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
const scrollToBottomOnSend = React.useCallback(() => {
|
|
|
|
|
isAtEndRef.current = true;
|
2026-08-25 01:35:45 +03:00
|
|
|
setUserOwnsScroll(false);
|
2026-08-04 12:09:37 +03:00
|
|
|
modeRef.current = 'anchoring-new-turn';
|
|
|
|
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
|
|
|
|
armedForNextUserMessageRef.current = true;
|
2026-08-25 01:35:45 +03:00
|
|
|
// The optimistic row is not committed yet; the next NEW user message id
|
|
|
|
|
// relative to this baseline claims the anchor, independent of whether
|
|
|
|
|
// the commit lands before or after this call.
|
|
|
|
|
armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
|
2026-08-04 12:09:37 +03:00
|
|
|
pendingAnchorRef.current = null;
|
|
|
|
|
positionedAnchorRef.current = null;
|
|
|
|
|
settledAnchorRef.current = null;
|
|
|
|
|
activeAnchorIndexRef.current = null;
|
|
|
|
|
hideScrollButton();
|
|
|
|
|
}, [hideScrollButton]);
|
|
|
|
|
|
2026-08-25 01:35:45 +03:00
|
|
|
// 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
|
|
|
|
|
// anchor, so the claim works whether the optimistic row committed before
|
|
|
|
|
// or after the arming call.
|
2026-08-04 12:09:37 +03:00
|
|
|
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
2026-08-25 01:35:45 +03:00
|
|
|
const armBaselineUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
2026-08-04 12:09:37 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
lastArmedUserMessageIdRef.current = lastUserMessageId;
|
|
|
|
|
if (!armedForNextUserMessageRef.current) return;
|
2026-08-25 01:35:45 +03:00
|
|
|
if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
|
2026-08-04 12:09:37 +03:00
|
|
|
armedForNextUserMessageRef.current = false;
|
|
|
|
|
pendingAnchorRef.current = lastUserMessageId;
|
|
|
|
|
setAnchorMessageId(lastUserMessageId);
|
|
|
|
|
}, [lastUserMessageId]);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
clearAnchor();
|
|
|
|
|
hideScrollButton();
|
|
|
|
|
void listRef.current?.scrollToEnd({ animated: false });
|
|
|
|
|
return false;
|
|
|
|
|
}, [clearAnchor, hideScrollButton]);
|
|
|
|
|
|
|
|
|
|
// ── 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-08-25 11:49:52 +03:00
|
|
|
// own doing (the anchored turn parks mid-timeline, the glide trails its
|
|
|
|
|
// target between corrections) — not a reason to offer the pill. Only a
|
|
|
|
|
// 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-08-25 01:35:45 +03:00
|
|
|
if (modeRef.current !== 'anchoring-new-turn') {
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
// Park the anchored row near the top once the list has measured it.
|
|
|
|
|
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
|
2026-08-25 01:35:45 +03:00
|
|
|
// The anchored end space can be remeasured long after the send (turn
|
|
|
|
|
// completion, images decoding). Only the send-time anchoring mode may
|
|
|
|
|
// position the viewport.
|
|
|
|
|
if (modeRef.current !== 'anchoring-new-turn') return;
|
2026-08-04 12:09:37 +03:00
|
|
|
if (pendingAnchorRef.current === messageId) {
|
|
|
|
|
pendingAnchorRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
activeAnchorIndexRef.current = anchorIndex;
|
|
|
|
|
if (positionedAnchorRef.current === messageId) return;
|
|
|
|
|
positionedAnchorRef.current = messageId;
|
|
|
|
|
settledAnchorRef.current = null;
|
|
|
|
|
|
|
|
|
|
const positionAnchor = (remainingAttempts: number) => {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (positionedAnchorRef.current !== messageId) return;
|
|
|
|
|
const list = listRef.current;
|
|
|
|
|
if (!list) {
|
|
|
|
|
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const scrollNode = list.getScrollableNode();
|
|
|
|
|
if (!scrollNode) {
|
|
|
|
|
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let finished = false;
|
|
|
|
|
const finishPositioning = () => {
|
|
|
|
|
if (finished) return;
|
|
|
|
|
finished = true;
|
|
|
|
|
clearTimeout(fallbackTimer);
|
|
|
|
|
scrollNode.removeEventListener('scrollend', finishPositioning);
|
|
|
|
|
if (positionedAnchorRef.current !== messageId) return;
|
|
|
|
|
// Re-assert the resting offset without animation so the
|
|
|
|
|
// smooth scroll's own momentum cannot drift past it.
|
|
|
|
|
const scrollOffset = list.getState().scroll;
|
|
|
|
|
void list.scrollToOffset({ offset: scrollOffset, animated: false });
|
|
|
|
|
settledAnchorRef.current = messageId;
|
|
|
|
|
};
|
|
|
|
|
const fallbackTimer = setTimeout(finishPositioning, ANCHOR_SETTLE_FALLBACK_MS);
|
|
|
|
|
scrollNode.addEventListener('scrollend', finishPositioning, { once: true });
|
|
|
|
|
|
|
|
|
|
void list.scrollToIndex({
|
|
|
|
|
index: anchorIndex,
|
|
|
|
|
animated: true,
|
|
|
|
|
viewPosition: 0,
|
|
|
|
|
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(() => positionAnchor(ANCHOR_POSITION_ATTEMPTS));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// The anchored row can still change height after it settles (an image
|
|
|
|
|
// decoding, a code block highlighting). Hold the resting offset, but only
|
|
|
|
|
// against sub-pixel drift and only while the user has not taken over.
|
|
|
|
|
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
|
|
|
|
|
if (settledAnchorRef.current !== messageId) return;
|
2026-08-25 01:35:45 +03:00
|
|
|
if (!isLiveFollowActive()) return;
|
2026-08-04 12:09:37 +03:00
|
|
|
const scrollOffset = listRef.current?.getState().scroll;
|
|
|
|
|
if (scrollOffset === undefined) return;
|
|
|
|
|
|
|
|
|
|
if (pendingAnchorRestoreRef.current === null) {
|
|
|
|
|
pendingAnchorRestoreRef.current = {
|
|
|
|
|
messageId,
|
|
|
|
|
offset: scrollOffset,
|
|
|
|
|
userGeneration: userGenerationRef.current,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (anchorRestoreFrameRef.current !== null) return;
|
|
|
|
|
|
|
|
|
|
anchorRestoreFrameRef.current = requestAnimationFrame(() => {
|
|
|
|
|
anchorRestoreFrameRef.current = null;
|
|
|
|
|
const pending = pendingAnchorRestoreRef.current;
|
|
|
|
|
pendingAnchorRestoreRef.current = null;
|
|
|
|
|
if (
|
|
|
|
|
!pending
|
|
|
|
|
|| settledAnchorRef.current !== pending.messageId
|
|
|
|
|
|| pending.userGeneration !== userGenerationRef.current
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const list = listRef.current;
|
|
|
|
|
const currentOffset = list?.getState().scroll;
|
|
|
|
|
if (
|
|
|
|
|
typeof currentOffset === 'number'
|
|
|
|
|
&& Math.abs(currentOffset - pending.offset) <= ANCHOR_RESTORE_TOLERANCE_PX
|
|
|
|
|
) {
|
|
|
|
|
void list?.scrollToOffset({ offset: pending.offset, animated: false });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, [isLiveFollowActive]);
|
|
|
|
|
|
|
|
|
|
// Whether the real rows (ignoring any reserved anchored end space) are tall
|
|
|
|
|
// enough to scroll. Without this, entering a short session would scroll into
|
|
|
|
|
// the reserved space and strand the content above the viewport.
|
|
|
|
|
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);
|
|
|
|
|
const visibleScrollLength = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
|
|
|
|
|
);
|
|
|
|
|
return realContentBottom > visibleScrollLength;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// One deterministic correction per data change, two frames out so the list
|
|
|
|
|
// has measured the new rows. Nothing runs while the user owns the scroll.
|
|
|
|
|
const dataChangeFramesRef = React.useRef<{ first: number | null; second: number | null }>({
|
|
|
|
|
first: null,
|
|
|
|
|
second: null,
|
|
|
|
|
});
|
|
|
|
|
const onTimelineDataChange = React.useCallback(() => {
|
|
|
|
|
if (!isLiveFollowActive()) return;
|
|
|
|
|
|
2026-08-25 14:27:37 +03:00
|
|
|
// Following the end needs no animation frames: the totalSize listener
|
|
|
|
|
// already fires after measurement, so correct synchronously the way
|
|
|
|
|
// the previous scroll engine wrote scrollTop directly. Scheduling a
|
|
|
|
|
// two-frame chain per streamed chunk kept a continuous rAF load (and
|
|
|
|
|
// its per-frame style recalcs) running for the whole stream.
|
|
|
|
|
if (modeRef.current === 'following-end') {
|
|
|
|
|
const list = listRef.current;
|
|
|
|
|
if (!list) return;
|
|
|
|
|
if (!realContentOverflowsViewport(list)) return;
|
|
|
|
|
// Write scrollTop directly instead of going through scrollToEnd:
|
|
|
|
|
// the list's programmatic-scroll machinery schedules follow-up
|
|
|
|
|
// animation frames per call, which doubles frame production for
|
|
|
|
|
// the whole stream. A direct write is what a user gesture does,
|
|
|
|
|
// and the list reconciles it through its normal onScroll path.
|
|
|
|
|
const node = list.getScrollableNode();
|
|
|
|
|
if (!node) return;
|
|
|
|
|
// Overshoot so the browser clamps to the exact fractional maximum
|
|
|
|
|
// (scrollHeight is integer-rounded).
|
|
|
|
|
node.scrollTop = node.scrollHeight + 4096;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 12:09:37 +03:00
|
|
|
const frames = dataChangeFramesRef.current;
|
|
|
|
|
if (frames.first !== null) cancelAnimationFrame(frames.first);
|
|
|
|
|
if (frames.second !== null) cancelAnimationFrame(frames.second);
|
|
|
|
|
|
|
|
|
|
frames.first = requestAnimationFrame(() => {
|
|
|
|
|
frames.first = null;
|
|
|
|
|
frames.second = requestAnimationFrame(() => {
|
|
|
|
|
frames.second = null;
|
|
|
|
|
if (!isLiveFollowActive()) return;
|
|
|
|
|
// An anchor that exists but has not come to rest yet owns the
|
|
|
|
|
// viewport; correcting now would fight its animation.
|
|
|
|
|
if (pendingAnchorRef.current !== null) return;
|
|
|
|
|
if (
|
|
|
|
|
positionedAnchorRef.current !== null
|
|
|
|
|
&& settledAnchorRef.current !== positionedAnchorRef.current
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const list = listRef.current;
|
|
|
|
|
if (!list) return;
|
|
|
|
|
|
|
|
|
|
if (modeRef.current === 'anchoring-new-turn') {
|
|
|
|
|
const anchorIndex = activeAnchorIndexRef.current;
|
|
|
|
|
if (anchorIndex === null) return;
|
|
|
|
|
const metrics = getAnchoredTurnMetrics({
|
|
|
|
|
state: list.getState(),
|
|
|
|
|
anchorIndex,
|
|
|
|
|
composerOverlayHeight: composerOverlayHeightRef.current,
|
|
|
|
|
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
|
|
|
|
});
|
|
|
|
|
// The turn still fits: leave the viewport exactly where the
|
|
|
|
|
// user is reading.
|
|
|
|
|
if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) return;
|
2026-08-25 01:59:53 +03:00
|
|
|
// Animated: successive corrections restart the smooth scroll
|
|
|
|
|
// from the current position, so streaming reads as one
|
|
|
|
|
// continuous glide instead of a per-line hop. A real user
|
|
|
|
|
// gesture interrupts the native smooth scroll on its own.
|
2026-08-04 12:09:37 +03:00
|
|
|
void list.scrollToOffset({
|
|
|
|
|
offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd,
|
2026-08-25 01:59:53 +03:00
|
|
|
animated: true,
|
2026-08-04 12:09:37 +03:00
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (modeRef.current !== 'following-end') return;
|
|
|
|
|
if (!realContentOverflowsViewport(list)) return;
|
|
|
|
|
void list.scrollToEnd({ animated: false });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}, [isLiveFollowActive, realContentOverflowsViewport]);
|
|
|
|
|
|
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
|
|
|
|
|
// history above (an anchored turn parks mid-conversation with
|
|
|
|
|
// reserved space below — the real rows may not overflow yet, but
|
|
|
|
|
// wheel-up is still a genuine opt-out; swallowing it left live-follow
|
|
|
|
|
// armed, which suppressed the pill and kept corrections armed under a
|
|
|
|
|
// viewport the user had taken).
|
|
|
|
|
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) => {
|
|
|
|
|
// Scrolling toward the end is not opting out of follow.
|
2026-08-25 12:35:12 +03:00
|
|
|
if (event.deltaY < 0 && canScrollUp()) gesture();
|
2026-08-25 01:35:45 +03:00
|
|
|
};
|
|
|
|
|
const handleTouchMove = () => {
|
2026-08-25 12:35:12 +03:00
|
|
|
// Touch is continuous: the first move may still read as at-end,
|
|
|
|
|
// but the next one lands after the viewport left it.
|
|
|
|
|
if (!isAtEndRef.current && canScrollUp()) gesture();
|
2026-08-25 01:35:45 +03:00
|
|
|
};
|
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
|
|
|
// The scrollbar track is the scroll node itself; a tap on a row
|
|
|
|
|
// only breaks follow when the viewport already left the end.
|
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-25 12:35:12 +03:00
|
|
|
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) {
|
2026-08-25 01:35:45 +03:00
|
|
|
gesture();
|
|
|
|
|
}
|
|
|
|
|
};
|
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 });
|
|
|
|
|
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
|
|
|
|
|
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);
|
|
|
|
|
scrollNode.removeEventListener('touchmove', handleTouchMove);
|
|
|
|
|
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
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
clearAnchor();
|
|
|
|
|
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.
|
|
|
|
|
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);
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
// ── 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]);
|
|
|
|
|
|
|
|
|
|
// ── inert compatibility surface ─────────────────────────────────────────
|
|
|
|
|
const stableAnimationHandlers = React.useMemo<AnimationHandlers>(() => ({
|
|
|
|
|
onChunk: NOOP,
|
|
|
|
|
onComplete: NOOP,
|
|
|
|
|
onStreamingCandidate: NOOP,
|
|
|
|
|
onAnimationStart: NOOP,
|
|
|
|
|
onReservationCancelled: NOOP,
|
|
|
|
|
onReasoningBlock: NOOP,
|
|
|
|
|
onAnimatedHeightChange: NOOP,
|
|
|
|
|
}), []);
|
|
|
|
|
const getAnimationHandlers = React.useCallback(() => stableAnimationHandlers, [stableAnimationHandlers]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
scrollRef,
|
|
|
|
|
scrollNode,
|
|
|
|
|
isPinned,
|
|
|
|
|
registerList,
|
|
|
|
|
anchorMessageId,
|
|
|
|
|
onAnchorReady,
|
|
|
|
|
onAnchorSizeChanged,
|
|
|
|
|
onIsAtEndChange,
|
|
|
|
|
onManualNavigation,
|
|
|
|
|
onTimelineDataChange,
|
|
|
|
|
showScrollButton,
|
2026-08-25 12:35:12 +03:00
|
|
|
userOwnsScroll,
|
2026-08-04 12:09:37 +03:00
|
|
|
isFollowingProgrammatically,
|
|
|
|
|
goToBottom,
|
|
|
|
|
scrollToBottomOnSend,
|
|
|
|
|
notifyContentChange: NOOP,
|
|
|
|
|
getAnimationHandlers,
|
|
|
|
|
saveSnapshotNow,
|
|
|
|
|
restoreSnapshot,
|
|
|
|
|
};
|
|
|
|
|
};
|