2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2026-01-19 02:48:56 +02:00
|
|
|
import { flushSync } from 'react-dom';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
|
|
|
|
|
|
|
|
|
import { useScrollEngine } from './useScrollEngine';
|
|
|
|
|
|
|
|
|
|
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
|
|
|
|
|
|
|
|
|
|
export type ContentChangeReason = 'text' | 'structural' | 'permission';
|
|
|
|
|
|
|
|
|
|
interface ChatMessageRecord {
|
|
|
|
|
info: Record<string, unknown>;
|
|
|
|
|
parts: Part[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SessionMemoryState {
|
|
|
|
|
viewportAnchor: number;
|
|
|
|
|
isStreaming: boolean;
|
|
|
|
|
lastAccessedAt: number;
|
|
|
|
|
backgroundMessageCount: number;
|
|
|
|
|
totalAvailableMessages?: number;
|
|
|
|
|
hasMoreAbove?: boolean;
|
|
|
|
|
streamStartTime?: number;
|
|
|
|
|
isZombie?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
|
|
|
|
|
|
|
|
|
|
interface UseChatScrollManagerOptions {
|
|
|
|
|
currentSessionId: string | null;
|
|
|
|
|
sessionMessages: ChatMessageRecord[];
|
|
|
|
|
sessionPermissions: unknown[];
|
|
|
|
|
streamingMessageId: string | null;
|
|
|
|
|
sessionMemoryState: Map<string, SessionMemoryState>;
|
|
|
|
|
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
2025-12-29 12:12:52 +02:00
|
|
|
updateActiveTurnAnchor: (sessionId: string, anchorId: string | null, spacerHeight: number) => void;
|
|
|
|
|
getActiveTurnAnchor: (sessionId: string) => { anchorId: string | null; spacerHeight: number } | null;
|
2025-12-07 19:32:53 +02:00
|
|
|
isSyncing: boolean;
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
messageStreamStates: Map<string, unknown>;
|
|
|
|
|
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
|
|
|
|
|
sessionActivityPhase?: Map<string, SessionActivityPhase>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AnimationHandlers {
|
|
|
|
|
onChunk: () => void;
|
|
|
|
|
onComplete: () => void;
|
|
|
|
|
onStreamingCandidate?: () => void;
|
|
|
|
|
onAnimationStart?: () => void;
|
|
|
|
|
onReservationCancelled?: () => void;
|
|
|
|
|
onReasoningBlock?: () => void;
|
|
|
|
|
onAnimatedHeightChange?: (height: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface UseChatScrollManagerResult {
|
|
|
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
|
|
|
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
showScrollButton: boolean;
|
2026-01-19 02:48:56 +02:00
|
|
|
scrollToBottom: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void;
|
2025-12-07 19:32:53 +02:00
|
|
|
spacerHeight: number;
|
|
|
|
|
pendingAnchorId: string | null;
|
|
|
|
|
hasActiveAnchor: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const ANCHOR_TARGET_OFFSET = 8;
|
2025-12-07 19:32:53 +02:00
|
|
|
const DEFAULT_SCROLL_BUTTON_THRESHOLD = 40;
|
2026-01-19 02:48:56 +02:00
|
|
|
const NEW_USER_ANCHOR_WINDOW_MS = 20_000;
|
|
|
|
|
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
2026-01-19 15:38:15 +02:00
|
|
|
// After we set an anchor/spacer, ignore incidental scroll events for a bit.
|
|
|
|
|
const ANCHOR_CLEAR_GRACE_MS = 1200;
|
|
|
|
|
// Require recent direct user input (wheel/touch) to treat scroll as intentional.
|
|
|
|
|
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
2026-01-19 16:23:27 +02:00
|
|
|
const ANCHOR_CLEAR_TOLERANCE_PX = 24;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const getMessageId = (message: ChatMessageRecord): string | null => {
|
|
|
|
|
const info = message.info;
|
|
|
|
|
if (typeof info?.id === 'string') {
|
|
|
|
|
return info.id;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isUserMessage = (message: ChatMessageRecord): boolean => {
|
|
|
|
|
const info = message.info;
|
|
|
|
|
if (info?.userMessageMarker === true) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const clientRole = info?.clientRole;
|
|
|
|
|
const serverRole = info?.role;
|
|
|
|
|
return clientRole === 'user' || serverRole === 'user';
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const getMessageCreatedAt = (message: ChatMessageRecord): number => {
|
|
|
|
|
const info = message.info as { time?: { created?: unknown } };
|
|
|
|
|
const created = info?.time?.created;
|
|
|
|
|
return typeof created === 'number' ? created : 0;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export const useChatScrollManager = ({
|
|
|
|
|
currentSessionId,
|
|
|
|
|
sessionMessages,
|
2026-01-19 02:48:56 +02:00
|
|
|
streamingMessageId,
|
2025-12-07 19:32:53 +02:00
|
|
|
updateViewportAnchor,
|
2025-12-29 12:12:52 +02:00
|
|
|
updateActiveTurnAnchor,
|
|
|
|
|
getActiveTurnAnchor,
|
2025-12-07 19:32:53 +02:00
|
|
|
isSyncing,
|
|
|
|
|
isMobile,
|
|
|
|
|
sessionActivityPhase,
|
|
|
|
|
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
|
|
|
|
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
|
|
|
|
|
|
|
|
|
|
const [anchorId, setAnchorId] = React.useState<string | null>(null);
|
|
|
|
|
const [spacerHeight, setSpacerHeight] = React.useState(0);
|
|
|
|
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
|
|
|
|
const [pendingAnchorId, setPendingAnchorId] = React.useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const lastScrolledAnchorIdRef = React.useRef<string | null>(null);
|
|
|
|
|
const lastSessionIdRef = React.useRef<string | null>(null);
|
2026-01-18 18:00:19 +02:00
|
|
|
const currentSessionIdRef = React.useRef<string | null>(currentSessionId ?? null);
|
2026-01-19 02:48:56 +02:00
|
|
|
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
2026-01-19 15:38:15 +02:00
|
|
|
const anchorClearIgnoreUntilRef = React.useRef<number>(0);
|
|
|
|
|
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
|
2026-01-19 02:48:56 +02:00
|
|
|
const previousMessageIdsRef = React.useRef<Set<string>>(new Set());
|
2025-12-07 19:32:53 +02:00
|
|
|
const lastMessageCountRef = React.useRef<number>(sessionMessages.length);
|
|
|
|
|
const spacerHeightRef = React.useRef(0);
|
|
|
|
|
|
|
|
|
|
const anchorIdRef = React.useRef<string | null>(null);
|
2026-01-19 16:23:27 +02:00
|
|
|
const pendingRestoreAnchorRef = React.useRef<{ sessionId: string; anchorId: string; startedAt: number } | null>(null);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
const userScrollOverrideRef = React.useRef<boolean>(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const currentPhase = currentSessionId
|
|
|
|
|
? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
|
|
|
|
|
: 'idle';
|
2026-01-19 02:48:56 +02:00
|
|
|
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-18 18:00:19 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
currentSessionIdRef.current = currentSessionId ?? null;
|
|
|
|
|
}, [currentSessionId]);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const updateSpacerHeight = React.useCallback((height: number) => {
|
|
|
|
|
const newHeight = Math.max(0, height);
|
|
|
|
|
if (spacerHeightRef.current !== newHeight) {
|
|
|
|
|
spacerHeightRef.current = newHeight;
|
|
|
|
|
setSpacerHeight(newHeight);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-01-19 16:23:27 +02:00
|
|
|
const calculateAnchorPosition = React.useCallback((anchorElement: HTMLElement): number => {
|
|
|
|
|
const messageTop = anchorElement.offsetTop;
|
|
|
|
|
return messageTop - ANCHOR_TARGET_OFFSET;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const isAnchorStillPinned = React.useCallback((): boolean => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const container = scrollRef.current;
|
2026-01-19 16:23:27 +02:00
|
|
|
const anchorId = anchorIdRef.current;
|
|
|
|
|
if (!container || !anchorId) return false;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 16:23:27 +02:00
|
|
|
const anchorElement = container.querySelector(`[data-message-id="${anchorId}"]`) as HTMLElement | null;
|
|
|
|
|
if (!anchorElement) return false;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 16:23:27 +02:00
|
|
|
const expectedTop = calculateAnchorPosition(anchorElement);
|
|
|
|
|
const distance = Math.abs(container.scrollTop - expectedTop);
|
|
|
|
|
return distance <= ANCHOR_CLEAR_TOLERANCE_PX;
|
|
|
|
|
}, [calculateAnchorPosition]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const clearActiveTurnAnchor = React.useCallback((sessionId: string) => {
|
|
|
|
|
anchorIdRef.current = null;
|
|
|
|
|
lastScrolledAnchorIdRef.current = null;
|
|
|
|
|
pendingRestoreAnchorRef.current = null;
|
|
|
|
|
setAnchorId(null);
|
|
|
|
|
updateSpacerHeight(0);
|
|
|
|
|
updateActiveTurnAnchor(sessionId, null, 0);
|
|
|
|
|
}, [updateActiveTurnAnchor, updateSpacerHeight]);
|
|
|
|
|
|
|
|
|
|
const markProgrammaticScroll = React.useCallback(() => {
|
|
|
|
|
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
2026-01-18 18:00:19 +02:00
|
|
|
}, []);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const updateScrollButtonVisibility = React.useCallback(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) {
|
|
|
|
|
setShowScrollButton(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pendingAnchorId) {
|
|
|
|
|
setShowScrollButton(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
|
|
|
|
if (!hasScrollableContent) {
|
|
|
|
|
setShowScrollButton(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
|
|
|
const currentSpacerHeight = spacerHeightRef.current;
|
|
|
|
|
|
|
|
|
|
if (currentSpacerHeight > 0) {
|
|
|
|
|
const spacerStartPosition = container.scrollHeight - currentSpacerHeight;
|
|
|
|
|
const viewportBottom = container.scrollTop + container.clientHeight;
|
|
|
|
|
setShowScrollButton(viewportBottom < spacerStartPosition);
|
|
|
|
|
} else {
|
|
|
|
|
setShowScrollButton(distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD);
|
|
|
|
|
}
|
|
|
|
|
}, [pendingAnchorId]);
|
|
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
|
|
|
|
|
|
|
|
const shouldRespectUserScroll =
|
|
|
|
|
userScrollOverrideRef.current &&
|
|
|
|
|
currentPhase === 'idle' &&
|
|
|
|
|
!isSyncing &&
|
|
|
|
|
!options?.force &&
|
|
|
|
|
distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD;
|
|
|
|
|
|
|
|
|
|
if (shouldRespectUserScroll) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (options?.force) {
|
|
|
|
|
userScrollOverrideRef.current = false;
|
2026-01-19 02:48:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (options?.clearAnchor && currentSessionId && anchorIdRef.current) {
|
|
|
|
|
clearActiveTurnAnchor(currentSessionId);
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const bottom = container.scrollHeight - container.clientHeight;
|
2026-01-19 02:48:56 +02:00
|
|
|
markProgrammaticScroll();
|
2025-12-07 19:32:53 +02:00
|
|
|
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
2026-01-19 02:48:56 +02:00
|
|
|
}, [clearActiveTurnAnchor, currentPhase, currentSessionId, isSyncing, markProgrammaticScroll, scrollEngine]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const scrollToNewAnchor = React.useCallback((messageId: string) => {
|
|
|
|
|
if (lastScrolledAnchorIdRef.current === messageId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
lastScrolledAnchorIdRef.current = messageId;
|
|
|
|
|
|
2026-01-19 15:38:15 +02:00
|
|
|
// Give the UI a grace window so incidental scroll/layout events don't clear the anchor.
|
|
|
|
|
anchorClearIgnoreUntilRef.current = Date.now() + ANCHOR_CLEAR_GRACE_MS;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
setPendingAnchorId(messageId);
|
2026-01-18 18:00:19 +02:00
|
|
|
const expectedSessionId = currentSessionIdRef.current;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
if (expectedSessionId !== currentSessionIdRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) {
|
|
|
|
|
setPendingAnchorId(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const anchorElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;
|
|
|
|
|
if (!anchorElement) {
|
|
|
|
|
setPendingAnchorId(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const containerHeight = container.clientHeight;
|
|
|
|
|
const targetScrollTop = calculateAnchorPosition(anchorElement);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const contentHeight = container.scrollHeight;
|
|
|
|
|
const currentSpacer = spacerHeightRef.current;
|
|
|
|
|
const contentWithoutSpacer = contentHeight - currentSpacer;
|
|
|
|
|
const requiredHeight = targetScrollTop + containerHeight;
|
2025-12-29 12:12:52 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
let newSpacerHeight = 0;
|
|
|
|
|
if (contentWithoutSpacer < requiredHeight) {
|
|
|
|
|
newSpacerHeight = requiredHeight - contentWithoutSpacer;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
if (newSpacerHeight !== currentSpacer) {
|
|
|
|
|
updateSpacerHeight(newSpacerHeight);
|
2026-01-18 18:00:19 +02:00
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
if (currentSessionIdRef.current) {
|
|
|
|
|
updateActiveTurnAnchor(currentSessionIdRef.current, messageId, newSpacerHeight);
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
window.requestAnimationFrame(() => {
|
2026-01-18 18:00:19 +02:00
|
|
|
if (expectedSessionId !== currentSessionIdRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-19 02:48:56 +02:00
|
|
|
|
|
|
|
|
markProgrammaticScroll();
|
|
|
|
|
scrollEngine.scrollToPosition(Math.max(0, targetScrollTop), { instant: true });
|
|
|
|
|
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
if (expectedSessionId !== currentSessionIdRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setPendingAnchorId(null);
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
});
|
|
|
|
|
});
|
2026-01-19 02:48:56 +02:00
|
|
|
}, [calculateAnchorPosition, markProgrammaticScroll, scrollEngine, updateActiveTurnAnchor, updateSpacerHeight]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
const handleScrollEvent = React.useCallback((event?: Event) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container || !currentSessionId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 15:38:15 +02:00
|
|
|
const now = Date.now();
|
|
|
|
|
const isProgrammatic = now < suppressUserScrollUntilRef.current || pendingAnchorId !== null;
|
|
|
|
|
|
|
|
|
|
const hasDirectIntent = now - lastDirectScrollIntentAtRef.current <= DIRECT_SCROLL_INTENT_WINDOW_MS;
|
2026-01-19 02:48:56 +02:00
|
|
|
|
2026-01-19 15:38:15 +02:00
|
|
|
if (event?.isTrusted && !isProgrammatic && hasDirectIntent) {
|
2025-12-13 16:34:17 +02:00
|
|
|
userScrollOverrideRef.current = true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
scrollEngine.handleScroll();
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
|
2026-01-19 15:38:15 +02:00
|
|
|
const shouldIgnoreAnchorClear = now < anchorClearIgnoreUntilRef.current;
|
|
|
|
|
|
2026-01-18 18:00:19 +02:00
|
|
|
if (
|
|
|
|
|
event?.isTrusted &&
|
2026-01-19 02:48:56 +02:00
|
|
|
!isProgrammatic &&
|
2026-01-19 15:38:15 +02:00
|
|
|
!shouldIgnoreAnchorClear &&
|
|
|
|
|
hasDirectIntent &&
|
2026-01-19 02:48:56 +02:00
|
|
|
currentPhase === 'idle' &&
|
2026-01-18 18:00:19 +02:00
|
|
|
anchorIdRef.current !== null &&
|
2026-01-19 02:48:56 +02:00
|
|
|
spacerHeightRef.current > 0 &&
|
2026-01-19 16:23:27 +02:00
|
|
|
// Only clear when the user actually scrolls away from the pinned anchor.
|
|
|
|
|
// (Spacer being out of viewport is expected while anchored.)
|
|
|
|
|
!isAnchorStillPinned()
|
2026-01-18 18:00:19 +02:00
|
|
|
) {
|
2026-01-19 02:48:56 +02:00
|
|
|
clearActiveTurnAnchor(currentSessionId);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
|
|
|
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
|
|
|
|
|
const estimatedIndex = Math.floor(position * sessionMessages.length);
|
|
|
|
|
updateViewportAnchor(currentSessionId, estimatedIndex);
|
|
|
|
|
}, [
|
2026-01-19 02:48:56 +02:00
|
|
|
clearActiveTurnAnchor,
|
|
|
|
|
currentPhase,
|
2025-12-07 19:32:53 +02:00
|
|
|
currentSessionId,
|
2026-01-19 16:23:27 +02:00
|
|
|
isAnchorStillPinned,
|
2026-01-19 02:48:56 +02:00
|
|
|
pendingAnchorId,
|
2025-12-07 19:32:53 +02:00
|
|
|
scrollEngine,
|
|
|
|
|
sessionMessages.length,
|
|
|
|
|
updateScrollButtonVisibility,
|
|
|
|
|
updateViewportAnchor,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
2026-01-19 15:38:15 +02:00
|
|
|
const markDirectIntent = () => {
|
|
|
|
|
lastDirectScrollIntentAtRef.current = Date.now();
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true });
|
2026-01-19 15:38:15 +02:00
|
|
|
container.addEventListener('wheel', markDirectIntent as EventListener, { passive: true });
|
|
|
|
|
container.addEventListener('touchmove', markDirectIntent as EventListener, { passive: true });
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
return () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
container.removeEventListener('scroll', handleScrollEvent as EventListener);
|
2026-01-19 15:38:15 +02:00
|
|
|
container.removeEventListener('wheel', markDirectIntent as EventListener);
|
|
|
|
|
container.removeEventListener('touchmove', markDirectIntent as EventListener);
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
}, [handleScrollEvent]);
|
|
|
|
|
|
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
2026-01-19 02:48:56 +02:00
|
|
|
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
lastSessionIdRef.current = currentSessionId;
|
|
|
|
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
previousMessageIdsRef.current = new Set(
|
|
|
|
|
sessionMessages.map(getMessageId).filter((id): id is string => Boolean(id))
|
|
|
|
|
);
|
|
|
|
|
lastMessageCountRef.current = sessionMessages.length;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
if (isActivePhase) {
|
2025-12-29 12:12:52 +02:00
|
|
|
const persistedAnchor = getActiveTurnAnchor(currentSessionId);
|
2026-01-18 18:00:19 +02:00
|
|
|
if (persistedAnchor && persistedAnchor.anchorId) {
|
2025-12-29 12:12:52 +02:00
|
|
|
anchorIdRef.current = persistedAnchor.anchorId;
|
|
|
|
|
lastScrolledAnchorIdRef.current = persistedAnchor.anchorId;
|
|
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
const anchorElement = container
|
|
|
|
|
? (container.querySelector(`[data-message-id="${persistedAnchor.anchorId}"]`) as HTMLElement | null)
|
|
|
|
|
: null;
|
|
|
|
|
const messageHeight = anchorElement?.offsetHeight ?? 0;
|
2026-01-19 03:42:34 +02:00
|
|
|
const restoredSpacerHeight = Math.max(0, persistedAnchor.spacerHeight - (messageHeight - 50));
|
2026-01-19 02:48:56 +02:00
|
|
|
|
|
|
|
|
flushSync(() => {
|
|
|
|
|
setAnchorId(persistedAnchor.anchorId);
|
|
|
|
|
updateSpacerHeight(restoredSpacerHeight);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-19 16:23:27 +02:00
|
|
|
pendingRestoreAnchorRef.current = { sessionId: currentSessionId, anchorId: persistedAnchor.anchorId, startedAt: Date.now() };
|
2025-12-29 12:12:52 +02:00
|
|
|
} else {
|
|
|
|
|
lastScrolledAnchorIdRef.current = null;
|
|
|
|
|
anchorIdRef.current = null;
|
|
|
|
|
setAnchorId(null);
|
2026-01-18 18:00:19 +02:00
|
|
|
updateSpacerHeight(0);
|
|
|
|
|
pendingRestoreAnchorRef.current = null;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (container) {
|
|
|
|
|
const bottom = container.scrollHeight - container.clientHeight;
|
|
|
|
|
markProgrammaticScroll();
|
|
|
|
|
scrollEngine.scrollToPosition(Math.max(0, bottom), { instant: true });
|
|
|
|
|
}
|
2025-12-29 12:12:52 +02:00
|
|
|
}
|
2026-01-19 02:48:56 +02:00
|
|
|
} else {
|
|
|
|
|
lastScrolledAnchorIdRef.current = null;
|
|
|
|
|
anchorIdRef.current = null;
|
|
|
|
|
setAnchorId(null);
|
|
|
|
|
updateSpacerHeight(0);
|
|
|
|
|
pendingRestoreAnchorRef.current = null;
|
|
|
|
|
updateActiveTurnAnchor(currentSessionId, null, 0);
|
2025-12-18 19:02:42 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (container) {
|
|
|
|
|
const bottom = container.scrollHeight - container.clientHeight;
|
|
|
|
|
markProgrammaticScroll();
|
|
|
|
|
scrollEngine.scrollToPosition(Math.max(0, bottom), { instant: true });
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-01-19 02:48:56 +02:00
|
|
|
|
|
|
|
|
setPendingAnchorId(null);
|
|
|
|
|
setShowScrollButton(false);
|
|
|
|
|
userScrollOverrideRef.current = false;
|
|
|
|
|
}, [
|
|
|
|
|
currentSessionId,
|
|
|
|
|
getActiveTurnAnchor,
|
|
|
|
|
isActivePhase,
|
|
|
|
|
markProgrammaticScroll,
|
|
|
|
|
scrollEngine,
|
|
|
|
|
updateActiveTurnAnchor,
|
|
|
|
|
updateSpacerHeight,
|
|
|
|
|
sessionMessages,
|
|
|
|
|
]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-18 18:00:19 +02:00
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
if (!currentSessionId) return;
|
|
|
|
|
|
|
|
|
|
const pending = pendingRestoreAnchorRef.current;
|
|
|
|
|
if (!pending || pending.sessionId !== currentSessionId) return;
|
|
|
|
|
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
2026-01-19 16:23:27 +02:00
|
|
|
const anchorElement = container.querySelector(`[data-message-id="${pending.anchorId}"]`) as HTMLElement | null;
|
|
|
|
|
if (!anchorElement) {
|
|
|
|
|
// When the anchor is created from a just-sent user message, the persisted anchor can
|
|
|
|
|
// show up before the message is in the rendered list. Give it a short window.
|
|
|
|
|
if (Date.now() - pending.startedAt < 1200) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-19 02:48:56 +02:00
|
|
|
clearActiveTurnAnchor(currentSessionId);
|
2026-01-18 18:00:19 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const targetScrollTop = calculateAnchorPosition(anchorElement);
|
|
|
|
|
markProgrammaticScroll();
|
2026-01-18 18:00:19 +02:00
|
|
|
scrollEngine.scrollToPosition(targetScrollTop, { instant: true });
|
|
|
|
|
pendingRestoreAnchorRef.current = null;
|
2026-01-19 02:48:56 +02:00
|
|
|
}, [calculateAnchorPosition, clearActiveTurnAnchor, currentSessionId, markProgrammaticScroll, scrollEngine, sessionMessages]);
|
2026-01-18 18:00:19 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
|
|
|
if (isSyncing) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lastSessionIdRef.current !== currentSessionId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const previousIds = previousMessageIdsRef.current;
|
|
|
|
|
const nextIds = new Set(sessionMessages.map(getMessageId).filter((id): id is string => Boolean(id)));
|
2025-12-07 19:32:53 +02:00
|
|
|
const nextCount = sessionMessages.length;
|
|
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
if (nextCount > lastMessageCountRef.current) {
|
|
|
|
|
const addedIds: string[] = [];
|
|
|
|
|
nextIds.forEach((id) => {
|
|
|
|
|
if (!previousIds.has(id)) {
|
|
|
|
|
addedIds.push(id);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-07 22:03:23 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
if (addedIds.length > 0) {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
let latestNewUserMessageId: string | null = null;
|
|
|
|
|
let latestNewUserCreatedAt = 0;
|
2025-12-07 22:03:23 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
for (let i = 0; i < sessionMessages.length; i++) {
|
|
|
|
|
const message = sessionMessages[i];
|
|
|
|
|
const id = getMessageId(message);
|
|
|
|
|
if (!id || !addedIds.includes(id)) continue;
|
|
|
|
|
if (!isUserMessage(message)) continue;
|
2025-12-07 22:03:23 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
let createdAt = getMessageCreatedAt(message);
|
|
|
|
|
if (createdAt <= 0 && (Boolean(streamingMessageId) || isActivePhase)) {
|
|
|
|
|
createdAt = now;
|
|
|
|
|
}
|
|
|
|
|
if (createdAt >= latestNewUserCreatedAt) {
|
|
|
|
|
latestNewUserCreatedAt = createdAt;
|
|
|
|
|
latestNewUserMessageId = id;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
const shouldAnchorNewUser =
|
|
|
|
|
latestNewUserMessageId !== null &&
|
|
|
|
|
(Boolean(streamingMessageId) ||
|
|
|
|
|
isActivePhase ||
|
|
|
|
|
now - latestNewUserCreatedAt <= NEW_USER_ANCHOR_WINDOW_MS);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-19 02:48:56 +02:00
|
|
|
if (shouldAnchorNewUser && latestNewUserMessageId) {
|
|
|
|
|
anchorIdRef.current = latestNewUserMessageId;
|
|
|
|
|
setAnchorId(latestNewUserMessageId);
|
|
|
|
|
scrollToNewAnchor(latestNewUserMessageId);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastMessageCountRef.current = nextCount;
|
2026-01-19 02:48:56 +02:00
|
|
|
previousMessageIdsRef.current = nextIds;
|
|
|
|
|
}, [currentSessionId, isActivePhase, isSyncing, scrollToNewAnchor, sessionMessages, streamingMessageId]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const container = scrollRef.current;
|
|
|
|
|
if (!container || typeof ResizeObserver === 'undefined') return;
|
|
|
|
|
|
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
observer.observe(container);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
};
|
2026-01-19 02:48:56 +02:00
|
|
|
}, [updateScrollButtonVisibility]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-18 19:02:42 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rafId = window.requestAnimationFrame(() => {
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.cancelAnimationFrame(rafId);
|
|
|
|
|
};
|
|
|
|
|
}, [currentSessionId, sessionMessages.length, updateScrollButtonVisibility]);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (anchorId) {
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
}
|
2026-01-19 02:48:56 +02:00
|
|
|
}, [anchorId, updateScrollButtonVisibility]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
updateScrollButtonVisibility();
|
|
|
|
|
}, [spacerHeight, updateScrollButtonVisibility]);
|
|
|
|
|
|
|
|
|
|
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
|
|
|
|
|
|
|
|
|
const handleMessageContentChange = React.useCallback(() => {
|
|
|
|
|
updateScrollButtonVisibility();
|
2026-01-19 02:48:56 +02:00
|
|
|
}, [updateScrollButtonVisibility]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
|
|
|
|
const existing = animationHandlersRef.current.get(messageId);
|
|
|
|
|
if (existing) {
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handlers: AnimationHandlers = {
|
|
|
|
|
onChunk: () => {
|
2026-01-19 02:48:56 +02:00
|
|
|
updateScrollButtonVisibility();
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
onComplete: () => {
|
2026-01-19 02:48:56 +02:00
|
|
|
updateScrollButtonVisibility();
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
onStreamingCandidate: () => {
|
|
|
|
|
|
|
|
|
|
},
|
|
|
|
|
onAnimationStart: () => {
|
|
|
|
|
|
|
|
|
|
},
|
|
|
|
|
onAnimatedHeightChange: () => {
|
2026-01-19 02:48:56 +02:00
|
|
|
updateScrollButtonVisibility();
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
onReservationCancelled: () => {
|
|
|
|
|
|
|
|
|
|
},
|
|
|
|
|
onReasoningBlock: () => {
|
|
|
|
|
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
animationHandlersRef.current.set(messageId, handlers);
|
|
|
|
|
return handlers;
|
2026-01-19 02:48:56 +02:00
|
|
|
}, [updateScrollButtonVisibility]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
scrollRef,
|
|
|
|
|
handleMessageContentChange,
|
|
|
|
|
getAnimationHandlers,
|
|
|
|
|
showScrollButton,
|
|
|
|
|
scrollToBottom,
|
|
|
|
|
spacerHeight,
|
|
|
|
|
pendingAnchorId,
|
|
|
|
|
hasActiveAnchor: anchorId !== null,
|
|
|
|
|
};
|
|
|
|
|
};
|