2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
2026-03-12 23:45:45 +02:00
|
|
|
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
|
|
|
|
import {
|
|
|
|
|
isNearBottom,
|
|
|
|
|
normalizeWheelDelta,
|
|
|
|
|
shouldPauseAutoScrollOnWheel,
|
|
|
|
|
} from '@/components/chat/lib/scroll/scrollIntent';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
import { useScrollEngine } from './useScrollEngine';
|
|
|
|
|
|
|
|
|
|
export type ContentChangeReason = 'text' | 'structural' | 'permission';
|
|
|
|
|
|
|
|
|
|
interface SessionMemoryState {
|
|
|
|
|
viewportAnchor: number;
|
|
|
|
|
isStreaming: boolean;
|
|
|
|
|
lastAccessedAt: number;
|
|
|
|
|
backgroundMessageCount: number;
|
|
|
|
|
totalAvailableMessages?: number;
|
|
|
|
|
hasMoreAbove?: boolean;
|
|
|
|
|
streamStartTime?: number;
|
|
|
|
|
isZombie?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface UseChatScrollManagerOptions {
|
|
|
|
|
currentSessionId: string | null;
|
2026-04-05 15:58:06 +03:00
|
|
|
sessionMessageCount: number;
|
2025-12-07 19:32:53 +02:00
|
|
|
sessionPermissions: unknown[];
|
2026-04-05 23:01:12 +03:00
|
|
|
sessionIsWorking: boolean;
|
2025-12-07 19:32:53 +02:00
|
|
|
sessionMemoryState: Map<string, SessionMemoryState>;
|
|
|
|
|
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
|
|
|
|
isSyncing: boolean;
|
|
|
|
|
isMobile: boolean;
|
2026-03-12 23:45:45 +02:00
|
|
|
chatRenderMode?: 'sorted' | 'live';
|
|
|
|
|
onActiveTurnChange?: (turnId: string | null) => void;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AnimationHandlers {
|
|
|
|
|
onChunk: () => void;
|
|
|
|
|
onComplete: () => void;
|
|
|
|
|
onStreamingCandidate?: () => void;
|
|
|
|
|
onAnimationStart?: () => void;
|
|
|
|
|
onReservationCancelled?: () => void;
|
|
|
|
|
onReasoningBlock?: () => void;
|
|
|
|
|
onAnimatedHeightChange?: (height: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
type FollowMode = 'none' | 'smooth';
|
|
|
|
|
|
|
|
|
|
type AutoScrollMarker = {
|
|
|
|
|
top: number;
|
|
|
|
|
at: number;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
interface UseChatScrollManagerResult {
|
|
|
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
|
|
|
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
showScrollButton: boolean;
|
2026-04-06 20:18:20 +03:00
|
|
|
prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void;
|
2026-01-20 03:23:39 +02:00
|
|
|
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
|
|
|
|
scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
|
2026-03-12 23:45:45 +02:00
|
|
|
releasePinnedScroll: () => void;
|
2026-01-20 03:23:39 +02:00
|
|
|
isPinned: boolean;
|
2026-03-12 23:45:45 +02:00
|
|
|
isOverflowing: boolean;
|
|
|
|
|
isProgrammaticFollowActive: boolean;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
2026-01-20 03:23:39 +02:00
|
|
|
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
|
|
|
|
|
const PIN_THRESHOLD_RATIO = 0.10;
|
2026-03-20 01:01:03 +02:00
|
|
|
const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
|
2026-01-19 02:48:56 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export const useChatScrollManager = ({
|
|
|
|
|
currentSessionId,
|
2026-04-05 15:58:06 +03:00
|
|
|
sessionMessageCount,
|
2026-04-05 23:01:12 +03:00
|
|
|
sessionIsWorking,
|
2025-12-07 19:32:53 +02:00
|
|
|
updateViewportAnchor,
|
|
|
|
|
isSyncing,
|
|
|
|
|
isMobile,
|
2026-03-12 23:45:45 +02:00
|
|
|
onActiveTurnChange,
|
2025-12-07 19:32:53 +02:00
|
|
|
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
|
|
|
|
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
const getPinThreshold = React.useCallback(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container || container.clientHeight <= 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
const raw = container.clientHeight * PIN_THRESHOLD_RATIO;
|
|
|
|
|
return Math.max(24, Math.min(200, raw));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const getAutoFollowThreshold = React.useCallback(() => {
|
|
|
|
|
return getPinThreshold();
|
2026-03-20 01:01:03 +02:00
|
|
|
}, [getPinThreshold]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-04-05 23:01:12 +03:00
|
|
|
const getAutoFollowSnapThreshold = React.useCallback(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container || container.clientHeight <= 0) {
|
|
|
|
|
return 96;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const raw = container.clientHeight * 0.2;
|
|
|
|
|
return Math.max(72, Math.min(192, raw));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
2026-01-20 03:23:39 +02:00
|
|
|
const [isPinned, setIsPinned] = React.useState(true);
|
2026-03-12 23:45:45 +02:00
|
|
|
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
2026-04-05 15:36:11 +03:00
|
|
|
const showScrollButtonRef = React.useRef(false);
|
|
|
|
|
const isOverflowingRef = React.useRef(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const lastSessionIdRef = React.useRef<string | null>(null);
|
2026-01-20 03:23:39 +02:00
|
|
|
const isPinnedRef = React.useRef(true);
|
|
|
|
|
const lastScrollTopRef = React.useRef<number>(0);
|
2026-03-12 23:45:45 +02:00
|
|
|
const touchLastYRef = React.useRef<number | null>(null);
|
2026-03-20 01:01:03 +02:00
|
|
|
const pinnedSyncRafRef = React.useRef<number | null>(null);
|
2026-04-06 20:18:20 +03:00
|
|
|
const followModeRef = React.useRef<FollowMode>('none');
|
|
|
|
|
const autoScrollMarkerRef = React.useRef<AutoScrollMarker | null>(null);
|
2026-03-20 01:01:03 +02:00
|
|
|
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
|
|
|
|
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
|
|
|
|
const lastViewportAnchorWriteAtRef = React.useRef<number>(0);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
const markAutoScroll = React.useCallback((top: number) => {
|
|
|
|
|
autoScrollMarkerRef.current = {
|
|
|
|
|
top,
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const isMarkedAutoScroll = React.useCallback((scrollTop: number) => {
|
|
|
|
|
const marker = autoScrollMarkerRef.current;
|
|
|
|
|
if (!marker) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Date.now() - marker.at > PROGRAMMATIC_SCROLL_SUPPRESS_MS) {
|
|
|
|
|
autoScrollMarkerRef.current = null;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Math.abs(scrollTop - marker.top) > 2) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
2025-12-07 19:32:53 +02:00
|
|
|
}, []);
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
const getDistanceFromBottom = React.useCallback(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return 0;
|
|
|
|
|
return container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const updatePinnedState = React.useCallback((newPinned: boolean) => {
|
|
|
|
|
if (isPinnedRef.current !== newPinned) {
|
|
|
|
|
isPinnedRef.current = newPinned;
|
|
|
|
|
setIsPinned(newPinned);
|
|
|
|
|
}
|
2026-01-19 16:23:27 +02:00
|
|
|
}, []);
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
const setShowScrollButtonState = React.useCallback((next: boolean) => {
|
|
|
|
|
showScrollButtonRef.current = next;
|
|
|
|
|
setShowScrollButton((previous) => (previous === next ? previous : next));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const setIsOverflowingState = React.useCallback((next: boolean) => {
|
|
|
|
|
isOverflowingRef.current = next;
|
|
|
|
|
setIsOverflowing((previous) => (previous === next ? previous : next));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
const setFollowMode = React.useCallback((next: FollowMode) => {
|
|
|
|
|
followModeRef.current = next;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
const shouldSkipLiveContentSync = React.useCallback(() => {
|
|
|
|
|
return !isPinnedRef.current && showScrollButtonRef.current && isOverflowingRef.current;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
const scrollToBottomInternal = React.useCallback((options?: { instant?: boolean; followBottom?: boolean }) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const container = scrollRef.current;
|
2026-01-20 03:23:39 +02:00
|
|
|
if (!container) return;
|
2026-01-19 02:48:56 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
const bottom = container.scrollHeight - container.clientHeight;
|
2026-04-06 20:18:20 +03:00
|
|
|
markAutoScroll(Math.max(0, bottom));
|
|
|
|
|
scrollEngine.scrollToPosition(Math.max(0, bottom), {
|
|
|
|
|
...options,
|
|
|
|
|
persistFollow: Boolean(options?.followBottom && sessionIsWorking),
|
|
|
|
|
});
|
|
|
|
|
}, [markAutoScroll, scrollEngine, sessionIsWorking]);
|
2026-01-18 18:00:19 +02:00
|
|
|
|
2026-04-05 23:01:12 +03:00
|
|
|
const scrollPinnedToBottom = React.useCallback((distanceFromBottom: number) => {
|
|
|
|
|
if (sessionIsWorking) {
|
2026-04-06 20:18:20 +03:00
|
|
|
if (followModeRef.current === 'smooth' || scrollEngine.isFollowingBottom) {
|
2026-04-05 23:01:12 +03:00
|
|
|
scrollToBottomInternal({ followBottom: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
if (distanceFromBottom > getAutoFollowSnapThreshold()) {
|
2026-04-05 23:01:12 +03:00
|
|
|
scrollToBottomInternal({ instant: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode('smooth');
|
2026-04-05 23:01:12 +03:00
|
|
|
scrollToBottomInternal({ followBottom: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
if (followModeRef.current === 'smooth' || scrollEngine.isFollowingBottom) {
|
|
|
|
|
scrollToBottomInternal({ followBottom: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setFollowMode('none');
|
2026-02-17 18:25:03 +02:00
|
|
|
scrollToBottomInternal({ instant: true });
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [getAutoFollowSnapThreshold, scrollEngine.isFollowingBottom, scrollToBottomInternal, sessionIsWorking, setFollowMode]);
|
2026-02-17 18:25:03 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const updateScrollButtonVisibility = React.useCallback(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) {
|
2026-04-05 15:36:11 +03:00
|
|
|
setShowScrollButtonState(false);
|
|
|
|
|
setIsOverflowingState(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
2026-04-05 15:36:11 +03:00
|
|
|
setIsOverflowingState(hasScrollableContent);
|
2025-12-07 19:32:53 +02:00
|
|
|
if (!hasScrollableContent) {
|
2026-04-05 15:36:11 +03:00
|
|
|
setShowScrollButtonState(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
// Show scroll button when scrolled above the 10vh threshold
|
|
|
|
|
const distanceFromBottom = getDistanceFromBottom();
|
2026-04-05 15:36:11 +03:00
|
|
|
setShowScrollButtonState(!isNearBottom(distanceFromBottom, getPinThreshold()));
|
|
|
|
|
}, [getDistanceFromBottom, getPinThreshold, setIsOverflowingState, setShowScrollButtonState]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
const syncPinnedStateAndIndicators = React.useCallback(() => {
|
|
|
|
|
pinnedSyncRafRef.current = null;
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
if (!isPinnedRef.current) {
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode('none');
|
2026-03-20 01:01:03 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const distanceFromBottom = getDistanceFromBottom();
|
2026-04-05 23:01:12 +03:00
|
|
|
if (sessionIsWorking) {
|
2026-04-06 20:18:20 +03:00
|
|
|
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
2026-04-06 17:36:08 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 23:01:12 +03:00
|
|
|
scrollPinnedToBottom(distanceFromBottom);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
if (distanceFromBottom <= getAutoFollowThreshold()) {
|
2026-04-06 20:18:20 +03:00
|
|
|
if (followModeRef.current !== 'smooth') {
|
|
|
|
|
setFollowMode('none');
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
if (distanceFromBottom > getAutoFollowThreshold()) {
|
2026-04-05 23:01:12 +03:00
|
|
|
scrollPinnedToBottom(distanceFromBottom);
|
2026-03-20 01:01:03 +02:00
|
|
|
}
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, sessionIsWorking, setFollowMode, updateScrollButtonVisibility]);
|
2026-03-20 01:01:03 +02:00
|
|
|
|
|
|
|
|
const schedulePinnedStateAndIndicators = React.useCallback(() => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
syncPinnedStateAndIndicators();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (pinnedSyncRafRef.current !== null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pinnedSyncRafRef.current = window.requestAnimationFrame(() => {
|
|
|
|
|
syncPinnedStateAndIndicators();
|
|
|
|
|
});
|
|
|
|
|
}, [syncPinnedStateAndIndicators]);
|
|
|
|
|
|
|
|
|
|
const flushViewportAnchor = React.useCallback(() => {
|
|
|
|
|
if (viewportAnchorTimerRef.current !== null) {
|
|
|
|
|
clearTimeout(viewportAnchorTimerRef.current);
|
|
|
|
|
viewportAnchorTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pending = pendingViewportAnchorRef.current;
|
|
|
|
|
if (!pending) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lastPersisted = lastViewportAnchorRef.current;
|
|
|
|
|
if (lastPersisted && lastPersisted.sessionId === pending.sessionId && lastPersisted.anchor === pending.anchor) {
|
|
|
|
|
pendingViewportAnchorRef.current = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateViewportAnchor(pending.sessionId, pending.anchor);
|
|
|
|
|
lastViewportAnchorRef.current = pending;
|
|
|
|
|
pendingViewportAnchorRef.current = null;
|
|
|
|
|
lastViewportAnchorWriteAtRef.current = Date.now();
|
|
|
|
|
}, [updateViewportAnchor]);
|
|
|
|
|
|
|
|
|
|
const queueViewportAnchor = React.useCallback((sessionId: string, anchor: number) => {
|
|
|
|
|
const lastPersisted = lastViewportAnchorRef.current;
|
|
|
|
|
if (lastPersisted && lastPersisted.sessionId === sessionId && lastPersisted.anchor === anchor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pendingViewportAnchorRef.current = { sessionId, anchor };
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const elapsed = now - lastViewportAnchorWriteAtRef.current;
|
|
|
|
|
if (elapsed >= VIEWPORT_ANCHOR_MIN_UPDATE_MS) {
|
|
|
|
|
flushViewportAnchor();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (viewportAnchorTimerRef.current !== null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
viewportAnchorTimerRef.current = setTimeout(() => {
|
|
|
|
|
viewportAnchorTimerRef.current = null;
|
|
|
|
|
flushViewportAnchor();
|
|
|
|
|
}, VIEWPORT_ANCHOR_MIN_UPDATE_MS - elapsed);
|
|
|
|
|
}, [flushViewportAnchor]);
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
markAutoScroll(Math.max(0, position));
|
2026-01-20 03:23:39 +02:00
|
|
|
scrollEngine.scrollToPosition(Math.max(0, position), options);
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [markAutoScroll, scrollEngine]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
const prepareForBottomResume = React.useCallback(() => {
|
|
|
|
|
updatePinnedState(true);
|
|
|
|
|
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
|
|
|
|
|
setShowScrollButtonState(false);
|
|
|
|
|
}, [sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
|
|
|
|
|
|
|
|
|
|
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => {
|
2026-01-20 03:23:39 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
2026-01-19 02:48:56 +02:00
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
prepareForBottomResume();
|
2026-01-20 03:23:39 +02:00
|
|
|
scrollToBottomInternal(options);
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [prepareForBottomResume, scrollToBottomInternal]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const releasePinnedScroll = React.useCallback(() => {
|
|
|
|
|
scrollEngine.cancelFollow();
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode('none');
|
2026-03-12 23:45:45 +02:00
|
|
|
updatePinnedState(false);
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [schedulePinnedStateAndIndicators, scrollEngine, setFollowMode, updatePinnedState]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
const handleScrollEvent = React.useCallback((event?: Event) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container || !currentSessionId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
const isProgrammatic = isMarkedAutoScroll(container.scrollTop);
|
|
|
|
|
if (isProgrammatic) {
|
|
|
|
|
autoScrollMarkerRef.current = null;
|
|
|
|
|
}
|
2026-01-19 02:48:56 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
scrollEngine.handleScroll();
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
// Handle pin/unpin logic
|
|
|
|
|
const currentScrollTop = container.scrollTop;
|
2026-03-12 23:45:45 +02:00
|
|
|
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
if (event?.isTrusted && !isProgrammatic) {
|
2026-03-17 13:18:54 +02:00
|
|
|
if (scrollingUp && isPinnedRef.current) {
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode('none');
|
2026-01-20 03:23:39 +02:00
|
|
|
updatePinnedState(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 13:18:54 +02:00
|
|
|
// Re-pin at bottom should always work (even momentum scroll)
|
|
|
|
|
if (!isPinnedRef.current) {
|
|
|
|
|
const distanceFromBottom = getDistanceFromBottom();
|
2026-03-20 01:01:03 +02:00
|
|
|
if (distanceFromBottom <= getPinThreshold()) {
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
|
2026-01-20 03:23:39 +02:00
|
|
|
updatePinnedState(true);
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
lastScrollTopRef.current = currentScrollTop;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
|
|
|
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
|
2026-04-05 15:58:06 +03:00
|
|
|
const estimatedIndex = Math.floor(position * sessionMessageCount);
|
2026-03-20 01:01:03 +02:00
|
|
|
queueViewportAnchor(currentSessionId, estimatedIndex);
|
2025-12-07 19:32:53 +02:00
|
|
|
}, [
|
|
|
|
|
currentSessionId,
|
2026-01-20 03:23:39 +02:00
|
|
|
getDistanceFromBottom,
|
|
|
|
|
getPinThreshold,
|
2026-04-06 20:18:20 +03:00
|
|
|
isMarkedAutoScroll,
|
2026-03-20 01:01:03 +02:00
|
|
|
queueViewportAnchor,
|
|
|
|
|
schedulePinnedStateAndIndicators,
|
2025-12-07 19:32:53 +02:00
|
|
|
scrollEngine,
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode,
|
2026-04-05 15:58:06 +03:00
|
|
|
sessionMessageCount,
|
2026-04-06 20:18:20 +03:00
|
|
|
sessionIsWorking,
|
2026-01-20 03:23:39 +02:00
|
|
|
updatePinnedState,
|
2025-12-07 19:32:53 +02:00
|
|
|
]);
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const handleWheelIntent = React.useCallback((event: WheelEvent) => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const delta = normalizeWheelDelta({
|
|
|
|
|
deltaY: event.deltaY,
|
|
|
|
|
deltaMode: event.deltaMode,
|
|
|
|
|
rootHeight: container.clientHeight,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isPinnedRef.current && shouldPauseAutoScrollOnWheel({
|
|
|
|
|
root: container,
|
|
|
|
|
target: event.target,
|
|
|
|
|
delta,
|
|
|
|
|
})) {
|
|
|
|
|
scrollEngine.cancelFollow();
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode('none');
|
2026-03-12 23:45:45 +02:00
|
|
|
updatePinnedState(false);
|
|
|
|
|
}
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [scrollEngine, setFollowMode, updatePinnedState]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const handleTouchStartIntent = (event: TouchEvent) => {
|
|
|
|
|
const touch = event.touches.item(0);
|
|
|
|
|
touchLastYRef.current = touch ? touch.clientY : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleTouchMoveIntent = (event: TouchEvent) => {
|
|
|
|
|
const touch = event.touches.item(0);
|
|
|
|
|
if (!touch) {
|
|
|
|
|
touchLastYRef.current = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const previousY = touchLastYRef.current;
|
|
|
|
|
touchLastYRef.current = touch.clientY;
|
|
|
|
|
if (previousY === null || !isPinnedRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fingerDelta = touch.clientY - previousY;
|
|
|
|
|
if (Math.abs(fingerDelta) < 2) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const syntheticWheelDelta = -fingerDelta;
|
|
|
|
|
if (syntheticWheelDelta >= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shouldPauseAutoScrollOnWheel({
|
|
|
|
|
root: container,
|
|
|
|
|
target: event.target,
|
|
|
|
|
delta: syntheticWheelDelta,
|
|
|
|
|
})) {
|
|
|
|
|
scrollEngine.cancelFollow();
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode('none');
|
2026-03-12 23:45:45 +02:00
|
|
|
updatePinnedState(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleTouchEndIntent = () => {
|
|
|
|
|
touchLastYRef.current = null;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true });
|
2026-03-12 23:45:45 +02:00
|
|
|
container.addEventListener('touchstart', handleTouchStartIntent as EventListener, { passive: true });
|
|
|
|
|
container.addEventListener('touchmove', handleTouchMoveIntent as EventListener, { passive: true });
|
|
|
|
|
container.addEventListener('touchend', handleTouchEndIntent as EventListener, { passive: true });
|
|
|
|
|
container.addEventListener('touchcancel', handleTouchEndIntent as EventListener, { passive: true });
|
|
|
|
|
container.addEventListener('wheel', handleWheelIntent as EventListener, { passive: true });
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
return () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
container.removeEventListener('scroll', handleScrollEvent as EventListener);
|
2026-03-12 23:45:45 +02:00
|
|
|
container.removeEventListener('touchstart', handleTouchStartIntent as EventListener);
|
|
|
|
|
container.removeEventListener('touchmove', handleTouchMoveIntent as EventListener);
|
|
|
|
|
container.removeEventListener('touchend', handleTouchEndIntent as EventListener);
|
|
|
|
|
container.removeEventListener('touchcancel', handleTouchEndIntent as EventListener);
|
|
|
|
|
container.removeEventListener('wheel', handleWheelIntent as EventListener);
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [handleScrollEvent, handleWheelIntent, scrollEngine, setFollowMode, updatePinnedState]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
// Session switch - always start pinned at bottom
|
2026-03-31 18:47:00 +03:00
|
|
|
React.useEffect(() => {
|
2026-01-19 02:48:56 +02:00
|
|
|
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
lastSessionIdRef.current = currentSessionId;
|
|
|
|
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
2026-03-20 01:01:03 +02:00
|
|
|
flushViewportAnchor();
|
|
|
|
|
pendingViewportAnchorRef.current = null;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
// Always start pinned at bottom on session switch
|
2026-04-06 20:18:20 +03:00
|
|
|
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
|
2026-01-20 03:23:39 +02:00
|
|
|
updatePinnedState(true);
|
2026-04-05 15:36:11 +03:00
|
|
|
setShowScrollButtonState(false);
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [currentSessionId, flushViewportAnchor, sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
// Maintain pin-to-bottom when content changes
|
2026-04-05 23:01:12 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!sessionIsWorking) {
|
2026-04-06 20:18:20 +03:00
|
|
|
scrollEngine.cancelFollow();
|
|
|
|
|
setFollowMode('none');
|
2026-04-05 23:01:12 +03:00
|
|
|
}
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [scrollEngine, sessionIsWorking, setFollowMode]);
|
2026-04-05 23:01:12 +03:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
React.useEffect(() => {
|
2026-03-20 01:01:03 +02:00
|
|
|
if (isSyncing) {
|
|
|
|
|
return;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-04-05 15:36:11 +03:00
|
|
|
if (shouldSkipLiveContentSync()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2026-04-05 15:58:06 +03:00
|
|
|
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessageCount, shouldSkipLiveContentSync]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
// Use ResizeObserver to detect content changes and maintain pin
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container || typeof ResizeObserver === 'undefined') return;
|
|
|
|
|
|
2026-04-04 02:19:55 +03:00
|
|
|
let lastScrollHeight = container.scrollHeight;
|
|
|
|
|
let lastClientHeight = container.clientHeight;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const observer = new ResizeObserver(() => {
|
2026-04-04 02:19:55 +03:00
|
|
|
const nextScrollHeight = container.scrollHeight;
|
|
|
|
|
const nextClientHeight = container.clientHeight;
|
|
|
|
|
const scrollHeightChanged = nextScrollHeight !== lastScrollHeight;
|
|
|
|
|
const clientHeightChanged = nextClientHeight !== lastClientHeight;
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
if (scrollHeightChanged && isPinnedRef.current && sessionIsWorking) {
|
|
|
|
|
setFollowMode('smooth');
|
|
|
|
|
scrollToBottomInternal({ followBottom: true });
|
|
|
|
|
lastScrollHeight = nextScrollHeight;
|
|
|
|
|
lastClientHeight = nextClientHeight;
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-05 23:01:12 +03:00
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
if (clientHeightChanged) {
|
2026-04-04 02:19:55 +03:00
|
|
|
const previousDistanceFromBottom = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (isPinnedRef.current) {
|
|
|
|
|
const targetScrollTop = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
nextScrollHeight - nextClientHeight - previousDistanceFromBottom,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (Math.abs(container.scrollTop - targetScrollTop) > 0.5) {
|
2026-04-06 20:18:20 +03:00
|
|
|
markAutoScroll(targetScrollTop);
|
2026-04-04 02:19:55 +03:00
|
|
|
container.scrollTop = targetScrollTop;
|
|
|
|
|
lastScrollTopRef.current = targetScrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastScrollHeight = nextScrollHeight;
|
|
|
|
|
lastClientHeight = nextClientHeight;
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastScrollHeight = nextScrollHeight;
|
|
|
|
|
lastClientHeight = nextClientHeight;
|
|
|
|
|
|
|
|
|
|
if (clientHeightChanged && !scrollHeightChanged) {
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
if (scrollHeightChanged && shouldSkipLiveContentSync()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2026-01-20 03:23:39 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
observer.observe(container);
|
2026-01-20 03:23:39 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
return () => {
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
};
|
2026-04-06 20:18:20 +03:00
|
|
|
}, [markAutoScroll, schedulePinnedStateAndIndicators, scrollToBottomInternal, sessionIsWorking, setFollowMode, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-18 19:02:42 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
2026-04-05 15:36:11 +03:00
|
|
|
if (shouldSkipLiveContentSync()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2025-12-18 19:02:42 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rafId = window.requestAnimationFrame(() => {
|
2026-04-05 15:36:11 +03:00
|
|
|
if (shouldSkipLiveContentSync()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2025-12-18 19:02:42 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.cancelAnimationFrame(rafId);
|
|
|
|
|
};
|
2026-04-05 15:58:06 +03:00
|
|
|
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessageCount, shouldSkipLiveContentSync]);
|
2025-12-18 19:02:42 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
|
|
|
|
|
|
|
|
|
const handleMessageContentChange = React.useCallback(() => {
|
2026-04-05 15:36:11 +03:00
|
|
|
if (shouldSkipLiveContentSync()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2026-04-05 15:36:11 +03:00
|
|
|
}, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]);
|
2026-02-11 19:28:22 +02:00
|
|
|
|
|
|
|
|
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const existing = animationHandlersRef.current.get(messageId);
|
|
|
|
|
if (existing) {
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handlers: AnimationHandlers = {
|
|
|
|
|
onChunk: () => {
|
2026-04-05 15:36:11 +03:00
|
|
|
if (shouldSkipLiveContentSync()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
onComplete: () => {
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
2026-01-20 03:23:39 +02:00
|
|
|
onStreamingCandidate: () => {},
|
|
|
|
|
onAnimationStart: () => {},
|
2025-12-07 19:32:53 +02:00
|
|
|
onAnimatedHeightChange: () => {
|
2026-04-05 15:36:11 +03:00
|
|
|
if (shouldSkipLiveContentSync()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
schedulePinnedStateAndIndicators();
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
2026-01-20 03:23:39 +02:00
|
|
|
onReservationCancelled: () => {},
|
|
|
|
|
onReasoningBlock: () => {},
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
animationHandlersRef.current.set(messageId, handlers);
|
|
|
|
|
return handlers;
|
2026-04-05 15:36:11 +03:00
|
|
|
}, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]);
|
2026-03-20 01:01:03 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (pinnedSyncRafRef.current !== null && typeof window !== 'undefined') {
|
|
|
|
|
window.cancelAnimationFrame(pinnedSyncRafRef.current);
|
|
|
|
|
pinnedSyncRafRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
flushViewportAnchor();
|
|
|
|
|
if (viewportAnchorTimerRef.current !== null) {
|
|
|
|
|
clearTimeout(viewportAnchorTimerRef.current);
|
|
|
|
|
viewportAnchorTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [flushViewportAnchor]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!onActiveTurnChange) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
let lastActiveTurnId: string | null = null;
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const spy = createScrollSpy({
|
|
|
|
|
onActive: (turnId) => {
|
2026-03-31 18:47:00 +03:00
|
|
|
if (turnId === lastActiveTurnId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
lastActiveTurnId = turnId;
|
2026-03-12 23:45:45 +02:00
|
|
|
onActiveTurnChange(turnId);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
spy.setContainer(container);
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
const elementByTurnId = new Map<string, HTMLElement>();
|
|
|
|
|
|
|
|
|
|
const registerTurnNode = (node: HTMLElement): boolean => {
|
|
|
|
|
const turnId = node.dataset.turnId;
|
|
|
|
|
if (!turnId) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
elementByTurnId.set(turnId, node);
|
|
|
|
|
spy.register(node, turnId);
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const unregisterTurnNode = (node: HTMLElement): boolean => {
|
|
|
|
|
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((turnNode) => {
|
|
|
|
|
collected.push(turnNode);
|
2026-03-12 23:45:45 +02:00
|
|
|
});
|
2026-03-20 01:01:03 +02:00
|
|
|
return collected;
|
2026-03-12 23:45:45 +02:00
|
|
|
};
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((node) => {
|
|
|
|
|
registerTurnNode(node);
|
|
|
|
|
});
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
if (changed) {
|
|
|
|
|
spy.markDirty();
|
|
|
|
|
}
|
2026-03-12 23:45:45 +02:00
|
|
|
});
|
|
|
|
|
mutationObserver.observe(container, { subtree: true, childList: true });
|
|
|
|
|
|
|
|
|
|
const handleScroll = () => {
|
|
|
|
|
spy.onScroll();
|
|
|
|
|
};
|
|
|
|
|
container.addEventListener('scroll', handleScroll, { passive: true });
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
container.removeEventListener('scroll', handleScroll);
|
|
|
|
|
mutationObserver.disconnect();
|
|
|
|
|
spy.destroy();
|
|
|
|
|
};
|
2026-04-05 15:58:06 +03:00
|
|
|
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessageCount]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
scrollRef,
|
|
|
|
|
handleMessageContentChange,
|
|
|
|
|
getAnimationHandlers,
|
|
|
|
|
showScrollButton,
|
2026-04-06 20:18:20 +03:00
|
|
|
prepareForBottomResume,
|
2025-12-07 19:32:53 +02:00
|
|
|
scrollToBottom,
|
2026-01-20 03:23:39 +02:00
|
|
|
scrollToPosition,
|
2026-03-12 23:45:45 +02:00
|
|
|
releasePinnedScroll,
|
2026-01-20 03:23:39 +02:00
|
|
|
isPinned,
|
2026-03-12 23:45:45 +02:00
|
|
|
isOverflowing,
|
|
|
|
|
isProgrammaticFollowActive: scrollEngine.isFollowingBottom,
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
};
|