Treat the mobile web surface as a constrained runtime so sync loads smaller message pages, keeps fewer warm session caches, and evicts heavy inactive sessions instead of retaining them across switches. Limit mobile message-record and turn-model caches to reduce memory pressure on phones while preserving bounded initial page expansion for large final turns. Split the mobile session status bar so the collapsed state avoids subscribing to the full session/status list; the expensive grouping work now only mounts for the expanded list. Verified with bun run type-check and bun run lint.
560 lines
20 KiB
TypeScript
560 lines
20 KiB
TypeScript
import React from 'react';
|
|
|
|
import type { ChatMessageEntry } from '../lib/turns/types';
|
|
import type { MessageListHandle } from '../MessageList';
|
|
import { TURN_WINDOW_DEFAULTS } from '../lib/turns/constants';
|
|
import {
|
|
buildTurnWindowModel,
|
|
clampTurnStart,
|
|
getInitialTurnStart,
|
|
updateTurnWindowModelIncremental,
|
|
windowMessagesByTurn,
|
|
type TurnWindowModel,
|
|
} from '../lib/turns/windowTurns';
|
|
import type { TurnHistorySignals } from '../lib/turns/historySignals';
|
|
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
|
|
|
type ViewportAnchor = { messageId: string; offsetTop: number };
|
|
|
|
type PendingScrollRequest = {
|
|
sessionId: string;
|
|
kind: 'turn' | 'message';
|
|
id: string;
|
|
behavior: ScrollBehavior;
|
|
turnId: string | null;
|
|
resolve: (value: boolean) => void;
|
|
};
|
|
|
|
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>;
|
|
goToBottom: (mode?: 'instant' | 'smooth') => void;
|
|
releaseAutoFollow: () => void;
|
|
isPinned: boolean;
|
|
showScrollButton: boolean;
|
|
}
|
|
|
|
export interface UseChatTimelineControllerResult {
|
|
turnIds: string[];
|
|
turnStart: number;
|
|
renderedMessages: ChatMessageEntry[];
|
|
historySignals: TurnHistorySignals;
|
|
isLoadingOlder: boolean;
|
|
pendingRevealWork: boolean;
|
|
activeTurnId: string | null;
|
|
showScrollToBottom: boolean;
|
|
turnWindowModel: TurnWindowModel;
|
|
loadEarlier: () => Promise<void>;
|
|
revealBufferedTurns: () => Promise<boolean>;
|
|
resumeToBottom: () => void;
|
|
resumeToBottomInstant: () => Promise<void>;
|
|
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
|
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
|
captureViewportAnchor: () => ViewportAnchor | null;
|
|
restoreViewportAnchor: (anchor: ViewportAnchor) => boolean;
|
|
handleActiveTurnChange: (turnId: string | null) => void;
|
|
}
|
|
|
|
const TURN_MODEL_CACHE_MAX = 30
|
|
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
|
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
|
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
|
const MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
|
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
|
|
const getTurnModelCacheMax = () => {
|
|
if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX
|
|
if (isMobileSurfaceRuntime()) return MOBILE_TURN_MODEL_CACHE_MAX
|
|
return TURN_MODEL_CACHE_MAX
|
|
}
|
|
|
|
const shouldCacheTurnModelMessages = (messages: ChatMessageEntry[]): boolean => {
|
|
if (isVSCodeRuntime()) return messages.length <= VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES
|
|
if (isMobileSurfaceRuntime()) return messages.length <= MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES
|
|
return true
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
export const useChatTimelineController = ({
|
|
sessionId,
|
|
messages,
|
|
historyMeta,
|
|
scrollRef,
|
|
messageListRef,
|
|
loadMoreMessages,
|
|
goToBottom,
|
|
releaseAutoFollow,
|
|
isPinned,
|
|
showScrollButton,
|
|
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
|
|
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
|
|
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
|
const turnWindowModel = React.useMemo(() => {
|
|
const key = sessionId ?? ""
|
|
const cached = key ? turnModelCache.get(key) : undefined
|
|
if (cached && cached.messages === messages) {
|
|
rememberTurnModel(key, cached)
|
|
previousTurnWindowModelRef.current = cached.model
|
|
previousMessagesRef.current = messages
|
|
return cached.model
|
|
}
|
|
|
|
const incrementalModel = updateTurnWindowModelIncremental(
|
|
previousTurnWindowModelRef.current,
|
|
previousMessagesRef.current,
|
|
messages,
|
|
);
|
|
const nextModel = incrementalModel ?? buildTurnWindowModel(messages);
|
|
previousTurnWindowModelRef.current = nextModel;
|
|
previousMessagesRef.current = messages;
|
|
|
|
if (key && messages.length > 0) {
|
|
rememberTurnModel(key, { messages, model: nextModel })
|
|
}
|
|
|
|
return nextModel;
|
|
}, [messages, sessionId]);
|
|
|
|
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
|
|
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 turnStartRef = React.useRef(turnStart);
|
|
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 previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
|
|
const initializedSessionRef = React.useRef<string | null>(null);
|
|
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
|
|
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
|
|
|
|
const historySignals = React.useMemo(() => {
|
|
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
|
|
const hasBufferedTurns = turnStart > 0;
|
|
const hasMoreAboveTurns = historyMeta
|
|
? !historyMeta.complete
|
|
: messages.length >= defaultLimit;
|
|
const historyLoading = Boolean(historyMeta?.loading);
|
|
return {
|
|
hasBufferedTurns,
|
|
hasMoreAboveTurns,
|
|
historyLoading,
|
|
canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns,
|
|
};
|
|
}, [historyMeta, messages.length, turnStart]);
|
|
|
|
const historySignalsRef = React.useRef(historySignals);
|
|
|
|
turnModelRef.current = turnWindowModel;
|
|
turnStartRef.current = turnStart;
|
|
isPinnedRef.current = isPinned;
|
|
isLoadingOlderRef.current = isLoadingOlder;
|
|
pendingRevealWorkRef.current = pendingRevealWork;
|
|
historySignalsRef.current = historySignals;
|
|
sessionIdRef.current = sessionId;
|
|
messagesRef.current = messages;
|
|
historyMetaRef.current = historyMeta;
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (initializedSessionRef.current === sessionId) {
|
|
return;
|
|
}
|
|
initializedSessionRef.current = sessionId;
|
|
setTurnStart(getInitialTurnStart(turnWindowModel.turnCount));
|
|
setIsLoadingOlder(false);
|
|
setPendingRevealWork(false);
|
|
setActiveTurnId(null);
|
|
previousTurnCountRef.current = turnWindowModel.turnCount;
|
|
}, [sessionId, turnWindowModel.turnCount]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
|
|
}, [turnWindowModel.turnCount]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
const previousTurnCount = previousTurnCountRef.current;
|
|
const nextTurnCount = turnWindowModel.turnCount;
|
|
if (previousTurnCount === nextTurnCount) {
|
|
return;
|
|
}
|
|
|
|
setTurnStart((current) => {
|
|
const previousInitial = getInitialTurnStart(previousTurnCount);
|
|
const nextInitial = getInitialTurnStart(nextTurnCount);
|
|
if (isPinnedRef.current && current === previousInitial) {
|
|
return nextInitial;
|
|
}
|
|
return clampTurnStart(current, nextTurnCount);
|
|
});
|
|
|
|
previousTurnCountRef.current = nextTurnCount;
|
|
}, [turnWindowModel.turnCount]);
|
|
|
|
const resolvePendingRenderWaiters = React.useCallback(() => {
|
|
const resolvers = pendingRenderResolversRef.current;
|
|
if (resolvers.length === 0) {
|
|
return;
|
|
}
|
|
pendingRenderResolversRef.current = [];
|
|
resolvers.forEach((resolve) => resolve());
|
|
}, []);
|
|
|
|
const waitForNextRenderCommit = React.useCallback((): Promise<void> => {
|
|
return new Promise<void>((resolve) => {
|
|
pendingRenderResolversRef.current.push(resolve);
|
|
});
|
|
}, []);
|
|
|
|
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);
|
|
|
|
if (typeof targetIndex === 'number' && targetIndex >= turnStartRef.current) {
|
|
resolvePendingScrollRequest(false);
|
|
}
|
|
}, [messageListRef, resolvePendingScrollRequest]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
resolvePendingRenderWaiters();
|
|
resolvePendingScrollRequest(false);
|
|
};
|
|
}, [resolvePendingRenderWaiters, resolvePendingScrollRequest]);
|
|
|
|
const renderedMessages = React.useMemo(() => {
|
|
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
|
|
}, [messages, turnStart, turnWindowModel]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
resolvePendingRenderWaiters();
|
|
attemptPendingScrollRequest();
|
|
}, [attemptPendingScrollRequest, renderedMessages, resolvePendingRenderWaiters, turnStart]);
|
|
|
|
// --- 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);
|
|
|
|
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]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
const snap = prePrependScrollRef.current;
|
|
const container = scrollRef.current;
|
|
if (!snap || !container) return;
|
|
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)) {
|
|
return;
|
|
}
|
|
|
|
// Fallback: height-delta compensation
|
|
const delta = container.scrollHeight - snap.height;
|
|
if (delta > 0) {
|
|
container.scrollTop = snap.top + delta;
|
|
}
|
|
}, [renderedMessages, scrollRef, restoreViewportAnchor]);
|
|
|
|
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
|
|
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
|
|
return false;
|
|
}
|
|
|
|
const container = scrollRef.current;
|
|
if (container) {
|
|
prePrependScrollRef.current = {
|
|
height: container.scrollHeight,
|
|
top: container.scrollTop,
|
|
anchor: captureViewportAnchor(),
|
|
};
|
|
}
|
|
|
|
setPendingRevealWork(true);
|
|
setTurnStart((current) => {
|
|
const next = current - TURN_WINDOW_DEFAULTS.batchTurns;
|
|
return next > 0 ? next : 0;
|
|
});
|
|
|
|
await waitForNextRenderCommit();
|
|
setPendingRevealWork(false);
|
|
return true;
|
|
}, [captureViewportAnchor, scrollRef, waitForNextRenderCommit]);
|
|
|
|
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;
|
|
|
|
// 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(),
|
|
};
|
|
}
|
|
|
|
setIsLoadingOlder(true);
|
|
|
|
try {
|
|
const targetSessionId = sessionIdRef.current;
|
|
if (!targetSessionId) {
|
|
return false;
|
|
}
|
|
|
|
await loadMoreMessages(targetSessionId, 'up');
|
|
|
|
const afterMessages = messagesRef.current;
|
|
const afterMessageCount = afterMessages.length;
|
|
const afterOldestMessageId = afterMessages[0]?.info?.id ?? null;
|
|
const afterLimit = historyMetaRef.current?.limit ?? beforeLimit;
|
|
const historyGrew =
|
|
afterMessageCount > beforeMessageCount
|
|
|| (typeof beforeOldestMessageId === 'string'
|
|
&& typeof afterOldestMessageId === 'string'
|
|
&& beforeOldestMessageId !== afterOldestMessageId);
|
|
|
|
return historyGrew || afterLimit > beforeLimit;
|
|
} finally {
|
|
setIsLoadingOlder(false);
|
|
}
|
|
}, [captureViewportAnchor, loadMoreMessages, scrollRef]);
|
|
|
|
const loadEarlier = React.useCallback(async () => {
|
|
if (await revealBufferedTurns()) {
|
|
return;
|
|
}
|
|
|
|
void (await fetchOlderHistory({ preserveViewport: true }));
|
|
}, [fetchOlderHistory, revealBufferedTurns]);
|
|
|
|
const scrollToTurn = React.useCallback(async (
|
|
turnId: string,
|
|
options?: { behavior?: ScrollBehavior },
|
|
): Promise<boolean> => {
|
|
if (!turnId || !sessionIdRef.current) {
|
|
return false;
|
|
}
|
|
|
|
releaseAutoFollow();
|
|
setPendingRevealWork(true);
|
|
|
|
try {
|
|
if (sessionIdRef.current !== sessionId) {
|
|
return false;
|
|
}
|
|
|
|
const turnIndex = turnModelRef.current.turnIndexById.get(turnId);
|
|
if (typeof turnIndex !== 'number') {
|
|
return false;
|
|
}
|
|
|
|
if (turnIndex < turnStartRef.current) {
|
|
setTurnStart(turnIndex);
|
|
}
|
|
|
|
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) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} finally {
|
|
setPendingRevealWork(false);
|
|
}
|
|
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
|
|
|
|
const scrollToMessage = React.useCallback(async (
|
|
messageId: string,
|
|
options?: { behavior?: ScrollBehavior },
|
|
): Promise<boolean> => {
|
|
if (!messageId || !sessionIdRef.current) {
|
|
return false;
|
|
}
|
|
|
|
releaseAutoFollow();
|
|
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;
|
|
}
|
|
|
|
if (turnIndex < turnStartRef.current) {
|
|
setTurnStart(turnIndex);
|
|
}
|
|
|
|
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) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} finally {
|
|
setPendingRevealWork(false);
|
|
}
|
|
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
|
|
|
|
const resumeToBottom = React.useCallback(async () => {
|
|
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
|
setPendingRevealWork(false);
|
|
setIsLoadingOlder(false);
|
|
|
|
const shouldWaitForRender = nextStart !== turnStartRef.current;
|
|
if (shouldWaitForRender) {
|
|
setTurnStart(nextStart);
|
|
await waitForNextRenderCommit();
|
|
}
|
|
|
|
goToBottom('smooth');
|
|
}, [goToBottom, waitForNextRenderCommit]);
|
|
|
|
const resumeToBottomInstant = React.useCallback(async () => {
|
|
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
|
setPendingRevealWork(false);
|
|
setIsLoadingOlder(false);
|
|
|
|
const shouldWaitForRender = nextStart !== turnStartRef.current;
|
|
if (shouldWaitForRender) {
|
|
setTurnStart(nextStart);
|
|
await waitForNextRenderCommit();
|
|
}
|
|
|
|
goToBottom('instant');
|
|
}, [goToBottom, waitForNextRenderCommit]);
|
|
|
|
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
|
setActiveTurnId(turnId);
|
|
}, []);
|
|
|
|
return {
|
|
turnIds: turnWindowModel.turnIds,
|
|
turnStart,
|
|
renderedMessages,
|
|
historySignals,
|
|
isLoadingOlder,
|
|
pendingRevealWork,
|
|
activeTurnId,
|
|
showScrollToBottom: showScrollButton && !pendingRevealWork,
|
|
turnWindowModel,
|
|
loadEarlier,
|
|
revealBufferedTurns,
|
|
resumeToBottom,
|
|
resumeToBottomInstant,
|
|
scrollToTurn,
|
|
scrollToMessage,
|
|
captureViewportAnchor,
|
|
restoreViewportAnchor,
|
|
handleActiveTurnChange,
|
|
};
|
|
};
|