/* eslint-disable max-lines -- pagination, scroll anchoring, and retry state share one boundary. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { useAppStoreApi } from "@/components/state-provider"; import { getStoredAutoScrollTop } from "@/lib/local-storage"; import { useLazyLoadSentinel as useSharedLazyLoadSentinel, type LazyLoadSentinelSettleResult, } from "@/hooks/use-lazy-load-sentinel"; import type { Message } from "@/lib/types/http"; import { TASK_DESCRIPTION_SYNTHETIC_ID, type RenderItem } from "@/hooks/use-processed-messages"; import { getItemKey, shouldAutoScrollToBottom } from "./message-list-shared"; import { getOldestVisibleBoundaryKey } from "./message-list-native-boundary"; import { isPrependUpdate, hasTranscriptProgressedPastView, hasTranscriptAppendedSinceBaseline, shouldCatchUpOnAutoScrollEnable, resolveNativeInitialScrollTop, createFrameCoalescer, scheduleAfterPanelRestore, useActivationPending, } from "./transcript-auto-scroll"; import { scheduleClampedScrollRestore } from "./clamped-scroll-restore"; import { useTranscriptViewportResize } from "./transcript-viewport-resize"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; const paginationDebug = createDebugLogger("messages:pagination"); const placementDebug = createDebugLogger("messages:scroll-placement"); // i18n-exempt: IntersectionObserver root-margin configuration, not user-facing copy. export const TRANSCRIPT_SENTINEL_ROOT_MARGIN = "200px 0px 0px 0px"; // INT32_MAX: WebKit resolves Number.MAX_SAFE_INTEGER to 0 (not bottom). const NATIVE_BOTTOM_SCROLL_TOP = 2_147_483_647; const USER_SCROLL_INTENT_WINDOW_MS = 250; const SCROLL_KEYS = new Set([ "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "End", "Home", "PageDown", "PageUp", " ", ]); /** Writes a clamped maximum so the browser resolves the native bottom without * forcing a synchronous scrollHeight layout read. */ export function scrollNativeToBottom(element: HTMLElement): void { element.scrollTop = NATIVE_BOTTOM_SCROLL_TOP; } type PaginationRequest = { boundaryBefore: string | null; sessionEpoch: number; debug?: { generation: number; scrollTopBefore: number | null; scrollHeightBefore: number | null; }; }; type PaginationStopReason = | "visible-boundary-unchanged" | "visible-boundary-added" | "exhausted" | "no-progress" | "sentinel-left-preload" | "disarmed" | "blocked" | "stale" | "not-rearmed"; export function resolvePaginationStopReason( boundaryUnchanged: boolean, hasMore: boolean, ): PaginationStopReason { if (!hasMore) return "exhausted"; return boundaryUnchanged ? "visible-boundary-unchanged" : "visible-boundary-added"; } function resolvePaginationSettleReason( result: LazyLoadSentinelSettleResult, boundaryUnchanged: boolean, hasMore: boolean, sentinelInPreload: boolean, ): PaginationStopReason | null { switch (result.continuation) { case "continued": return null; case "rejected": case "no-progress": return "no-progress"; case "caller-stopped": case "no-more": if (result.continuation === "caller-stopped" && !sentinelInPreload) { return "sentinel-left-preload"; } return resolvePaginationStopReason(boundaryUnchanged, hasMore); default: return result.continuation; } } type RootMarginPixels = { top: number; right: number; bottom: number; left: number; }; function parseRootMarginPixels(rootMargin: string): RootMarginPixels { const values = rootMargin .trim() .split(/\s+/) .map((value) => Number.parseFloat(value)) .map((value) => (Number.isFinite(value) ? value : 0)); const [top = 0, right = top, bottom = top, left = right] = values; return { top, right, bottom, left }; } /** Returns whether the current sentinel geometry is inside the observer's * preload rectangle. This is intentionally measured after a page commit, * rather than inferred from the last IntersectionObserver entry. */ export function isElementInPreloadRegion( scrollRoot: HTMLElement, sentinel: HTMLElement, rootMargin = TRANSCRIPT_SENTINEL_ROOT_MARGIN, ): boolean { const rootRect = scrollRoot.getBoundingClientRect(); const sentinelRect = sentinel.getBoundingClientRect(); const margin = parseRootMarginPixels(rootMargin); return ( sentinelRect.bottom >= rootRect.top - margin.top && sentinelRect.top <= rootRect.bottom + margin.bottom && sentinelRect.right >= rootRect.left - margin.left && sentinelRect.left <= rootRect.right + margin.right ); } function resolveRecoveryVisibility( result: LazyLoadSentinelSettleResult, hasMore: boolean, ): boolean | null { if (result.continuation === "stale") return null; if (hasMore && (result.continuation === "rejected" || result.continuation === "no-progress")) { return true; } if (result.count > 0 || !hasMore) return false; return null; } function usePaginationRecovery(sessionId: string | null, hasMore: boolean) { const [showRecovery, setShowRecovery] = useState(false); const recoverySessionRef = useRef(sessionId); useEffect(() => { if (recoverySessionRef.current !== sessionId || !sessionId || !hasMore) { setShowRecovery(false); } recoverySessionRef.current = sessionId; }, [hasMore, sessionId]); const reportRecovery = useCallback( (result: LazyLoadSentinelSettleResult) => { const nextVisibility = resolveRecoveryVisibility(result, hasMore); if (nextVisibility !== null) setShowRecovery(nextVisibility); }, [hasMore], ); return { showRecovery, reportRecovery }; } function useSessionEpoch(sessionId: string | null) { const epochRef = useRef(0); const previousSessionIdRef = useRef(sessionId); if (previousSessionIdRef.current !== sessionId) { previousSessionIdRef.current = sessionId; epochRef.current += 1; } return epochRef; } function reportPaginationSettleDebug(params: { result: LazyLoadSentinelSettleResult; sessionId: string | null; request: PaginationRequest | null; items: RenderItem[]; scrollRoot: HTMLElement | null; sentinel: HTMLElement | null; hasMore: boolean; }) { const request = params.request; if (!isDebug() || !request?.debug) return; const { result, scrollRoot, sentinel } = params; const requestDebug = request.debug; const boundaryAfter = getOldestVisibleBoundaryKey(params.items); const boundaryUnchanged = request.boundaryBefore === boundaryAfter; const sentinelInPreload = Boolean( scrollRoot && sentinel && isElementInPreloadRegion(scrollRoot, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN), ); paginationDebug("older page settled", { sessionId: params.sessionId, trigger: "top-intersection", generation: requestDebug.generation, loadedCount: result.count, boundaryBefore: request.boundaryBefore, boundaryAfter, scrollTopBefore: requestDebug.scrollTopBefore, scrollTopAfter: scrollRoot?.scrollTop ?? null, scrollHeightBefore: requestDebug.scrollHeightBefore, scrollHeightAfter: scrollRoot?.scrollHeight ?? null, continued: result.continuation === "continued", stopReason: resolvePaginationSettleReason( result, boundaryUnchanged, params.hasMore, sentinelInPreload, ), continuation: result.continuation, }); } type PrependScrollState = { scrollHeight: number; scrollTop: number; anchorKey: string | null; anchorTop: number | null; }; /** Captures the reader's current position between older-history requests. */ function usePrependScrollStateCapture({ scrollRef, isLoadingMoreRef, olderLoadPendingRef, newestItemKeyRef, scrollState, }: { scrollRef: React.RefObject; isLoadingMoreRef: React.MutableRefObject; olderLoadPendingRef: React.MutableRefObject; newestItemKeyRef: React.MutableRefObject; scrollState: React.MutableRefObject; }) { useEffect(() => { const element = scrollRef.current; if (!element) return; const capture = () => { if (olderLoadPendingRef.current || isLoadingMoreRef.current) return; scrollState.current = capturePrependScrollState(element, newestItemKeyRef.current); }; capture(); element.addEventListener("scroll", capture, { passive: true }); return () => element.removeEventListener("scroll", capture); }, [isLoadingMoreRef, newestItemKeyRef, olderLoadPendingRef, scrollRef, scrollState]); } /** * Continuously captures scroll state via scroll listener. * On a genuine prepend (older messages loaded above the current view, so the * oldest non-synthetic item's identity changes), restores scroll position so * the user stays at the same visual spot. A plain append (new item count grows * but the oldest real item is unchanged) is left alone — that's the * auto-scroll hook's concern, not this one's. Skipped while a user-initiated * programmatic scroll (scroll-to-start / scroll-to-last-prompt) is in flight * — otherwise writing a stale captured `scrollTop` mid-animation * interrupts/cancels the user's smooth scroll and can leave the transcript at * the wrong position. */ function useScrollPositionOnPrepend( scrollRef: React.RefObject, items: RenderItem[], isLoadingMore: boolean, isProgrammaticScrollLocked: () => boolean, ): () => void { const scrollState = useRef({ scrollHeight: 0, scrollTop: 0, anchorKey: null, anchorTop: null, }); const isLoadingMoreRef = useRef(isLoadingMore); const olderLoadPendingRef = useRef(false); const newestItemKeyRef = useRef(getNewestNonSyntheticItemKey(items)); isLoadingMoreRef.current = isLoadingMore; if (isLoadingMore) olderLoadPendingRef.current = true; newestItemKeyRef.current = getNewestNonSyntheticItemKey(items); usePrependScrollStateCapture({ scrollRef, isLoadingMoreRef, olderLoadPendingRef, newestItemKeyRef, scrollState, }); const prevItemCountRef = useRef(items.length); const prevFirstKeyRef = useRef(getOldestNonSyntheticItemKey(items)); const beginOlderLoad = useCallback(() => { if (olderLoadPendingRef.current) return; const el = scrollRef.current; if (el) scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current); olderLoadPendingRef.current = true; }, [scrollRef]); useLayoutEffect(() => { const el = scrollRef.current; const nextFirstKey = getOldestNonSyntheticItemKey(items); const identityPrepend = !!el && isPrependUpdate({ prevItemCount: prevItemCountRef.current, nextItemCount: items.length, prevFirstKey: prevFirstKeyRef.current, nextFirstKey, }); const olderLoadSettled = olderLoadPendingRef.current && !isLoadingMore; const prepend = olderLoadSettled || identityPrepend; prevItemCountRef.current = items.length; prevFirstKeyRef.current = nextFirstKey; if (olderLoadSettled) olderLoadPendingRef.current = false; if (!el || !prepend || isProgrammaticScrollLocked()) return; const prev = scrollState.current; const anchor = findMessageRow(el, prev.anchorKey); if (anchor && prev.anchorTop !== null) { el.scrollTop += anchor.getBoundingClientRect().top - prev.anchorTop; } else { const delta = el.scrollHeight - prev.scrollHeight; if (delta > 0) el.scrollTop = prev.scrollTop + delta; } scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current); }, [items, scrollRef, isLoadingMore, isProgrammaticScrollLocked]); return beginOlderLoad; } function getOldestNonSyntheticItemKey(items: RenderItem[]): string | null { const oldestRealItem = items.find((item) => { if (item.type === "prepare_progress" || item.type === "agent_error_notice") return false; return item.type !== "message" || item.message.id !== TASK_DESCRIPTION_SYNTHETIC_ID; }); return oldestRealItem ? getItemKey(oldestRealItem) : null; } function getNewestNonSyntheticItemKey(items: RenderItem[]): string | null { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]; if (item.type === "prepare_progress" || item.type === "agent_error_notice") continue; if (item.type === "message" && item.message.id === TASK_DESCRIPTION_SYNTHETIC_ID) continue; return getItemKey(item); } return null; } function findMessageRow(scrollRoot: HTMLElement, itemKey: string | null): HTMLElement | null { if (!itemKey) return null; const expectedId = `msg-${itemKey}`; return ( Array.from(scrollRoot.querySelectorAll("[id^='msg-']")).find( (candidate) => candidate.id === expectedId, ) ?? null ); } function capturePrependScrollState(scrollRoot: HTMLElement, anchorKey: string | null) { const anchor = findMessageRow(scrollRoot, anchorKey); return { scrollHeight: scrollRoot.scrollHeight, scrollTop: scrollRoot.scrollTop, anchorKey, anchorTop: anchor?.getBoundingClientRect().top ?? null, }; } /** * Observes a sentinel element at the top of the list to trigger lazy loading. * Re-arms only while the committed sentinel remains inside the preload * region, which crosses both collapsed activity and standalone messages. The * transcript does not join an in-flight request. The explicit button is * rendered only as the recovery path for errors and no-op pages. */ // eslint-disable-next-line max-lines-per-function -- request epoch, geometry, and recovery must stay synchronized here. function useLazyLoadSentinel(params: { scrollRef: React.RefObject; items: RenderItem[]; sessionId: string | null; hasMore: boolean; blocked: boolean; isLoadingMore: boolean; loadMore: () => Promise; }) { const { scrollRef, items, sessionId, hasMore, blocked, isLoadingMore, loadMore } = params; const itemsRef = useRef(items); itemsRef.current = items; const hasMoreRef = useRef(hasMore); hasMoreRef.current = hasMore; const sentinelNodeRef = useRef(null); const requestGenerationRef = useRef(0); const requestRef = useRef(null); const sessionEpochRef = useSessionEpoch(sessionId); const { showRecovery, reportRecovery } = usePaginationRecovery(sessionId, hasMore); const showRecoveryRef = useRef(showRecovery); showRecoveryRef.current = showRecovery; const reportSettle = useCallback( (result: LazyLoadSentinelSettleResult) => { const request = requestRef.current; const staleSession = request !== null && request.sessionEpoch !== sessionEpochRef.current; if (!staleSession) reportRecovery(result); reportPaginationSettleDebug({ result, sessionId, request, items: itemsRef.current, scrollRoot: scrollRef.current, sentinel: sentinelNodeRef.current, hasMore: hasMoreRef.current, }); }, [reportRecovery, scrollRef, sessionId], ); const loadPage = useCallback(async () => { const request: PaginationRequest = { boundaryBefore: getOldestVisibleBoundaryKey(itemsRef.current), sessionEpoch: sessionEpochRef.current, }; requestRef.current = request; if (isDebug()) { const scroller = scrollRef.current; request.debug = { generation: ++requestGenerationRef.current, scrollTopBefore: scroller?.scrollTop ?? null, scrollHeightBefore: scroller?.scrollHeight ?? null, }; paginationDebug("older page started", { sessionId, trigger: "top-intersection", generation: request.debug.generation, boundaryBefore: request.boundaryBefore, scrollTopBefore: request.debug.scrollTopBefore, scrollHeightBefore: request.debug.scrollHeightBefore, }); } return loadMore(); }, [loadMore, scrollRef, sessionId]); const isRequestCurrent = useCallback(() => { const request = requestRef.current; return request === null || request.sessionEpoch === sessionEpochRef.current; }, []); const shouldContinueWhileIntersecting = useCallback(() => { const scrollRoot = scrollRef.current; const sentinel = sentinelNodeRef.current; return Boolean( hasMoreRef.current && scrollRoot && sentinel && isElementInPreloadRegion(scrollRoot, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN), ); }, [scrollRef]); const sharedSentinel = useSharedLazyLoadSentinel( scrollRef, hasMore, blocked, isLoadingMore, loadPage, { rootMargin: TRANSCRIPT_SENTINEL_ROOT_MARGIN, rearmWhileIntersecting: true, shouldContinueWhileIntersecting, // Continuation and lifecycle/input eligibility both require the // sentinel to remain inside the transcript's current preload region. isCurrentGeometryEligible: shouldContinueWhileIntersecting, onLoadSettled: reportSettle, isRequestCurrent, }, ); const sentinelRef = useCallback( (node: HTMLDivElement | null) => { sentinelNodeRef.current = node; sharedSentinel.sentinelRef(node); }, [sharedSentinel.sentinelRef], ); const onUserGesture = useCallback(() => { if (!showRecoveryRef.current) sharedSentinel.onUserGesture(); }, [sharedSentinel.onUserGesture]); const recheck = useCallback(() => { if (!showRecoveryRef.current) sharedSentinel.recheck(); }, [sharedSentinel.recheck]); return { sentinelRef, onUserGesture, retry: sharedSentinel.retry, recheck, showRecovery, }; } /** * Treats an actual upward movement as fresh pagination intent. This is * modality-independent (wheel, keyboard, scrollbar, or touch) and only calls * the shared sentinel's guarded retry path; normal armed scrolling remains * owned by IntersectionObserver. */ function useRetryPaginationOnUpwardScroll( scrollRef: React.RefObject, onUserGesture: () => void, recheck: () => void, isProgrammaticScrollLocked: () => boolean, ) { useEffect(() => { const scroller = scrollRef.current; if (!scroller) return; let previousScrollTop = scroller.scrollTop; let touchStartY: number | null = null; let touchHandled = false; const canRecheckHardTop = () => scroller.scrollTop <= 0 && !isProgrammaticScrollLocked(); const onScroll = () => { const nextScrollTop = scroller.scrollTop; const movedUp = nextScrollTop < previousScrollTop; previousScrollTop = nextScrollTop; if (movedUp && !isProgrammaticScrollLocked()) onUserGesture(); }; const onWheel = (event: WheelEvent) => { if (event.deltaY < 0 && canRecheckHardTop()) recheck(); }; const onKeyDown = (event: KeyboardEvent) => { const movesUp = event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home"; if (movesUp && canRecheckHardTop()) recheck(); }; const onTouchStart = (event: TouchEvent) => { touchStartY = event.touches[0]?.clientY ?? null; touchHandled = false; }; const onTouchMove = (event: TouchEvent) => { const nextY = event.touches[0]?.clientY; if ( !touchHandled && touchStartY !== null && nextY !== undefined && nextY > touchStartY && canRecheckHardTop() ) { touchHandled = true; recheck(); } }; scroller.addEventListener("scroll", onScroll, { passive: true }); scroller.addEventListener("wheel", onWheel, { passive: true }); scroller.addEventListener("keydown", onKeyDown); scroller.addEventListener("touchstart", onTouchStart, { passive: true }); scroller.addEventListener("touchmove", onTouchMove, { passive: true }); return () => { scroller.removeEventListener("scroll", onScroll); scroller.removeEventListener("wheel", onWheel); scroller.removeEventListener("keydown", onKeyDown); scroller.removeEventListener("touchstart", onTouchStart); scroller.removeEventListener("touchmove", onTouchMove); }; }, [isProgrammaticScrollLocked, onUserGesture, recheck, scrollRef]); } /** Rechecks pagination after Dockview restores a hidden panel's scroll offset. * Two frames place this after SessionPanelContent's one-frame restore without * treating the initial visible mount as pagination intent. */ function useRecheckPaginationOnVisible(isVisible: boolean, recheck: () => void) { const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible); useEffect(() => { if (!isVisible || !activationPendingRef.current) return; return scheduleAfterPanelRestore(() => { if (!isVisibleRef.current) return; activationPendingRef.current = false; recheck(); }); }, [isVisible, recheck]); } /** Duration a programmatic scroll's guard stays held if the browser never * reports `scrollend` (Safari lacks it as of writing; some scroll targets * fire it inconsistently). Comfortably longer than a `scrollIntoView` * smooth-scroll's typical animation so the guard doesn't outlive it in the * common case, but still bounded so a missed event can't wedge auto-scroll * off indefinitely. */ const PROGRAMMATIC_SCROLL_GUARD_MS = 1000; /** * Auto-scrolls to bottom when new messages arrive (if user is near bottom) * or when the agent starts working (isWorking transitions to true) — unless * auto-scroll is disabled for this session, in which case both are * suppressed and the current position is continuously persisted so it * survives a dockview panel remount. Re-enabling catches the view up to the * bottom if the transcript progressed past it while disabled (see * `useCatchUpOnReEnable`). * * `isProgrammaticScrollLocked` suppresses both triggers while a user-initiated * scroll-to-start/scroll-to-last-prompt animation (see * `useProgrammaticScrollGuard`) is still in flight — otherwise a message * streaming in mid-animation can silently snap the transcript back to the * bottom, cancelling the user's action. */ export function useAutoScroll(params: { scrollRef: React.RefObject; messages: Message[]; isWorking: boolean; sessionId: string | null; enabled: boolean; hasUnreadDivider: boolean; isProgrammaticScrollLocked: () => boolean; isVisible?: boolean; initialPlacementPending?: boolean; }) { const { scrollRef, messages, isWorking, sessionId, enabled, hasUnreadDivider, isProgrammaticScrollLocked, isVisible = true, initialPlacementPending = false, } = params; const storeApi = useAppStoreApi(); const isNearBottomRef = useRef(true); const prevIsWorkingRef = useRef(isWorking); const isVisibleRef = useRef(isVisible); const enabledRef = useRef(enabled); const hasUnreadDividerRef = useRef(hasUnreadDivider); const sessionIdRef = useRef(sessionId); const isProgrammaticScrollLockedRef = useRef(isProgrammaticScrollLocked); isVisibleRef.current = isVisible; enabledRef.current = enabled; hasUnreadDividerRef.current = hasUnreadDivider; sessionIdRef.current = sessionId; isProgrammaticScrollLockedRef.current = isProgrammaticScrollLocked; const resyncIsNearBottom = useCallback(() => { const el = scrollRef.current; if (!el || !isVisibleRef.current) return; isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100; }, [scrollRef]); const markNotNearBottom = useCallback(() => { isNearBottomRef.current = false; }, []); useTranscriptViewportResize({ scrollRef, sessionId, enabled, isVisible, initialPlacementPending, isProgrammaticScrollLocked, }); usePersistedTranscriptScroll({ scrollRef, sessionId, storeApi, resyncIsNearBottom, enabled, isWorking, isVisible, isVisibleRef, messages, initialPlacementPending, }); useAutoScrollOnContent({ scrollRef, messages, isWorking, sessionId, enabled, hasUnreadDivider, isNearBottomRef, isVisibleRef, isProgrammaticScrollLocked, prevIsWorkingRef, initialPlacementPending, }); useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef); useCatchUpOnVisible({ scrollRef, isVisible, isNearBottomRef, enabledRef, hasUnreadDividerRef, sessionIdRef, isProgrammaticScrollLockedRef, initialPlacementPending, }); return { isNearBottomRef, resyncIsNearBottom, markNotNearBottom }; } // eslint-disable-next-line max-lines-per-function -- persisted placement coordinates user intent, session resets, and layout recovery. function usePersistedTranscriptScroll({ scrollRef, sessionId, storeApi, resyncIsNearBottom, enabled, isWorking, isVisible, isVisibleRef, messages, initialPlacementPending, }: { scrollRef: React.RefObject; sessionId: string | null; storeApi: ReturnType; resyncIsNearBottom: () => void; enabled: boolean; isWorking: boolean; isVisible: boolean; isVisibleRef: React.RefObject; messages: Message[]; initialPlacementPending: boolean; }) { const frozenScrollTopRef = useRef(null); const userScrollIntentUntilRef = useRef(0); const latestSessionIdRef = useRef(sessionId); if (latestSessionIdRef.current !== sessionId) { latestSessionIdRef.current = sessionId; frozenScrollTopRef.current = null; } useEffect(() => { const el = scrollRef.current; if (!el) return; const markUserScrollIntent = () => { userScrollIntentUntilRef.current = Date.now() + USER_SCROLL_INTENT_WINDOW_MS; }; const clearUserScrollIntent = () => { userScrollIntentUntilRef.current = 0; }; const markScrollKeyIntent = (event: KeyboardEvent) => { if (SCROLL_KEYS.has(event.key)) markUserScrollIntent(); }; /** Persists the container's current scrollTop for the session (used when * auto-scroll is disabled). */ const captureScrollTop = () => { if (sessionId && (isVisibleRef.current || frozenScrollTopRef.current !== null)) { storeApi .getState() .setTranscriptScrollTop(sessionId, frozenScrollTopRef.current ?? el.scrollTop); } }; // Coalesce persisted writes to at most one per animation frame — native // scroll events can fire far more often than that, and each write is a // synchronous sessionStorage.setItem plus a store update. const coalescer = createFrameCoalescer(captureScrollTop); /** Scroll listener: resyncs the near-bottom flag and schedules a * coalesced persistence of the scroll position. */ const onScroll = () => { if (!isVisibleRef.current) return; resyncIsNearBottom(); // A layout change can clamp a disabled transcript's scrollTop and emit a // native scroll event. Only adopt an offset when a recent user gesture // explains the movement; otherwise the layout effect must restore the // frozen offset on the next render. if (!enabled && userScrollIntentUntilRef.current >= Date.now()) { frozenScrollTopRef.current = el.scrollTop; } coalescer.schedule(); }; el.addEventListener("wheel", markUserScrollIntent, { passive: true }); el.addEventListener("touchstart", markUserScrollIntent, { passive: true }); el.addEventListener("touchmove", markUserScrollIntent, { passive: true }); el.addEventListener("pointerdown", markUserScrollIntent, { passive: true }); el.addEventListener("pointerup", clearUserScrollIntent, { passive: true }); el.addEventListener("pointercancel", clearUserScrollIntent, { passive: true }); el.addEventListener("touchend", clearUserScrollIntent, { passive: true }); el.addEventListener("touchcancel", clearUserScrollIntent, { passive: true }); el.addEventListener("keydown", markScrollKeyIntent); el.addEventListener("scrollend", clearUserScrollIntent); el.addEventListener("scroll", onScroll, { passive: true }); return () => { el.removeEventListener("wheel", markUserScrollIntent); el.removeEventListener("touchstart", markUserScrollIntent); el.removeEventListener("touchmove", markUserScrollIntent); el.removeEventListener("pointerdown", markUserScrollIntent); el.removeEventListener("pointerup", clearUserScrollIntent); el.removeEventListener("pointercancel", clearUserScrollIntent); el.removeEventListener("touchend", clearUserScrollIntent); el.removeEventListener("touchcancel", clearUserScrollIntent); el.removeEventListener("keydown", markScrollKeyIntent); el.removeEventListener("scrollend", clearUserScrollIntent); el.removeEventListener("scroll", onScroll); // Final capture on unmount so a disabled session's exact position // survives a dockview panel teardown/remount (e.g. navigating away // and back), even if no scroll event fired right before it, and even // if a coalesced write above was still pending. if (!initialPlacementPending || latestSessionIdRef.current !== sessionId) coalescer.flush(); }; }, [ scrollRef, sessionId, storeApi, resyncIsNearBottom, enabled, frozenScrollTopRef, userScrollIntentUntilRef, initialPlacementPending, ]); // Own the disabled offset across every transcript layout update. Sending a // prompt can briefly shrink the scroll range before the new message row is // committed, which makes the browser clamp scrollTop even with // overflow-anchor disabled. Keep the pre-update offset and reapply it after // each message/working-state render so that transient clamp cannot move // the reader. Real user scroll events update the owned offset above. useLayoutEffect(() => { if (initialPlacementPending) return; const el = scrollRef.current; if (!el || !isVisibleRef.current) return; const wasEnabled = frozenScrollTopRef.current === null || enabled; if (enabled) { frozenScrollTopRef.current = null; return; } if (wasEnabled || frozenScrollTopRef.current === null) { frozenScrollTopRef.current = el.scrollTop; return; } el.scrollTop = frozenScrollTopRef.current; }, [enabled, isWorking, isVisible, messages, scrollRef, initialPlacementPending]); } function useAutoScrollOnContent({ scrollRef, messages, isWorking, sessionId, enabled, hasUnreadDivider, isNearBottomRef, isVisibleRef, isProgrammaticScrollLocked, prevIsWorkingRef, initialPlacementPending, }: { scrollRef: React.RefObject; messages: Message[]; isWorking: boolean; sessionId: string | null; enabled: boolean; hasUnreadDivider: boolean; isNearBottomRef: React.RefObject; isVisibleRef: React.RefObject; isProgrammaticScrollLocked: () => boolean; prevIsWorkingRef: React.MutableRefObject; initialPlacementPending: boolean; }) { const lastLoggedMessageCountRef = useRef(messages.length); // When isWorking transitions to true, force scroll to bottom (unless // disabled, locked, or a layout rebuild scroll restore is pending). useEffect(() => { if ( isWorking && !prevIsWorkingRef.current && isVisibleRef.current && !initialPlacementPending && shouldAutoScrollToBottom({ isNearBottom: !hasUnreadDivider, isProgrammaticScrollLocked: isProgrammaticScrollLocked(), hasPendingLayoutRestore: useDockviewStore.getState().pendingChatScrollTop !== null, }) && enabled ) { const el = scrollRef.current; if (el) { scrollNativeToBottom(el); isNearBottomRef.current = true; placementDebug("work-start bottom", { sessionId }); } } prevIsWorkingRef.current = isWorking; }, [ hasUnreadDivider, isWorking, sessionId, scrollRef, enabled, isProgrammaticScrollLocked, initialPlacementPending, ]); // Auto-scroll on new messages if near bottom (unless disabled, locked, or a // layout rebuild scroll restore is pending). useLayoutEffect(() => { const el = scrollRef.current; if (!el || !isVisibleRef.current || initialPlacementPending) return; if ( shouldAutoScrollToBottom({ isNearBottom: isNearBottomRef.current, isProgrammaticScrollLocked: isProgrammaticScrollLocked(), hasPendingLayoutRestore: useDockviewStore.getState().pendingChatScrollTop !== null, }) && enabled ) { scrollNativeToBottom(el); if (messages.length !== lastLoggedMessageCountRef.current) { placementDebug("message-update bottom", { sessionId, messageCount: messages.length, }); } } lastLoggedMessageCountRef.current = messages.length; }, [ messages, sessionId, scrollRef, enabled, isProgrammaticScrollLocked, initialPlacementPending, ]); } function useCatchUpOnVisible({ scrollRef, isVisible, isNearBottomRef, enabledRef, hasUnreadDividerRef, sessionIdRef, isProgrammaticScrollLockedRef, initialPlacementPending, }: { scrollRef: React.RefObject; isVisible: boolean; isNearBottomRef: React.RefObject; enabledRef: React.RefObject; hasUnreadDividerRef: React.RefObject; sessionIdRef: React.RefObject; isProgrammaticScrollLockedRef: React.RefObject<() => boolean>; initialPlacementPending: boolean; }) { const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible); useEffect(() => { if (!isVisible || !activationPendingRef.current) return; if (initialPlacementPending) { activationPendingRef.current = false; return; } return scheduleAfterPanelRestore(() => { if (!isVisibleRef.current) return; activationPendingRef.current = false; if ( !enabledRef.current || hasUnreadDividerRef.current || !isNearBottomRef.current || (isProgrammaticScrollLockedRef.current?.() ?? false) ) return; const dockviewState = useDockviewStore.getState(); if ( dockviewState.pendingChatScrollTop !== null || dockviewState.scrollTarget?.sessionId === sessionIdRef.current ) { return; } const el = scrollRef.current; if (!el) return; scrollNativeToBottom(el); isNearBottomRef.current = true; }); }, [initialPlacementPending, isVisible, scrollRef]); } /** * Catches the view up to the bottom when the user re-enables auto-scroll, * but only if the transcript actually appended content while disabled — * never on a manual scroll or a prepend, which don't change baselineRef's * identity. A one-time mount-time init covers panels that remount already * disabled (no live transition to capture a baseline from). */ function useCatchUpOnReEnable( scrollRef: React.RefObject, messages: Message[], enabled: boolean, isNearBottomRef: React.RefObject, ) { const prevEnabledRef = useRef(enabled); const baselineRef = useRef<{ count: number; lastId: string | null; lastUpdatedAt: string | undefined; } | null>(null); const hasInitializedBaselineRef = useRef(false); useEffect(() => { const wasEnabled = prevEnabledRef.current; prevEnabledRef.current = enabled; /** Snapshots the current transcript tail (count, last id, last updated * timestamp) as the disable-time baseline for detecting progression. */ const captureBaseline = () => { const last = messages[messages.length - 1]; baselineRef.current = { count: messages.length, lastId: last?.id ?? null, lastUpdatedAt: last?.updated_at, }; }; // First run: if the panel mounted already disabled (e.g. a session // opened, or remounted via a dockview rebuild, with a persisted // disabled preference), there was no in-process disable transition to // capture a baseline from — establish one now from whatever the // transcript looks like at mount, so a later re-enable can still detect // genuine progression instead of never catching up. if (!hasInitializedBaselineRef.current) { hasInitializedBaselineRef.current = true; if (!enabled) captureBaseline(); return; } if (wasEnabled === enabled) return; if (!enabled) { // Transitioning to disabled: capture the baseline used to detect real // progression (a new row, or the trailing row streaming more content) // once re-enabled. captureBaseline(); return; } // Transitioning to enabled. const el = scrollRef.current; const baseline = baselineRef.current; baselineRef.current = null; if (!el || !baseline) return; const lastNow = messages[messages.length - 1]; const appendedSinceDisable = hasTranscriptAppendedSinceBaseline({ baselineCount: baseline.count, currentCount: messages.length, baselineLastId: baseline.lastId, currentLastId: lastNow?.id ?? null, baselineLastUpdatedAt: baseline.lastUpdatedAt, currentLastUpdatedAt: lastNow?.updated_at, }); const isAtBottom = !hasTranscriptProgressedPastView({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, }); if ( shouldCatchUpOnAutoScrollEnable({ wasEnabled, nowEnabled: enabled, appendedSinceDisable, isAtBottom, }) ) { scrollNativeToBottom(el); isNearBottomRef.current = true; } }, [enabled, scrollRef, messages]); } /** * Guards a user-initiated smooth scroll (scroll-to-start / scroll-to-last- * prompt) against `useAutoScroll`'s follow-bottom behavior firing mid-flight * — e.g. the agent streams a new message while the scroll is still animating, * which would otherwise snap the transcript back to the bottom and silently * cancel the user's action. Held until the scroll settles (native `scrollend` * where supported, a bounded timeout fallback otherwise), then resyncs the * near-bottom state from the ACTUAL scroll position — never assumed — before * releasing. */ function useProgrammaticScrollGuard( scrollRef: React.RefObject, lockedRef: React.RefObject, resyncIsNearBottom: () => void, ) { const cleanupRef = useRef<(() => void) | null>(null); const release = useCallback(() => { cleanupRef.current?.(); cleanupRef.current = null; lockedRef.current = false; resyncIsNearBottom(); }, [lockedRef, resyncIsNearBottom]); const runGuardedScroll = useCallback( (performScroll: () => void) => { cleanupRef.current?.(); lockedRef.current = true; performScroll(); const timeoutId = window.setTimeout(release, PROGRAMMATIC_SCROLL_GUARD_MS); const el = scrollRef.current; el?.addEventListener("scrollend", release, { once: true }); cleanupRef.current = () => { window.clearTimeout(timeoutId); el?.removeEventListener("scrollend", release); }; }, [scrollRef, lockedRef, release], ); useEffect(() => () => cleanupRef.current?.(), []); return runGuardedScroll; } /** * Returns a `scrollToMessage(messageId, options?)` callback that scrolls the * message's row into view (start- or center-aligned) under the programmatic * scroll guard, then watches the animation and force-lands the alignment if * the browser settles misaligned. Returns false when the row isn't rendered * yet; a superseding request invalidates in-flight verification. When the * requested alignment exceeds the scroll range, the nearest reachable * position is accepted. */ export function useScrollToMessage( scrollRef: React.RefObject, runGuardedScroll: (performScroll: () => void) => void, ) { // Bumped on every scrollToMessage call; in-flight verifiers of a superseded // request bail on the next frame so stale work can never land the // transcript on an older prompt after a newer one consumed. const generationRef = useRef(0); return useCallback( (messageId: string, options?: { align?: "start" | "center"; behavior?: "smooth" | "auto" }) => { // Advance the generation BEFORE the lookup: a superseding request whose // row is not rendered yet still returns false, but must invalidate any // in-flight verifier so it can never force-land on a stale prompt. const generation = ++generationRef.current; const selector = `[id="msg-${CSS.escape(messageId)}"]`; const el = scrollRef.current?.querySelector(selector); if (!el) return false; const alignStart = options?.align === "start"; runGuardedScroll(() => { el.scrollIntoView({ block: alignStart ? "start" : "center", behavior: options?.behavior ?? "smooth", }); const container = scrollRef.current; if (!container) return; const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0; // A dockview panel re-show (the prompt-history jump activates the // chat) makes SessionPanelContent restore its saved scrollTop in a // rAF that can cancel the scroll, and some runtimes no-op a smooth // scrollIntoView entirely. Watch a bounded frame window: follow an // in-progress animation toward the target, and force-land the // alignment whenever the container settles misaligned (movement // stopped short or never started). Bail immediately if a newer // scroll request superseded this one. let frames = 0; let lastAbsDelta = Infinity; /** Frame-watch verifier: follows an in-progress animation toward the * target and force-lands the alignment once the container settles * misaligned; bails when superseded or the nodes disconnect. */ const verify = () => { frames += 1; if (frames > 30 || !container.isConnected || !el.isConnected) return; if (generationRef.current !== generation) return; // superseded const elementRect = el.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const delta = alignStart ? elementRect.top - containerRect.top - margin : elementRect.top + elementRect.height / 2 - (containerRect.top + containerRect.height / 2) - margin / 2; const absDelta = Math.abs(delta); if (absDelta <= 2) return; // aligned if (absDelta < lastAbsDelta) { // Animation still moving toward the target — keep watching. lastAbsDelta = absDelta; requestAnimationFrame(verify); return; } // Settled (or never moved): land the requested alignment. Browsers // clamp scrollTop at the scroll range, so accept that boundary as // the nearest reachable position instead of retrying forever. const hasScrollMetrics = container.scrollHeight > 0 || container.clientHeight > 0; const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); const desiredScrollTop = container.scrollTop + delta; const nextScrollTop = hasScrollMetrics ? Math.min(maxScrollTop, Math.max(0, desiredScrollTop)) : desiredScrollTop; if (hasScrollMetrics && nextScrollTop === container.scrollTop) return; container.scrollTop = nextScrollTop; lastAbsDelta = Infinity; requestAnimationFrame(verify); }; requestAnimationFrame(verify); }); return true; }, [runGuardedScroll, scrollRef], ); } type InitialScrollApplyParams = { element: HTMLDivElement; sessionId: string | null; enabled: boolean; storeApi: ReturnType; didInitialScroll: React.RefObject; activationPendingRef: React.RefObject; isVisibleRef: React.RefObject; isNearBottomRef: React.RefObject; envSwitchPlacementToken: number | null; hasUnreadDivider: boolean; isProgrammaticScrollLocked: () => boolean; phase: "provisional" | "final"; }; function markInitialScrollConsumed( didInitialScroll: React.RefObject, activationPendingRef: React.RefObject, ): void { didInitialScroll.current = true; activationPendingRef.current = false; } function completeEnvSwitchPlacement(envSwitchPlacementToken: number | null): void { if (envSwitchPlacementToken !== null) { if (isDebug()) { placementDebug("placement request completed", { token: envSwitchPlacementToken }); } useDockviewStore.getState().completePendingChatInitialPlacement(envSwitchPlacementToken); } } function isCurrentEnvSwitchPlacement( pending: { sessionId: string; token: number } | null, sessionId: string | null, token: number | null, ): boolean { if (token === null) return true; return pending?.token === token && pending.sessionId === sessionId; } export type CompetingInitialScrollOwner = | "layout-restore" | "explicit-target" | "unread-divider" | "programmatic-scroll"; export function resolveCompetingInitialScrollOwner(params: { hasPendingLayoutRestore: boolean; hasExplicitScrollTarget: boolean; hasUnreadDivider: boolean; isProgrammaticScrollLocked: () => boolean; }): CompetingInitialScrollOwner | null { if (params.hasPendingLayoutRestore) return "layout-restore"; if (params.hasExplicitScrollTarget) return "explicit-target"; if (params.hasUnreadDivider) return "unread-divider"; if (params.isProgrammaticScrollLocked()) return "programmatic-scroll"; return null; } function resolveInitialPlacementOwner(enabled: boolean): "bottom" | "saved-position" { return enabled ? "bottom" : "saved-position"; } function reportInitialPlacement(params: { phase: InitialScrollApplyParams["phase"]; element: HTMLDivElement; sessionId: string | null; token: number | null; enabled: boolean; }): void { if (!isDebug()) return; placementDebug(`${params.phase} placement`, { sessionId: params.sessionId, token: params.token, owner: resolveInitialPlacementOwner(params.enabled), scrollTop: params.element.scrollTop, scrollHeight: params.element.scrollHeight, clientHeight: params.element.clientHeight, }); } function completeFinalEnvSwitchPlacement( phase: InitialScrollApplyParams["phase"], token: number | null, ): void { if (phase === "final") completeEnvSwitchPlacement(token); } function finishAppliedInitialPlacement(params: { phase: InitialScrollApplyParams["phase"]; enabled: boolean; scrollTop: number; element: HTMLDivElement; envSwitchPlacementToken: number | null; syncNearBottom: () => void; }): void { if (params.phase === "provisional") return; if (params.enabled || params.scrollTop <= 0 || params.element.scrollTop >= params.scrollTop - 1) { completeEnvSwitchPlacement(params.envSwitchPlacementToken); return; } scheduleClampedScrollRestore({ element: params.element, targetScrollTop: params.scrollTop, onApply: params.syncNearBottom, onComplete: () => completeEnvSwitchPlacement(params.envSwitchPlacementToken), }); } function applyInitialScrollPosition(params: InitialScrollApplyParams): void { const { element, sessionId, enabled, storeApi, didInitialScroll, activationPendingRef, isVisibleRef, isNearBottomRef, envSwitchPlacementToken, hasUnreadDivider, isProgrammaticScrollLocked, phase, } = params; if (!isVisibleRef.current) return; const dockviewState = useDockviewStore.getState(); if ( !isCurrentEnvSwitchPlacement( dockviewState.pendingChatInitialPlacement, sessionId, envSwitchPlacementToken, ) ) return; const hasPendingLayoutRestore = dockviewState.pendingChatScrollTop !== null; const hasExplicitScrollTarget = sessionId !== null && dockviewState.scrollTarget?.sessionId === sessionId; const competingOwner = resolveCompetingInitialScrollOwner({ hasPendingLayoutRestore, hasExplicitScrollTarget, hasUnreadDivider, isProgrammaticScrollLocked, }); if (competingOwner) { if (phase === "provisional") return; placementDebug(`${phase} placement delegated`, { sessionId, owner: competingOwner, token: envSwitchPlacementToken, }); markInitialScrollConsumed(didInitialScroll, activationPendingRef); completeEnvSwitchPlacement(envSwitchPlacementToken); return; } const savedScrollTop = sessionId ? (storeApi.getState().transcriptAutoScroll.scrollTopBySessionId[sessionId] ?? getStoredAutoScrollTop(sessionId) ?? undefined) : undefined; const scrollTop = resolveNativeInitialScrollTop({ enabled, hasPendingLayoutRestore, savedScrollTop, scrollHeight: element.scrollHeight, }); if (phase === "final") { markInitialScrollConsumed(didInitialScroll, activationPendingRef); } if (scrollTop === null) { completeFinalEnvSwitchPlacement(phase, envSwitchPlacementToken); return; } const syncNearBottom = () => { isNearBottomRef.current = !hasTranscriptProgressedPastView({ scrollTop, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, }); }; element.scrollTop = scrollTop; syncNearBottom(); reportInitialPlacement({ phase, element, sessionId, token: envSwitchPlacementToken, enabled, }); finishAppliedInitialPlacement({ phase, enabled, scrollTop, element, envSwitchPlacementToken, syncNearBottom, }); } function shouldSkipProvisionalPlacement( phase: InitialScrollApplyParams["phase"], hasUnreadDivider: boolean, appliedToken: number | null, currentToken: number | null, ): boolean { return phase === "provisional" && (hasUnreadDivider || appliedToken === currentToken); } function useInitialPlacementLatches(envSwitchPlacementToken: number | null) { const didInitialScroll = useRef(false); const lastTokenRef = useRef(null); const provisionalTokenRef = useRef(null); if (envSwitchPlacementToken !== null && envSwitchPlacementToken !== lastTokenRef.current) { lastTokenRef.current = envSwitchPlacementToken; provisionalTokenRef.current = null; didInitialScroll.current = false; } return { didInitialScroll, provisionalTokenRef }; } type InitialPlacementLatches = ReturnType; type InitialScrollPositionParams = { scrollRef: React.RefObject; itemCount: number; sessionId: string | null; enabled: boolean; hasUnreadDivider: boolean; isNearBottomRef: React.RefObject; isVisible: boolean; historyRefreshPending: boolean; envSwitchPlacementToken: number | null; isRestoringLayout: boolean; isProgrammaticScrollLocked: () => boolean; placementLatches: InitialPlacementLatches; }; type InitialScrollPositionEffectParams = Omit & { storeApi: ReturnType; didInitialScroll: React.RefObject; provisionalTokenRef: React.RefObject; isVisibleRef: React.RefObject; activationPendingRef: React.RefObject; }; function runInitialScrollPositionEffect( params: InitialScrollPositionEffectParams, ): void | (() => void) { const { scrollRef, itemCount, sessionId, enabled, hasUnreadDivider, isNearBottomRef, isVisible, historyRefreshPending, envSwitchPlacementToken, isRestoringLayout, isProgrammaticScrollLocked, storeApi, didInitialScroll, provisionalTokenRef, isVisibleRef, activationPendingRef, } = params; if (!isVisible || (envSwitchPlacementToken !== null && isRestoringLayout)) return; const hasEmptyTranscript = itemCount === 0; if (envSwitchPlacementToken !== null && hasEmptyTranscript) { if (historyRefreshPending) return; markInitialScrollConsumed(didInitialScroll, activationPendingRef); completeEnvSwitchPlacement(envSwitchPlacementToken); return; } if (didInitialScroll.current || hasEmptyTranscript) return; const element = scrollRef.current; if (!element) return; const phase = envSwitchPlacementToken !== null && historyRefreshPending ? "provisional" : "final"; if ( shouldSkipProvisionalPlacement( phase, hasUnreadDivider, provisionalTokenRef.current, envSwitchPlacementToken, ) ) { return; } const applyInitialScroll = () => { applyInitialScrollPosition({ element, sessionId, enabled, storeApi, didInitialScroll, activationPendingRef, isVisibleRef, isNearBottomRef, envSwitchPlacementToken, hasUnreadDivider, isProgrammaticScrollLocked, phase, }); if (phase === "provisional") provisionalTokenRef.current = envSwitchPlacementToken; }; if (activationPendingRef.current) return scheduleAfterPanelRestore(applyInitialScroll); applyInitialScroll(); } function useInitialScrollPosition({ scrollRef, itemCount, sessionId, enabled, hasUnreadDivider, isNearBottomRef, isVisible, historyRefreshPending, envSwitchPlacementToken, isRestoringLayout, isProgrammaticScrollLocked, placementLatches, }: InitialScrollPositionParams) { const storeApi = useAppStoreApi(); const { didInitialScroll, provisionalTokenRef } = placementLatches; const { isVisibleRef, activationPendingRef } = useActivationPending( isVisible, envSwitchPlacementToken, ); useEffect( () => runInitialScrollPositionEffect({ scrollRef, itemCount, sessionId, enabled, hasUnreadDivider, isNearBottomRef, isVisible, historyRefreshPending, envSwitchPlacementToken, isRestoringLayout, isProgrammaticScrollLocked, storeApi, didInitialScroll, provisionalTokenRef, isVisibleRef, activationPendingRef, }), [ itemCount, sessionId, enabled, hasUnreadDivider, isNearBottomRef, storeApi, isVisible, historyRefreshPending, envSwitchPlacementToken, isRestoringLayout, isProgrammaticScrollLocked, scrollRef, ], ); } type NativeScrollManagementParams = { scrollRef: React.RefObject; items: RenderItem[]; messages: Message[]; isWorking: boolean; sessionId: string | null; enabled: boolean; hasUnreadDivider: boolean; /** Initial/refetch loading: the sentinel's hard block (never fires, never joins). */ messagesLoading: boolean; historyRefreshPending?: boolean; hasMore: boolean; isLoadingMore: boolean; loadMore: () => Promise; isVisible: boolean; }; /** * Composes every native-renderer scroll behavior — auto-follow-bottom (honoring * the session's auto-scroll toggle, with position persistence and re-enable * catch-up), the programmatic-scroll guard that protects scroll-to-start/ * scroll-to-last-prompt from streaming-content races, prepend scroll * preservation, lazy-load sentinel observation, and the initial scroll * position — behind one call so `NativeMessageList` only wires a scroll * container, item list, and the auto-scroll preference through it. */ export function useNativeScrollManagement(params: NativeScrollManagementParams) { const { scrollRef, items, messages, isWorking, sessionId, enabled, hasUnreadDivider, messagesLoading, historyRefreshPending = false, hasMore, isLoadingMore, loadMore, isVisible, } = params; const pendingChatInitialPlacement = useDockviewStore( (state) => state.pendingChatInitialPlacement, ); const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout); const envSwitchPlacementToken = pendingChatInitialPlacement?.sessionId === sessionId ? pendingChatInitialPlacement.token : null; const placementLatches = useInitialPlacementLatches(envSwitchPlacementToken); const initialPlacementPending = envSwitchPlacementToken !== null; const programmaticScrollLockRef = useRef(false); const isProgrammaticScrollLocked = useCallback(() => programmaticScrollLockRef.current, []); const { isNearBottomRef, resyncIsNearBottom, markNotNearBottom } = useAutoScroll({ scrollRef, messages, isWorking, sessionId, enabled, hasUnreadDivider, isProgrammaticScrollLocked, isVisible, initialPlacementPending, }); const runGuardedScroll = useProgrammaticScrollGuard( scrollRef, programmaticScrollLockRef, resyncIsNearBottom, ); const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll); const beginOlderLoad = useScrollPositionOnPrepend( scrollRef, items, isLoadingMore, isProgrammaticScrollLocked, ); const loadMoreWithPrependBaseline = useCallback(() => { beginOlderLoad(); return loadMore(); }, [beginOlderLoad, loadMore]); const { sentinelRef, onUserGesture, retry: retryLoadMore, recheck, showRecovery, } = useLazyLoadSentinel({ scrollRef, items, sessionId, hasMore, blocked: messagesLoading || envSwitchPlacementToken !== null, isLoadingMore, loadMore: loadMoreWithPrependBaseline, }); useRetryPaginationOnUpwardScroll(scrollRef, onUserGesture, recheck, isProgrammaticScrollLocked); useRecheckPaginationOnVisible(isVisible, recheck); useInitialScrollPosition({ scrollRef, itemCount: items.length, sessionId, enabled, hasUnreadDivider, isNearBottomRef, isVisible, historyRefreshPending, envSwitchPlacementToken, isRestoringLayout, isProgrammaticScrollLocked, placementLatches, }); return { handleScrollToMessage, sentinelRef, resyncIsNearBottom, markNotNearBottom, isProgrammaticScrollLocked, retryLoadMore, showRecovery, }; }