1813 lines
61 KiB
TypeScript
1813 lines
61 KiB
TypeScript
/* eslint-disable max-lines -- pagination, scroll anchoring, and retry state share one boundary. */
|
|
|
|
import { cancelChatScrollMotion } from "./chat-scroll-motion";
|
|
import { useChatScrollMotion } from "./use-chat-scroll-motion";
|
|
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 {
|
|
cancelChatScrollMotion(element);
|
|
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<HTMLDivElement | null>;
|
|
isLoadingMoreRef: React.MutableRefObject<boolean>;
|
|
olderLoadPendingRef: React.MutableRefObject<boolean>;
|
|
newestItemKeyRef: React.MutableRefObject<string | null>;
|
|
scrollState: React.MutableRefObject<PrependScrollState>;
|
|
}) {
|
|
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<HTMLDivElement | null>,
|
|
items: RenderItem[],
|
|
isLoadingMore: boolean,
|
|
isProgrammaticScrollLocked: () => boolean,
|
|
): () => void {
|
|
const scrollState = useRef<PrependScrollState>({
|
|
scrollHeight: 0,
|
|
scrollTop: 0,
|
|
anchorKey: null,
|
|
anchorTop: null,
|
|
});
|
|
const isLoadingMoreRef = useRef(isLoadingMore);
|
|
const olderLoadPendingRef = useRef(false);
|
|
const newestItemKeyRef = useRef<string | null>(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<string | null>(getOldestNonSyntheticItemKey(items));
|
|
const beginOlderLoad = useCallback(() => {
|
|
if (olderLoadPendingRef.current) return;
|
|
const el = scrollRef.current;
|
|
if (el) {
|
|
cancelChatScrollMotion(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<HTMLElement>("[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<HTMLDivElement | null>;
|
|
items: RenderItem[];
|
|
sessionId: string | null;
|
|
hasMore: boolean;
|
|
blocked: boolean;
|
|
isLoadingMore: boolean;
|
|
loadMore: () => Promise<number>;
|
|
}) {
|
|
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<HTMLDivElement | null>(null);
|
|
const requestGenerationRef = useRef(0);
|
|
const requestRef = useRef<PaginationRequest | null>(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;
|
|
const request = requestRef.current;
|
|
const visibleBoundaryChanged =
|
|
request !== null && getOldestVisibleBoundaryKey(itemsRef.current) !== request.boundaryBefore;
|
|
return Boolean(
|
|
hasMoreRef.current &&
|
|
scrollRoot &&
|
|
sentinel &&
|
|
!visibleBoundaryChanged &&
|
|
isElementInPreloadRegion(scrollRoot, sentinel, TRANSCRIPT_SENTINEL_ROOT_MARGIN),
|
|
);
|
|
}, [scrollRef]);
|
|
const isCurrentGeometryEligible = 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.
|
|
// The continuation predicate additionally stops after a visible row is
|
|
// added; fresh upward input must still be allowed to start the next
|
|
// request from the new boundary.
|
|
isCurrentGeometryEligible,
|
|
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<HTMLDivElement | null>,
|
|
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.
|
|
*/
|
|
function useFollowIntent({
|
|
scrollRef,
|
|
isVisibleRef,
|
|
isAnimating,
|
|
userReading,
|
|
isNearBottomRef,
|
|
cancelMotion,
|
|
}: {
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
isVisibleRef: React.RefObject<boolean>;
|
|
isAnimating: () => boolean;
|
|
userReading: React.RefObject<boolean>;
|
|
isNearBottomRef: React.RefObject<boolean>;
|
|
cancelMotion: () => void;
|
|
}) {
|
|
const resyncIsNearBottom = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el || !isVisibleRef.current || isAnimating()) return;
|
|
isNearBottomRef.current =
|
|
el.scrollHeight - el.scrollTop - el.clientHeight < (userReading.current ? 3 : 100);
|
|
if (isNearBottomRef.current) userReading.current = false;
|
|
}, [scrollRef, isAnimating, userReading]);
|
|
const markNotNearBottom = useCallback(() => {
|
|
cancelMotion();
|
|
isNearBottomRef.current = false;
|
|
}, [cancelMotion]);
|
|
return { resyncIsNearBottom, markNotNearBottom };
|
|
}
|
|
|
|
type AutoScrollParams = {
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
messages: Message[];
|
|
isWorking: boolean;
|
|
sessionId: string | null;
|
|
enabled: boolean;
|
|
motionEnabled?: boolean;
|
|
motionBlocked?: boolean;
|
|
hasUnreadDivider: boolean;
|
|
isProgrammaticScrollLocked: () => boolean;
|
|
isVisible?: boolean;
|
|
initialPlacementPending?: boolean;
|
|
};
|
|
|
|
export function useAutoScroll(params: AutoScrollParams) {
|
|
const {
|
|
scrollRef,
|
|
messages,
|
|
isWorking,
|
|
sessionId,
|
|
enabled,
|
|
motionEnabled = false,
|
|
motionBlocked = false,
|
|
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 {
|
|
followBottom,
|
|
cancel: cancelMotion,
|
|
isAnimating,
|
|
userReading,
|
|
} = useChatScrollMotion({
|
|
scrollRef,
|
|
motionEnabled,
|
|
enabled,
|
|
isVisible,
|
|
sessionId,
|
|
isNearBottomRef,
|
|
instant: scrollNativeToBottom,
|
|
isBlocked: () =>
|
|
motionBlocked ||
|
|
initialPlacementPending ||
|
|
isProgrammaticScrollLockedRef.current() ||
|
|
useDockviewStore.getState().pendingChatScrollTop !== null ||
|
|
useDockviewStore.getState().isRestoringLayout,
|
|
});
|
|
const { resyncIsNearBottom, markNotNearBottom } = useFollowIntent({
|
|
scrollRef,
|
|
isVisibleRef,
|
|
isAnimating,
|
|
userReading,
|
|
isNearBottomRef,
|
|
cancelMotion,
|
|
});
|
|
|
|
useTranscriptViewportResize({ ...params, isVisible, initialPlacementPending });
|
|
|
|
usePersistedTranscriptScroll({
|
|
scrollRef,
|
|
sessionId,
|
|
storeApi,
|
|
resyncIsNearBottom,
|
|
enabled,
|
|
isWorking,
|
|
isVisible,
|
|
isVisibleRef,
|
|
messages,
|
|
initialPlacementPending,
|
|
});
|
|
|
|
useAutoScrollOnContent({
|
|
followBottom,
|
|
scrollRef,
|
|
messages,
|
|
isWorking,
|
|
sessionId,
|
|
enabled,
|
|
hasUnreadDivider,
|
|
isNearBottomRef,
|
|
isVisibleRef,
|
|
isProgrammaticScrollLocked,
|
|
prevIsWorkingRef,
|
|
initialPlacementPending,
|
|
});
|
|
|
|
useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef, followBottom);
|
|
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<HTMLDivElement | null>;
|
|
sessionId: string | null;
|
|
storeApi: ReturnType<typeof useAppStoreApi>;
|
|
resyncIsNearBottom: () => void;
|
|
enabled: boolean;
|
|
isWorking: boolean;
|
|
isVisible: boolean;
|
|
isVisibleRef: React.RefObject<boolean>;
|
|
messages: Message[];
|
|
initialPlacementPending: boolean;
|
|
}) {
|
|
const frozenScrollTopRef = useRef<number | null>(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({
|
|
followBottom,
|
|
scrollRef,
|
|
messages,
|
|
isWorking,
|
|
sessionId,
|
|
enabled,
|
|
hasUnreadDivider,
|
|
isNearBottomRef,
|
|
isVisibleRef,
|
|
isProgrammaticScrollLocked,
|
|
prevIsWorkingRef,
|
|
initialPlacementPending,
|
|
}: {
|
|
followBottom: () => void;
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
messages: Message[];
|
|
isWorking: boolean;
|
|
sessionId: string | null;
|
|
enabled: boolean;
|
|
hasUnreadDivider: boolean;
|
|
isNearBottomRef: React.RefObject<boolean>;
|
|
isVisibleRef: React.RefObject<boolean>;
|
|
isProgrammaticScrollLocked: () => boolean;
|
|
prevIsWorkingRef: React.MutableRefObject<boolean>;
|
|
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) {
|
|
followBottom();
|
|
isNearBottomRef.current = true;
|
|
placementDebug("work-start bottom", { sessionId });
|
|
}
|
|
}
|
|
prevIsWorkingRef.current = isWorking;
|
|
}, [
|
|
hasUnreadDivider,
|
|
isWorking,
|
|
sessionId,
|
|
scrollRef,
|
|
followBottom,
|
|
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
|
|
) {
|
|
followBottom();
|
|
if (messages.length !== lastLoggedMessageCountRef.current) {
|
|
placementDebug("message-update bottom", {
|
|
sessionId,
|
|
messageCount: messages.length,
|
|
});
|
|
}
|
|
}
|
|
lastLoggedMessageCountRef.current = messages.length;
|
|
}, [
|
|
messages,
|
|
sessionId,
|
|
scrollRef,
|
|
followBottom,
|
|
enabled,
|
|
isProgrammaticScrollLocked,
|
|
initialPlacementPending,
|
|
]);
|
|
}
|
|
|
|
function useCatchUpOnVisible({
|
|
scrollRef,
|
|
isVisible,
|
|
isNearBottomRef,
|
|
enabledRef,
|
|
hasUnreadDividerRef,
|
|
sessionIdRef,
|
|
isProgrammaticScrollLockedRef,
|
|
initialPlacementPending,
|
|
}: {
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
isVisible: boolean;
|
|
isNearBottomRef: React.RefObject<boolean>;
|
|
enabledRef: React.RefObject<boolean>;
|
|
hasUnreadDividerRef: React.RefObject<boolean>;
|
|
sessionIdRef: React.RefObject<string | null>;
|
|
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<HTMLDivElement | null>,
|
|
messages: Message[],
|
|
enabled: boolean,
|
|
isNearBottomRef: React.RefObject<boolean>,
|
|
followBottom: () => void,
|
|
) {
|
|
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,
|
|
})
|
|
) {
|
|
followBottom();
|
|
isNearBottomRef.current = true;
|
|
}
|
|
}, [enabled, scrollRef, messages, followBottom]);
|
|
}
|
|
|
|
/**
|
|
* 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<HTMLDivElement | null>,
|
|
lockedRef: React.RefObject<boolean>,
|
|
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<HTMLDivElement | null>,
|
|
runGuardedScroll: (performScroll: () => void) => void,
|
|
motionEnabled = true,
|
|
) {
|
|
// 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);
|
|
useLayoutEffect(() => {
|
|
if (motionEnabled || generationRef.current === 0) return;
|
|
generationRef.current += 1;
|
|
const element = scrollRef.current;
|
|
// A same-position write cancels native smooth scrolling without choosing a new target.
|
|
if (element) {
|
|
const currentTop = element.scrollTop;
|
|
element.scrollTop = currentTop;
|
|
}
|
|
}, [motionEnabled, scrollRef]);
|
|
useEffect(
|
|
() => () => {
|
|
generationRef.current += 1;
|
|
},
|
|
[],
|
|
);
|
|
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<HTMLElement>(selector);
|
|
if (!el) return false;
|
|
const alignStart = options?.align === "start";
|
|
if (scrollRef.current) cancelChatScrollMotion(scrollRef.current);
|
|
runGuardedScroll(() => {
|
|
el.scrollIntoView({
|
|
block: alignStart ? "start" : "center",
|
|
behavior: motionEnabled ? (options?.behavior ?? "smooth") : "auto",
|
|
});
|
|
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, motionEnabled],
|
|
);
|
|
}
|
|
|
|
type InitialScrollApplyParams = {
|
|
element: HTMLDivElement;
|
|
sessionId: string | null;
|
|
enabled: boolean;
|
|
storeApi: ReturnType<typeof useAppStoreApi>;
|
|
didInitialScroll: React.RefObject<boolean>;
|
|
activationPendingRef: React.RefObject<boolean>;
|
|
isVisibleRef: React.RefObject<boolean>;
|
|
isNearBottomRef: React.RefObject<boolean>;
|
|
envSwitchPlacementToken: number | null;
|
|
hasUnreadDivider: boolean;
|
|
isProgrammaticScrollLocked: () => boolean;
|
|
phase: "provisional" | "final";
|
|
};
|
|
|
|
function markInitialScrollConsumed(
|
|
didInitialScroll: React.RefObject<boolean>,
|
|
activationPendingRef: React.RefObject<boolean>,
|
|
): 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,
|
|
});
|
|
};
|
|
cancelChatScrollMotion(element);
|
|
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<number | null>(null);
|
|
const provisionalTokenRef = useRef<number | null>(null);
|
|
if (envSwitchPlacementToken !== null && envSwitchPlacementToken !== lastTokenRef.current) {
|
|
lastTokenRef.current = envSwitchPlacementToken;
|
|
provisionalTokenRef.current = null;
|
|
didInitialScroll.current = false;
|
|
}
|
|
return { didInitialScroll, provisionalTokenRef };
|
|
}
|
|
|
|
type InitialPlacementLatches = ReturnType<typeof useInitialPlacementLatches>;
|
|
|
|
type InitialScrollPositionParams = {
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
itemCount: number;
|
|
sessionId: string | null;
|
|
enabled: boolean;
|
|
hasUnreadDivider: boolean;
|
|
isNearBottomRef: React.RefObject<boolean>;
|
|
isVisible: boolean;
|
|
historyRefreshPending: boolean;
|
|
envSwitchPlacementToken: number | null;
|
|
isRestoringLayout: boolean;
|
|
isProgrammaticScrollLocked: () => boolean;
|
|
placementLatches: InitialPlacementLatches;
|
|
};
|
|
|
|
type InitialScrollPositionEffectParams = Omit<InitialScrollPositionParams, "placementLatches"> & {
|
|
storeApi: ReturnType<typeof useAppStoreApi>;
|
|
didInitialScroll: React.RefObject<boolean>;
|
|
provisionalTokenRef: React.RefObject<number | null>;
|
|
isVisibleRef: React.RefObject<boolean>;
|
|
activationPendingRef: React.RefObject<boolean>;
|
|
};
|
|
|
|
function handleEmptyTranscriptPlacement(params: {
|
|
hasEmptyTranscript: boolean;
|
|
envSwitchPlacementToken: number | null;
|
|
historyRefreshPending: boolean;
|
|
didInitialScroll: React.RefObject<boolean>;
|
|
activationPendingRef: React.RefObject<boolean>;
|
|
}): boolean {
|
|
if (!params.hasEmptyTranscript) return false;
|
|
if (params.envSwitchPlacementToken !== null && !params.historyRefreshPending) {
|
|
markInitialScrollConsumed(params.didInitialScroll, params.activationPendingRef);
|
|
completeEnvSwitchPlacement(params.envSwitchPlacementToken);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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 = handleEmptyTranscriptPlacement({
|
|
hasEmptyTranscript: itemCount === 0,
|
|
envSwitchPlacementToken,
|
|
historyRefreshPending,
|
|
didInitialScroll,
|
|
activationPendingRef,
|
|
});
|
|
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;
|
|
};
|
|
|
|
const isEnvSwitchSettling =
|
|
envSwitchPlacementToken !== null && provisionalTokenRef.current === envSwitchPlacementToken;
|
|
if (activationPendingRef.current && !isEnvSwitchSettling) {
|
|
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<HTMLDivElement | null>;
|
|
items: RenderItem[];
|
|
messages: Message[];
|
|
isWorking: boolean;
|
|
sessionId: string | null;
|
|
enabled: boolean;
|
|
motionEnabled?: 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<number>;
|
|
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,
|
|
motionEnabled = false,
|
|
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,
|
|
motionEnabled,
|
|
motionBlocked: messagesLoading || historyRefreshPending || isLoadingMore,
|
|
});
|
|
const runGuardedScroll = useProgrammaticScrollGuard(
|
|
scrollRef,
|
|
programmaticScrollLockRef,
|
|
resyncIsNearBottom,
|
|
);
|
|
const handleScrollToMessage = useScrollToMessage(scrollRef, runGuardedScroll, motionEnabled);
|
|
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,
|
|
};
|
|
}
|