2026-03-12 23:45:45 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
|
|
|
|
|
import type { ChatMessageEntry } from '../lib/turns/types';
|
|
|
|
|
import type { MessageListHandle } from '../MessageList';
|
|
|
|
|
import {
|
|
|
|
|
buildTurnWindowModel,
|
2026-04-05 15:36:11 +03:00
|
|
|
updateTurnWindowModelIncremental,
|
2026-03-12 23:45:45 +02:00
|
|
|
type TurnWindowModel,
|
|
|
|
|
} from '../lib/turns/windowTurns';
|
|
|
|
|
import type { TurnHistorySignals } from '../lib/turns/historySignals';
|
|
|
|
|
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
|
2026-05-21 15:45:15 +03:00
|
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
2026-05-25 01:39:47 +03:00
|
|
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
type ViewportAnchor = { messageId: string; offsetTop: number };
|
|
|
|
|
|
2026-04-06 17:36:08 +03:00
|
|
|
type PendingScrollRequest = {
|
|
|
|
|
sessionId: string;
|
|
|
|
|
kind: 'turn' | 'message';
|
|
|
|
|
id: string;
|
|
|
|
|
behavior: ScrollBehavior;
|
|
|
|
|
turnId: string | null;
|
|
|
|
|
resolve: (value: boolean) => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
interface UseChatTimelineControllerOptions {
|
|
|
|
|
sessionId: string | null;
|
|
|
|
|
messages: ChatMessageEntry[];
|
|
|
|
|
historyMeta: SessionHistoryMeta | null;
|
|
|
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
|
|
|
messageListRef: React.RefObject<MessageListHandle | null>;
|
|
|
|
|
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
|
2026-05-08 14:20:16 +03:00
|
|
|
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
|
|
|
|
releaseAutoFollow: () => void;
|
2026-03-12 23:45:45 +02:00
|
|
|
isPinned: boolean;
|
2026-05-08 14:20:16 +03:00
|
|
|
showScrollButton: boolean;
|
2026-03-12 23:45:45 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UseChatTimelineControllerResult {
|
|
|
|
|
turnIds: string[];
|
|
|
|
|
turnStart: number;
|
|
|
|
|
renderedMessages: ChatMessageEntry[];
|
|
|
|
|
historySignals: TurnHistorySignals;
|
|
|
|
|
isLoadingOlder: boolean;
|
|
|
|
|
pendingRevealWork: boolean;
|
|
|
|
|
activeTurnId: string | null;
|
|
|
|
|
showScrollToBottom: boolean;
|
|
|
|
|
turnWindowModel: TurnWindowModel;
|
2026-05-30 02:03:41 +03:00
|
|
|
loadEarlier: (options?: { userInitiated?: boolean }) => Promise<void>;
|
2026-03-12 23:45:45 +02:00
|
|
|
revealBufferedTurns: () => Promise<boolean>;
|
|
|
|
|
resumeToBottom: () => void;
|
2026-05-08 14:20:16 +03:00
|
|
|
resumeToBottomInstant: () => Promise<void>;
|
2026-03-12 23:45:45 +02:00
|
|
|
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
|
|
|
|
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
2026-05-30 02:03:41 +03:00
|
|
|
handleHistoryScroll: () => void;
|
2026-03-12 23:45:45 +02:00
|
|
|
captureViewportAnchor: () => ViewportAnchor | null;
|
|
|
|
|
restoreViewportAnchor: (anchor: ViewportAnchor) => boolean;
|
|
|
|
|
handleActiveTurnChange: (turnId: string | null) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:28:29 +03:00
|
|
|
const TURN_MODEL_CACHE_MAX = 30
|
2026-05-30 02:03:41 +03:00
|
|
|
const HISTORY_SCROLL_THRESHOLD = 200
|
2026-05-21 15:45:15 +03:00
|
|
|
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
|
|
|
|
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
2026-05-25 01:39:47 +03:00
|
|
|
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
|
|
|
|
const MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
2026-05-30 02:03:41 +03:00
|
|
|
const HISTORY_RENDER_WAIT_TIMEOUT_MS = 250
|
|
|
|
|
const HISTORY_INTERACTION_GUARD_MS = 2000
|
2026-05-13 14:28:29 +03:00
|
|
|
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
|
2026-05-25 01:39:47 +03:00
|
|
|
const getTurnModelCacheMax = () => {
|
|
|
|
|
if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX
|
|
|
|
|
if (isMobileSurfaceRuntime()) return MOBILE_TURN_MODEL_CACHE_MAX
|
|
|
|
|
return TURN_MODEL_CACHE_MAX
|
|
|
|
|
}
|
2026-05-21 15:45:15 +03:00
|
|
|
|
|
|
|
|
const shouldCacheTurnModelMessages = (messages: ChatMessageEntry[]): boolean => {
|
2026-05-25 01:39:47 +03:00
|
|
|
if (isVSCodeRuntime()) return messages.length <= VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES
|
|
|
|
|
if (isMobileSurfaceRuntime()) return messages.length <= MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES
|
|
|
|
|
return true
|
2026-05-21 15:45:15 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rememberTurnModel = (key: string, value: { messages: ChatMessageEntry[]; model: TurnWindowModel }) => {
|
|
|
|
|
turnModelCache.delete(key)
|
|
|
|
|
if (!shouldCacheTurnModelMessages(value.messages)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const max = getTurnModelCacheMax()
|
|
|
|
|
while (turnModelCache.size >= max) {
|
|
|
|
|
const oldest = turnModelCache.keys().next().value
|
|
|
|
|
if (typeof oldest !== 'string') break
|
|
|
|
|
turnModelCache.delete(oldest)
|
|
|
|
|
}
|
|
|
|
|
turnModelCache.set(key, value)
|
|
|
|
|
}
|
2026-05-13 14:28:29 +03:00
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: {
|
|
|
|
|
sessionId: string | null;
|
|
|
|
|
isPinned: boolean;
|
|
|
|
|
canLoadEarlier: boolean;
|
|
|
|
|
isLoadingOlder: boolean;
|
|
|
|
|
pendingRevealWork: boolean;
|
|
|
|
|
scrollHeight: number;
|
|
|
|
|
clientHeight: number;
|
|
|
|
|
}): boolean => {
|
|
|
|
|
if (!input.sessionId) return false;
|
|
|
|
|
if (!input.isPinned || !input.canLoadEarlier) return false;
|
|
|
|
|
if (input.isLoadingOlder || input.pendingRevealWork) return false;
|
|
|
|
|
return input.scrollHeight <= input.clientHeight + 1;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
export const useChatTimelineController = ({
|
|
|
|
|
sessionId,
|
|
|
|
|
messages,
|
|
|
|
|
historyMeta,
|
|
|
|
|
scrollRef,
|
|
|
|
|
messageListRef,
|
|
|
|
|
loadMoreMessages,
|
2026-05-08 14:20:16 +03:00
|
|
|
goToBottom,
|
|
|
|
|
releaseAutoFollow,
|
2026-03-12 23:45:45 +02:00
|
|
|
isPinned,
|
2026-05-08 14:20:16 +03:00
|
|
|
showScrollButton,
|
2026-03-12 23:45:45 +02:00
|
|
|
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
|
2026-04-05 15:36:11 +03:00
|
|
|
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
|
|
|
|
|
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
|
|
|
|
const turnWindowModel = React.useMemo(() => {
|
2026-05-13 14:28:29 +03:00
|
|
|
const key = sessionId ?? ""
|
|
|
|
|
const cached = key ? turnModelCache.get(key) : undefined
|
|
|
|
|
if (cached && cached.messages === messages) {
|
2026-05-21 15:45:15 +03:00
|
|
|
rememberTurnModel(key, cached)
|
2026-05-13 14:28:29 +03:00
|
|
|
previousTurnWindowModelRef.current = cached.model
|
|
|
|
|
previousMessagesRef.current = messages
|
|
|
|
|
return cached.model
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
const incrementalModel = updateTurnWindowModelIncremental(
|
|
|
|
|
previousTurnWindowModelRef.current,
|
|
|
|
|
previousMessagesRef.current,
|
|
|
|
|
messages,
|
|
|
|
|
);
|
|
|
|
|
const nextModel = incrementalModel ?? buildTurnWindowModel(messages);
|
|
|
|
|
previousTurnWindowModelRef.current = nextModel;
|
|
|
|
|
previousMessagesRef.current = messages;
|
2026-05-13 14:28:29 +03:00
|
|
|
|
|
|
|
|
if (key && messages.length > 0) {
|
2026-05-21 15:45:15 +03:00
|
|
|
rememberTurnModel(key, { messages, model: nextModel })
|
2026-05-13 14:28:29 +03:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
return nextModel;
|
2026-05-13 14:28:29 +03:00
|
|
|
}, [messages, sessionId]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
|
|
|
|
const [pendingRevealWork, setPendingRevealWork] = React.useState(false);
|
|
|
|
|
const [activeTurnId, setActiveTurnId] = React.useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const turnModelRef = React.useRef(turnWindowModel);
|
|
|
|
|
const isPinnedRef = React.useRef(isPinned);
|
|
|
|
|
const isLoadingOlderRef = React.useRef(isLoadingOlder);
|
|
|
|
|
const pendingRevealWorkRef = React.useRef(pendingRevealWork);
|
|
|
|
|
const sessionIdRef = React.useRef<string | null>(sessionId);
|
|
|
|
|
const messagesRef = React.useRef(messages);
|
|
|
|
|
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
|
|
|
|
|
const initializedSessionRef = React.useRef<string | null>(null);
|
2026-04-06 17:36:08 +03:00
|
|
|
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
|
|
|
|
|
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
|
2026-05-30 02:03:41 +03:00
|
|
|
const historyInteractionRef = React.useRef(false);
|
|
|
|
|
const historyInteractionTimerRef = React.useRef<number | null>(null);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
const historySignals = React.useMemo(() => {
|
|
|
|
|
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
|
2026-06-15 03:29:40 +03:00
|
|
|
const hasBufferedTurns = false;
|
2026-03-12 23:45:45 +02:00
|
|
|
const hasMoreAboveTurns = historyMeta
|
|
|
|
|
? !historyMeta.complete
|
|
|
|
|
: messages.length >= defaultLimit;
|
|
|
|
|
const historyLoading = Boolean(historyMeta?.loading);
|
|
|
|
|
return {
|
|
|
|
|
hasBufferedTurns,
|
|
|
|
|
hasMoreAboveTurns,
|
|
|
|
|
historyLoading,
|
2026-06-15 03:29:40 +03:00
|
|
|
canLoadEarlier: hasMoreAboveTurns,
|
2026-03-12 23:45:45 +02:00
|
|
|
};
|
2026-06-15 03:29:40 +03:00
|
|
|
}, [historyMeta, messages.length]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
const historySignalsRef = React.useRef(historySignals);
|
|
|
|
|
|
2026-04-26 16:24:07 +03:00
|
|
|
turnModelRef.current = turnWindowModel;
|
|
|
|
|
isPinnedRef.current = isPinned;
|
|
|
|
|
isLoadingOlderRef.current = isLoadingOlder;
|
|
|
|
|
pendingRevealWorkRef.current = pendingRevealWork;
|
|
|
|
|
historySignalsRef.current = historySignals;
|
|
|
|
|
sessionIdRef.current = sessionId;
|
|
|
|
|
messagesRef.current = messages;
|
|
|
|
|
historyMetaRef.current = historyMeta;
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-05-30 02:03:41 +03:00
|
|
|
const beginHistoryInteraction = React.useCallback(() => {
|
|
|
|
|
historyInteractionRef.current = true;
|
|
|
|
|
if (historyInteractionTimerRef.current !== null && typeof window !== 'undefined') {
|
|
|
|
|
window.clearTimeout(historyInteractionTimerRef.current);
|
|
|
|
|
historyInteractionTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const settleHistoryInteraction = React.useCallback(() => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
historyInteractionRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (historyInteractionTimerRef.current !== null) {
|
|
|
|
|
window.clearTimeout(historyInteractionTimerRef.current);
|
|
|
|
|
}
|
|
|
|
|
historyInteractionTimerRef.current = window.setTimeout(() => {
|
|
|
|
|
historyInteractionTimerRef.current = null;
|
|
|
|
|
historyInteractionRef.current = false;
|
|
|
|
|
}, HISTORY_INTERACTION_GUARD_MS);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
React.useLayoutEffect(() => {
|
2026-03-12 23:45:45 +02:00
|
|
|
if (initializedSessionRef.current === sessionId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-30 02:03:41 +03:00
|
|
|
if (historyInteractionTimerRef.current !== null && typeof window !== 'undefined') {
|
|
|
|
|
window.clearTimeout(historyInteractionTimerRef.current);
|
|
|
|
|
historyInteractionTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
historyInteractionRef.current = false;
|
2026-03-12 23:45:45 +02:00
|
|
|
initializedSessionRef.current = sessionId;
|
|
|
|
|
setIsLoadingOlder(false);
|
|
|
|
|
setPendingRevealWork(false);
|
|
|
|
|
setActiveTurnId(null);
|
2026-06-15 03:29:40 +03:00
|
|
|
}, [sessionId]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-04-06 17:36:08 +03:00
|
|
|
const resolvePendingRenderWaiters = React.useCallback(() => {
|
|
|
|
|
const resolvers = pendingRenderResolversRef.current;
|
|
|
|
|
if (resolvers.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pendingRenderResolversRef.current = [];
|
|
|
|
|
resolvers.forEach((resolve) => resolve());
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-30 02:03:41 +03:00
|
|
|
const waitForNextRenderCommitOrTimeout = React.useCallback((): Promise<void> => {
|
|
|
|
|
return new Promise<void>((resolve) => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
resolve();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let settled = false;
|
|
|
|
|
const finish = () => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
settled = true;
|
|
|
|
|
window.clearTimeout(timer);
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
pendingRenderResolversRef.current.push(finish);
|
|
|
|
|
const timer = window.setTimeout(finish, HISTORY_RENDER_WAIT_TIMEOUT_MS);
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-06 17:36:08 +03:00
|
|
|
const resolvePendingScrollRequest = React.useCallback((value: boolean) => {
|
|
|
|
|
const pending = pendingScrollRequestRef.current;
|
|
|
|
|
if (!pending) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pendingScrollRequestRef.current = null;
|
|
|
|
|
pending.resolve(value);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const attemptPendingScrollRequest = React.useCallback(() => {
|
|
|
|
|
const pending = pendingScrollRequestRef.current;
|
|
|
|
|
if (!pending) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pending.sessionId !== sessionIdRef.current) {
|
|
|
|
|
resolvePendingScrollRequest(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const didScroll = pending.kind === 'turn'
|
|
|
|
|
? (messageListRef.current?.scrollToTurnId(pending.id, { behavior: pending.behavior }) ?? false)
|
|
|
|
|
: (messageListRef.current?.scrollToMessageId(pending.id, { behavior: pending.behavior }) ?? false);
|
|
|
|
|
|
|
|
|
|
if (didScroll) {
|
|
|
|
|
if (pending.turnId) {
|
|
|
|
|
setActiveTurnId(pending.turnId);
|
|
|
|
|
}
|
|
|
|
|
resolvePendingScrollRequest(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetIndex = pending.kind === 'turn'
|
|
|
|
|
? turnModelRef.current.turnIndexById.get(pending.id)
|
|
|
|
|
: turnModelRef.current.messageToTurnIndex.get(pending.id);
|
|
|
|
|
|
2026-06-15 03:29:40 +03:00
|
|
|
if (typeof targetIndex === 'number') {
|
2026-04-06 17:36:08 +03:00
|
|
|
resolvePendingScrollRequest(false);
|
|
|
|
|
}
|
|
|
|
|
}, [messageListRef, resolvePendingScrollRequest]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
return () => {
|
2026-05-30 02:03:41 +03:00
|
|
|
if (historyInteractionTimerRef.current !== null && typeof window !== 'undefined') {
|
|
|
|
|
window.clearTimeout(historyInteractionTimerRef.current);
|
|
|
|
|
historyInteractionTimerRef.current = null;
|
|
|
|
|
}
|
2026-04-06 17:36:08 +03:00
|
|
|
resolvePendingRenderWaiters();
|
|
|
|
|
resolvePendingScrollRequest(false);
|
|
|
|
|
};
|
|
|
|
|
}, [resolvePendingRenderWaiters, resolvePendingScrollRequest]);
|
|
|
|
|
|
2026-06-15 03:29:40 +03:00
|
|
|
const renderedMessages = messages;
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-04-06 17:36:08 +03:00
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
resolvePendingRenderWaiters();
|
|
|
|
|
attemptPendingScrollRequest();
|
2026-06-15 03:29:40 +03:00
|
|
|
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters]);
|
2026-04-06 17:36:08 +03:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// --- Synchronous scroll compensation for load-more / reveal ---
|
|
|
|
|
// fetchOlderHistory and revealBufferedTurns store a snapshot here
|
|
|
|
|
// before triggering the state change. useLayoutEffect consumes it
|
|
|
|
|
// after React commits new DOM — before the browser paints.
|
|
|
|
|
const prePrependScrollRef = React.useRef<{
|
|
|
|
|
height: number;
|
|
|
|
|
top: number;
|
|
|
|
|
anchor: ViewportAnchor | null;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
2026-05-12 14:44:53 +03:00
|
|
|
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
|
|
|
|
|
return messageListRef.current?.captureViewportAnchor() ?? null;
|
|
|
|
|
}, [messageListRef]);
|
|
|
|
|
|
|
|
|
|
const restoreViewportAnchor = React.useCallback((anchor: ViewportAnchor): boolean => {
|
|
|
|
|
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
|
|
|
|
|
}, [messageListRef]);
|
|
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
// Tracks the timeline edges + height of the previous commit so a prepend
|
|
|
|
|
// that did NOT go through fetchOlderHistory (e.g. the background history
|
|
|
|
|
// prepend dispatched from useSync) can be compensated too. With
|
|
|
|
|
// overflow-anchor:none the browser leaves scrollTop unchanged when content
|
|
|
|
|
// is inserted above, so without this the viewport visibly jumps and
|
|
|
|
|
// auto-follow yanks it back on the next frame — a one-shot up/down judder.
|
|
|
|
|
const prependTrackingRef = React.useRef<{
|
|
|
|
|
oldestId: string | null;
|
|
|
|
|
newestId: string | null;
|
|
|
|
|
scrollHeight: number;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
const container = scrollRef.current;
|
2026-06-18 08:43:16 +11:00
|
|
|
if (!container) return;
|
2026-03-31 18:47:00 +03:00
|
|
|
|
2026-06-27 00:06:19 +03:00
|
|
|
const snap = prePrependScrollRef.current;
|
|
|
|
|
const prev = prependTrackingRef.current;
|
|
|
|
|
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
|
|
|
|
|
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
|
|
|
|
|
// A prepend = content inserted ABOVE the viewport: the oldest message id
|
|
|
|
|
// changed while the newest stayed the same. This distinguishes a history
|
|
|
|
|
// load from a bottom append, a streaming part growing, or a session switch.
|
|
|
|
|
const isPrepend = Boolean(
|
|
|
|
|
prev
|
|
|
|
|
&& prev.oldestId
|
|
|
|
|
&& currentOldestId
|
|
|
|
|
&& currentOldestId !== prev.oldestId
|
|
|
|
|
&& prev.newestId
|
|
|
|
|
&& currentNewestId
|
|
|
|
|
&& currentNewestId === prev.newestId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const updateTracking = () => {
|
2026-06-24 18:29:55 +03:00
|
|
|
prependTrackingRef.current = {
|
2026-06-27 00:06:19 +03:00
|
|
|
oldestId: currentOldestId,
|
|
|
|
|
newestId: currentNewestId,
|
2026-06-24 18:29:55 +03:00
|
|
|
scrollHeight: container.scrollHeight,
|
|
|
|
|
};
|
2026-06-27 00:06:19 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isPinnedRef.current) {
|
|
|
|
|
// Bottom-pinned. Only content inserted ABOVE (a prepend / history load)
|
|
|
|
|
// needs an explicit re-pin: with overflow-anchor:none the browser leaves
|
|
|
|
|
// scrollTop unchanged, so the viewport would visibly jump. Route that
|
|
|
|
|
// through goToBottom — the single programmatic writer.
|
|
|
|
|
//
|
|
|
|
|
// A normal bottom APPEND (a sent message, a streaming part) must NOT
|
2026-06-27 23:45:34 +03:00
|
|
|
// re-pin here. Auto-follow already owns the bottom: its content
|
|
|
|
|
// ResizeObserver re-pins instantly (scrollTop = scrollHeight, before
|
|
|
|
|
// paint) on every append. Re-pinning again from here would just be a
|
|
|
|
|
// second writer chasing the same target a frame later — redundant at
|
|
|
|
|
// best, and the source of the old up/down jiggle on send / from the
|
|
|
|
|
// queue / while streaming. So for an append we do nothing and let
|
|
|
|
|
// auto-follow own it.
|
2026-06-27 00:06:19 +03:00
|
|
|
if (snap || isPrepend) {
|
|
|
|
|
prePrependScrollRef.current = null;
|
|
|
|
|
goToBottom('instant');
|
|
|
|
|
}
|
|
|
|
|
updateTracking();
|
2026-06-24 18:29:55 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
if (snap) {
|
|
|
|
|
prePrependScrollRef.current = null;
|
|
|
|
|
// When a viewport anchor is available, delegate to MessageList
|
|
|
|
|
// restoreViewportAnchor which falls back to virtualizer-aware
|
|
|
|
|
// scrollHistoryIndexIntoView when the element is not in the DOM.
|
|
|
|
|
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
|
|
|
|
|
// Fallback: height-delta compensation
|
|
|
|
|
const delta = container.scrollHeight - snap.height;
|
|
|
|
|
if (delta > 0) {
|
|
|
|
|
container.scrollTop = snap.top + delta;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-27 00:06:19 +03:00
|
|
|
} else if (isPrepend && prev) {
|
|
|
|
|
// Released viewport: preserve the read position by compensating for the
|
|
|
|
|
// exact height the prepend added above, with no intermediate frame for
|
|
|
|
|
// auto-follow to fight.
|
|
|
|
|
const delta = container.scrollHeight - prev.scrollHeight;
|
|
|
|
|
if (delta > 0) {
|
|
|
|
|
container.scrollTop = container.scrollTop + delta;
|
2026-06-18 08:43:16 +11:00
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 00:06:19 +03:00
|
|
|
updateTracking();
|
2026-06-24 18:29:55 +03:00
|
|
|
}, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-06-15 03:29:40 +03:00
|
|
|
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
const fetchOlderHistory = React.useCallback(async (input: {
|
|
|
|
|
preserveViewport: boolean;
|
|
|
|
|
}): Promise<boolean> => {
|
|
|
|
|
if (!sessionIdRef.current || isLoadingOlderRef.current) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!historySignalsRef.current.hasMoreAboveTurns) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
const beforeMessages = messagesRef.current;
|
|
|
|
|
const beforeMessageCount = beforeMessages.length;
|
|
|
|
|
const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null;
|
|
|
|
|
const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES;
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// Store scroll snapshot BEFORE the fetch so useLayoutEffect can
|
|
|
|
|
// compensate synchronously when React commits the new messages.
|
|
|
|
|
if (input.preserveViewport && container) {
|
|
|
|
|
prePrependScrollRef.current = {
|
|
|
|
|
height: container.scrollHeight,
|
|
|
|
|
top: container.scrollTop,
|
|
|
|
|
anchor: captureViewportAnchor(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 02:03:41 +03:00
|
|
|
beginHistoryInteraction();
|
2026-03-12 23:45:45 +02:00
|
|
|
setIsLoadingOlder(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const targetSessionId = sessionIdRef.current;
|
|
|
|
|
if (!targetSessionId) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 02:03:41 +03:00
|
|
|
let loadedMessageCount = beforeMessageCount;
|
|
|
|
|
let loadedOldestMessageId = beforeOldestMessageId;
|
|
|
|
|
let loadedLimit = beforeLimit;
|
|
|
|
|
const beforeTurnCount = turnModelRef.current.turnCount;
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
await loadMoreMessages(targetSessionId, 'up');
|
|
|
|
|
if (sessionIdRef.current !== targetSessionId) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await waitForNextRenderCommitOrTimeout();
|
|
|
|
|
|
|
|
|
|
const afterMessages = messagesRef.current;
|
|
|
|
|
const afterMessageCount = afterMessages.length;
|
|
|
|
|
const afterOldestMessageId = afterMessages[0]?.info?.id ?? null;
|
|
|
|
|
const afterLimit = historyMetaRef.current?.limit ?? loadedLimit;
|
|
|
|
|
const messageGrowth =
|
|
|
|
|
afterMessageCount > loadedMessageCount
|
|
|
|
|
|| (typeof loadedOldestMessageId === 'string'
|
|
|
|
|
&& typeof afterOldestMessageId === 'string'
|
|
|
|
|
&& loadedOldestMessageId !== afterOldestMessageId)
|
|
|
|
|
|| afterLimit > loadedLimit;
|
|
|
|
|
const turnGrowth = turnModelRef.current.turnCount - beforeTurnCount;
|
|
|
|
|
|
|
|
|
|
if (turnGrowth > 0) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (!messageGrowth) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!historySignalsRef.current.hasMoreAboveTurns) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadedMessageCount = afterMessageCount;
|
|
|
|
|
loadedOldestMessageId = afterOldestMessageId;
|
|
|
|
|
loadedLimit = afterLimit;
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoadingOlder(false);
|
|
|
|
|
settleHistoryInteraction();
|
|
|
|
|
}
|
|
|
|
|
}, [beginHistoryInteraction, captureViewportAnchor, loadMoreMessages, scrollRef, settleHistoryInteraction, waitForNextRenderCommitOrTimeout]);
|
|
|
|
|
|
|
|
|
|
const loadEarlier = React.useCallback(async (options?: { userInitiated?: boolean }) => {
|
|
|
|
|
beginHistoryInteraction();
|
|
|
|
|
if (options?.userInitiated) {
|
|
|
|
|
releaseAutoFollow();
|
|
|
|
|
}
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-05-30 02:03:41 +03:00
|
|
|
try {
|
|
|
|
|
void (await fetchOlderHistory({ preserveViewport: true }));
|
2026-03-12 23:45:45 +02:00
|
|
|
} finally {
|
2026-05-30 02:03:41 +03:00
|
|
|
settleHistoryInteraction();
|
2026-03-12 23:45:45 +02:00
|
|
|
}
|
2026-06-15 03:29:40 +03:00
|
|
|
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]);
|
2026-05-30 02:03:41 +03:00
|
|
|
|
|
|
|
|
const handleHistoryScroll = React.useCallback(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
if (isPinnedRef.current) return;
|
|
|
|
|
if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return;
|
|
|
|
|
if (!historySignalsRef.current.canLoadEarlier) return;
|
|
|
|
|
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
|
|
|
|
|
|
|
|
|
|
void loadEarlier({ userInitiated: true });
|
|
|
|
|
}, [loadEarlier, scrollRef]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => {
|
|
|
|
|
if (historyInteractionRef.current) return;
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
if (!shouldAutoLoadEarlierForUnderfilledPinnedViewport({
|
|
|
|
|
sessionId: sessionIdRef.current,
|
|
|
|
|
isPinned: isPinnedRef.current,
|
|
|
|
|
canLoadEarlier: historySignalsRef.current.canLoadEarlier,
|
|
|
|
|
isLoadingOlder: isLoadingOlderRef.current,
|
|
|
|
|
pendingRevealWork: pendingRevealWorkRef.current,
|
|
|
|
|
scrollHeight: container.scrollHeight,
|
|
|
|
|
clientHeight: container.clientHeight,
|
|
|
|
|
})) {
|
2026-03-12 23:45:45 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
void loadEarlier();
|
|
|
|
|
}, [loadEarlier, scrollRef]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-05-30 02:03:41 +03:00
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const frame = window.requestAnimationFrame(() => {
|
2026-06-02 00:43:05 +03:00
|
|
|
loadEarlierIfPinnedViewportUnderfilled();
|
2026-05-30 02:03:41 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => window.cancelAnimationFrame(frame);
|
|
|
|
|
}, [
|
|
|
|
|
historySignals.canLoadEarlier,
|
|
|
|
|
isLoadingOlder,
|
|
|
|
|
isPinned,
|
2026-06-02 00:43:05 +03:00
|
|
|
loadEarlierIfPinnedViewportUnderfilled,
|
2026-05-30 02:03:41 +03:00
|
|
|
pendingRevealWork,
|
|
|
|
|
renderedMessages.length,
|
|
|
|
|
sessionId,
|
|
|
|
|
]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let frame: number | null = null;
|
|
|
|
|
const scheduleCheck = () => {
|
|
|
|
|
if (frame !== null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
frame = window.requestAnimationFrame(() => {
|
|
|
|
|
frame = null;
|
|
|
|
|
loadEarlierIfPinnedViewportUnderfilled();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const observer = new ResizeObserver(scheduleCheck);
|
|
|
|
|
observer.observe(container);
|
|
|
|
|
const content = container.firstElementChild;
|
|
|
|
|
if (content instanceof Element) {
|
|
|
|
|
observer.observe(content);
|
|
|
|
|
}
|
|
|
|
|
scheduleCheck();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (frame !== null) {
|
|
|
|
|
window.cancelAnimationFrame(frame);
|
|
|
|
|
}
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [loadEarlierIfPinnedViewportUnderfilled, scrollRef, sessionId]);
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const scrollToTurn = React.useCallback(async (
|
|
|
|
|
turnId: string,
|
|
|
|
|
options?: { behavior?: ScrollBehavior },
|
|
|
|
|
): Promise<boolean> => {
|
|
|
|
|
if (!turnId || !sessionIdRef.current) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 14:20:16 +03:00
|
|
|
releaseAutoFollow();
|
2026-03-12 23:45:45 +02:00
|
|
|
setPendingRevealWork(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (sessionIdRef.current !== sessionId) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const turnIndex = turnModelRef.current.turnIndexById.get(turnId);
|
|
|
|
|
if (typeof turnIndex !== 'number') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:36:08 +03:00
|
|
|
const result = await new Promise<boolean>((resolve) => {
|
|
|
|
|
pendingScrollRequestRef.current = {
|
|
|
|
|
sessionId: sessionIdRef.current ?? sessionId ?? '',
|
|
|
|
|
kind: 'turn',
|
|
|
|
|
id: turnId,
|
|
|
|
|
behavior: options?.behavior ?? 'auto',
|
|
|
|
|
turnId,
|
|
|
|
|
resolve,
|
|
|
|
|
};
|
|
|
|
|
attemptPendingScrollRequest();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (result) {
|
2026-03-12 23:45:45 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:36:08 +03:00
|
|
|
return false;
|
2026-03-12 23:45:45 +02:00
|
|
|
} finally {
|
|
|
|
|
setPendingRevealWork(false);
|
|
|
|
|
}
|
2026-05-08 14:20:16 +03:00
|
|
|
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
const scrollToMessage = React.useCallback(async (
|
|
|
|
|
messageId: string,
|
|
|
|
|
options?: { behavior?: ScrollBehavior },
|
|
|
|
|
): Promise<boolean> => {
|
|
|
|
|
if (!messageId || !sessionIdRef.current) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 14:20:16 +03:00
|
|
|
releaseAutoFollow();
|
2026-03-12 23:45:45 +02:00
|
|
|
setPendingRevealWork(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (sessionIdRef.current !== sessionId) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const turnId = turnModelRef.current.messageToTurnId.get(messageId);
|
|
|
|
|
const turnIndex = turnModelRef.current.messageToTurnIndex.get(messageId);
|
|
|
|
|
|
|
|
|
|
if (typeof turnIndex !== 'number') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:36:08 +03:00
|
|
|
const result = await new Promise<boolean>((resolve) => {
|
|
|
|
|
pendingScrollRequestRef.current = {
|
|
|
|
|
sessionId: sessionIdRef.current ?? sessionId ?? '',
|
|
|
|
|
kind: 'message',
|
|
|
|
|
id: messageId,
|
|
|
|
|
behavior: options?.behavior ?? 'auto',
|
|
|
|
|
turnId: turnId ?? null,
|
|
|
|
|
resolve,
|
|
|
|
|
};
|
|
|
|
|
attemptPendingScrollRequest();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (result) {
|
2026-03-12 23:45:45 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:36:08 +03:00
|
|
|
return false;
|
2026-03-12 23:45:45 +02:00
|
|
|
} finally {
|
|
|
|
|
setPendingRevealWork(false);
|
|
|
|
|
}
|
2026-05-08 14:20:16 +03:00
|
|
|
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
const resumeToBottom = React.useCallback(async () => {
|
2026-03-12 23:45:45 +02:00
|
|
|
setPendingRevealWork(false);
|
|
|
|
|
setIsLoadingOlder(false);
|
2026-05-08 14:20:16 +03:00
|
|
|
goToBottom('smooth');
|
2026-06-15 03:29:40 +03:00
|
|
|
}, [goToBottom]);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
const resumeToBottomInstant = React.useCallback(async () => {
|
2026-03-20 01:01:03 +02:00
|
|
|
setPendingRevealWork(false);
|
|
|
|
|
setIsLoadingOlder(false);
|
2026-05-08 14:20:16 +03:00
|
|
|
goToBottom('instant');
|
2026-06-15 03:29:40 +03:00
|
|
|
}, [goToBottom]);
|
2026-05-01 17:39:04 +08:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
|
|
|
|
setActiveTurnId(turnId);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
turnIds: turnWindowModel.turnIds,
|
2026-06-15 03:29:40 +03:00
|
|
|
turnStart: 0,
|
2026-03-12 23:45:45 +02:00
|
|
|
renderedMessages,
|
|
|
|
|
historySignals,
|
|
|
|
|
isLoadingOlder,
|
|
|
|
|
pendingRevealWork,
|
|
|
|
|
activeTurnId,
|
2026-05-08 14:20:16 +03:00
|
|
|
showScrollToBottom: showScrollButton && !pendingRevealWork,
|
2026-03-12 23:45:45 +02:00
|
|
|
turnWindowModel,
|
|
|
|
|
loadEarlier,
|
|
|
|
|
revealBufferedTurns,
|
|
|
|
|
resumeToBottom,
|
2026-03-20 01:01:03 +02:00
|
|
|
resumeToBottomInstant,
|
2026-03-12 23:45:45 +02:00
|
|
|
scrollToTurn,
|
|
|
|
|
scrollToMessage,
|
2026-05-30 02:03:41 +03:00
|
|
|
handleHistoryScroll,
|
2026-03-12 23:45:45 +02:00
|
|
|
captureViewportAnchor,
|
|
|
|
|
restoreViewportAnchor,
|
|
|
|
|
handleActiveTurnChange,
|
|
|
|
|
};
|
|
|
|
|
};
|