refactor: redesign chat scroll system

The previous system layered three hooks (useScrollEngine, useChatScrollManager,
useChatTimelineController) with overlapping responsibilities, four parallel
ResizeObservers/MutationObservers, and six entry points to "scroll to bottom"
(force-flag combinations, persistent follow loops, materialization recovery).
This produced bugs where users could not break free of auto-follow during
streaming: scrollbar drag, keyboard scrolling and find-in-page were not
detected as user intent, and observers kept restarting the follow loop on
every DOM mutation.

The new architecture replaces the two low-level hooks with a single
useChatAutoFollow that owns scroll behaviour end to end:

- One state: 'following' or 'released'. No follow modes, no pin flags,
  no marker pixels.
- One scroll writer: a lerp loop that runs only while the session is
  streaming and state is 'following'. Idle sessions never write scrollTop
  programmatically.
- One user-intent detector: wheel up, touch drag down, keyboard
  (PageUp / Home / ArrowUp), pointerdown on the OverlayScrollbar thumb,
  and explicit releaseAutoFollow() calls all flip the state to 'released'.
- A 1.2s grace period after explicit release: re-pin will not auto-engage
  inside this window, so a small wheel up cannot snap the user back even
  while they remain near the bottom spacer.
- Re-pin and the scroll-to-bottom button share the same threshold: the
  height of the empty bottom spacer (10vh on desktop, 40px on mobile).
  Released users see the button only after they have scrolled past the
  spacer that already exists at the end of the chat.
- Save/restore of scroll position uses ratio mapping, debounced at 150ms
  on user-driven scroll events; programmatic writes are masked via a short
  window so they never persist as user positions.
- Container reattachment is detected via a useLayoutEffect probe over
  scrollRef.current. Listeners and observers re-bind when ChatViewport
  mounts after hydration or after the first message promotes a draft
  session into a real chat.
- A pending-restore queue replays restoreSnapshot once the scroll
  container appears, fixing the case where a hydrating session landed at
  the top instead of the bottom.

Removed: useScrollEngine.ts, useChatScrollManager.ts, the persistent
follow loop with its own ResizeObserver+MutationObserver pair, the
materialization-recovery .finally resume that yanked idle users to the
bottom on transient sync gaps, and the openchamber:session-reselected
event (re-select still works through the existing onSessionSelected
callback). The openchamber:chat-force-scroll-bottom event remains for
synthetic-message paths like git-message generation.

Net change: ~1300 lines removed, two hooks replaced with one, one
observer pair instead of four.
This commit is contained in:
Bohdan Triapitsyn
2026-05-08 14:20:16 +03:00
parent e1ff21bc0a
commit 0ea573f766
17 changed files with 799 additions and 1416 deletions
+49 -131
View File
@@ -12,7 +12,7 @@ import { QuestionCard } from './QuestionCard';
import { StatusRowContainer } from './StatusRowContainer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager';
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { TimelineDialog } from './TimelineDialog';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
@@ -29,7 +29,6 @@ import {
// New sync system imports
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useStreamingStore } from '@/sync/streaming';
import {
useSessionMessageCount,
@@ -48,7 +47,6 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
const CHAT_SCROLL_STYLE = {
@@ -153,7 +151,7 @@ type ChatViewportProps = {
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
handleLoadOlder: () => void;
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom: () => void;
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
@@ -328,9 +326,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
const isSyncing = useViewportStore((s) => s.isSyncing);
const sessionMemoryStateMap = useViewportStore((s) => s.sessionMemoryState);
// Sync actions
const sync = useSync();
@@ -347,7 +342,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// UI store
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
@@ -538,10 +532,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
}
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
const sessionBlockingCards = React.useMemo(() => {
return [...sessionPermissions, ...sessionQuestions];
}, [sessionPermissions, sessionQuestions]);
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
activeTurnChangeRef.current(turnId);
@@ -549,24 +539,19 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
const {
scrollRef,
handleMessageContentChange,
notifyContentChange: handleMessageContentChange,
getAnimationHandlers,
prepareForBottomResume,
scrollToBottom,
goToBottom,
releaseAutoFollow,
restoreSnapshot,
isPinned,
isOverflowing,
isProgrammaticFollowActive,
clearRestoreInProgress,
} = useChatScrollManager({
isFollowingProgrammatically,
showScrollButton,
} = useChatAutoFollow({
currentSessionId,
sessionMessageCount,
sessionIsWorking,
sessionMemoryState: sessionMemoryStateMap,
updateViewportAnchor,
isSyncing,
isMobile,
chatRenderMode,
sessionPermissions: sessionBlockingCards,
onActiveTurnChange: handleActiveTurnChange,
});
@@ -579,47 +564,16 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
scrollRef,
messageListRef,
loadMoreMessages,
prepareForBottomResume,
scrollToBottom,
goToBottom,
releaseAutoFollow,
isPinned,
isOverflowing,
showScrollButton,
});
const { loadEarlier, resumeToBottomInstant, restoreSavedScrollPosition } = timelineController;
const { loadEarlier } = timelineController;
const runLatestInstantResume = React.useCallback(async (options?: { force?: boolean }) => {
if (!currentSessionId) {
scrollToBottom({ instant: true, force: true });
return;
}
if (options?.force) {
await resumeToBottomInstant();
clearRestoreInProgress(currentSessionId);
return;
}
// Check if this session has a saved non-bottom scroll position.
const savedMemState = sessionMemoryStateMap.get(currentSessionId);
const savedPos = savedMemState?.scrollPosition;
if (savedPos && !sessionIsWorking) {
const savedMaxScroll = Math.max(0, savedPos.scrollHeight - savedPos.clientHeight);
// Use the same pixel threshold as the pin logic: 10% of clientHeight, clamped.
const threshold = Math.max(24, Math.min(200, savedPos.clientHeight * 0.10));
const distanceFromSavedBottom = savedMaxScroll - savedPos.scrollTop;
if (savedMaxScroll > 0 && distanceFromSavedBottom > threshold) {
await restoreSavedScrollPosition(savedPos);
clearRestoreInProgress(currentSessionId);
return;
}
}
await resumeToBottomInstant();
clearRestoreInProgress(currentSessionId);
}, [clearRestoreInProgress, currentSessionId, restoreSavedScrollPosition, resumeToBottomInstant, scrollToBottom, sessionIsWorking, sessionMemoryStateMap]);
const resumeToLatestInstant = React.useCallback((options?: { force?: boolean }) => {
void runLatestInstantResume(options);
}, [runLatestInstantResume]);
const resumeToLatestInstant = React.useCallback(() => {
goToBottom('instant');
}, [goToBottom]);
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
@@ -645,6 +599,21 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
resumeToBottom: timelineController.resumeToBottomInstant,
});
React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId) return;
const handleForceScrollBottom = (event: Event) => {
const customEvent = event as CustomEvent<{ sessionId?: string }>;
if (customEvent.detail?.sessionId && customEvent.detail.sessionId !== currentSessionId) return;
goToBottom('instant');
};
window.addEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
return () => {
window.removeEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
};
}, [currentSessionId, goToBottom]);
React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId || isDesktopExpandedInput) {
return;
@@ -688,29 +657,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
};
}, [currentSessionId, isDesktopExpandedInput, navigation, scrollRef]);
React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId) return;
const handleForceScrollBottom = (event: Event) => {
const customEvent = event as CustomEvent<{ sessionId?: string }>;
if (customEvent.detail?.sessionId && customEvent.detail.sessionId !== currentSessionId) return;
resumeToLatestInstant({ force: true });
};
const handleSessionReselected = (event: Event) => {
const customEvent = event as CustomEvent<string>;
if (customEvent.detail !== currentSessionId) return;
if (isPinned || !isOverflowing || isProgrammaticFollowActive) return;
void resumeToBottomInstant();
};
window.addEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
return () => {
window.removeEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
};
}, [currentSessionId, isOverflowing, isPinned, isProgrammaticFollowActive, resumeToBottomInstant, resumeToLatestInstant]);
React.useLayoutEffect(() => {
const container = scrollRef.current;
@@ -755,60 +701,32 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
&& !hasRenderableSessionSnapshot;
React.useEffect(() => {
if (!currentSessionId) {
return;
}
if (lastScrolledSessionRef.current === currentSessionId) {
return;
}
if (!currentSessionId) return;
if (lastScrolledSessionRef.current === currentSessionId) return;
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
if (hasHashTarget) {
lastScrolledSessionRef.current = currentSessionId;
clearRestoreInProgress(currentSessionId);
return;
}
lastScrolledSessionRef.current = currentSessionId;
if (typeof window === 'undefined') {
resumeToLatestInstant();
if (hasHashTarget) {
// Hash navigation handler will scroll to target; we just release auto-follow.
releaseAutoFollow();
return;
}
window.requestAnimationFrame(() => {
resumeToLatestInstant();
});
}, [clearRestoreInProgress, currentSessionId, resumeToLatestInstant]);
const run = () => {
void restoreSnapshot();
};
if (typeof window === 'undefined') {
run();
} else {
window.requestAnimationFrame(run);
}
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]);
React.useEffect(() => {
if (!currentSessionId) return;
if (hasRenderableSessionSnapshot) return;
const load = async () => {
await ensureSessionRenderable(currentSessionId).finally(() => {
const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry';
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
// Active sessions are already followed by the scroll manager when pinned.
// If the user scrolled away, a materialization retry must not force-resume.
const shouldSkipScroll = hasHashTarget || isActivePhase;
if (!shouldSkipScroll) {
if (typeof window === 'undefined') {
resumeToLatestInstant();
} else {
window.requestAnimationFrame(() => {
resumeToLatestInstant();
});
}
}
});
};
void load();
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, resumeToLatestInstant, sessionStatusForCurrent.type]);
void ensureSessionRenderable(currentSessionId);
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
if (!currentSessionId && !draftOpen) {
return (
@@ -955,10 +873,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
handleLoadOlder={handleLoadOlder}
scrollToBottom={scrollToBottom}
scrollToBottom={resumeToLatestInstant}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isProgrammaticFollowActive}
isProgrammaticFollowActive={isFollowingProgrammatically}
/>
<div
@@ -634,7 +634,7 @@ const appendInlineText = (base: string, next: string): string => {
interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
}
type AutocompleteOverlayPosition = {
@@ -1489,12 +1489,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (commandName === 'undo' && currentSessionId) {
await useSessionUIStore.getState().handleSlashUndo(currentSessionId);
scrollToBottom?.({ instant: true, force: true });
scrollToBottom?.();
return;
}
else if (commandName === 'redo' && currentSessionId) {
await useSessionUIStore.getState().handleSlashRedo(currentSessionId);
scrollToBottom?.({ instant: true, force: true });
scrollToBottom?.();
return;
}
else if (commandName === 'timeline' && currentSessionId) {
@@ -1540,7 +1540,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
currentVariant,
inputMode,
);
scrollToBottom?.({ instant: true, force: true });
scrollToBottom?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.summaryFailed'));
}
@@ -1562,7 +1562,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
currentVariant,
inputMode,
);
scrollToBottom?.({ instant: true, force: true });
scrollToBottom?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.reviewFailed'));
}
@@ -1603,10 +1603,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
);
if (typeof window === 'undefined') {
scrollToBottom?.({ instant: true, force: true });
scrollToBottom?.();
} else {
window.requestAnimationFrame(() => {
scrollToBottom?.({ instant: true, force: true });
scrollToBottom?.();
});
}
@@ -16,7 +16,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { cn } from '@/lib/utils';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageHeader from './message/MessageHeader';
import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types';
@@ -127,7 +127,7 @@ interface ChatMessageProps {
};
onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
isInActiveTurn?: boolean;
+10 -10
View File
@@ -5,7 +5,7 @@ import { measureElement as measureVirtualElement, type VirtualItem, useVirtualiz
import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords';
@@ -405,7 +405,7 @@ interface MessageListProps {
hasMoreAbove: boolean;
isLoadingOlder: boolean;
onLoadOlder: () => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
scrollRef?: React.RefObject<HTMLDivElement | null>;
}
@@ -442,7 +442,7 @@ interface MessageRowProps {
onUserAnimationConsumed?: (messageId: string) => void;
onContentChange: (reason?: ContentChangeReason) => void;
animationHandlers: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
}
const MessageRow = React.memo<MessageRowProps>(({
@@ -511,7 +511,7 @@ interface TurnBlockProps {
chatRenderMode: 'sorted' | 'live';
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
stickyUserHeader?: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
@@ -806,7 +806,7 @@ interface UngroupedMessageRowProps {
nextMessage?: ChatMessageEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
@@ -847,7 +847,7 @@ interface MessageListEntryProps {
entry: RenderEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
stickyUserHeader?: boolean;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
@@ -938,7 +938,7 @@ const StaticHistoryList: React.FC<{
contentRef: React.RefObject<HTMLDivElement | null>;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
stickyUserHeader: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
@@ -1023,7 +1023,7 @@ const StreamingTailContent: React.FC<{
entry: RenderEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom?: () => void;
stickyUserHeader: boolean;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
@@ -1103,8 +1103,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
const stableOnLoadOlder = useStableEvent(onLoadOlder);
const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => {
scrollToBottom?.(options);
const stableScrollToBottom = useStableEvent(() => {
scrollToBottom?.();
});
React.useEffect(() => {
@@ -4,7 +4,7 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
import type { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
interface DiffStats {
additions: number;
@@ -13,7 +13,6 @@ import {
} from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
import { useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
type ViewportAnchor = { messageId: string; offsetTop: number };
@@ -33,10 +32,10 @@ interface UseChatTimelineControllerOptions {
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom: (options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => void;
goToBottom: (mode?: 'instant' | 'smooth') => void;
releaseAutoFollow: () => void;
isPinned: boolean;
isOverflowing: boolean;
showScrollButton: boolean;
}
export interface UseChatTimelineControllerResult {
@@ -52,8 +51,7 @@ export interface UseChatTimelineControllerResult {
loadEarlier: () => Promise<void>;
revealBufferedTurns: () => Promise<boolean>;
resumeToBottom: () => void;
resumeToBottomInstant: () => void;
restoreSavedScrollPosition: (savedPos: NonNullable<SessionMemoryState['scrollPosition']>) => Promise<void>;
resumeToBottomInstant: () => Promise<void>;
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
captureViewportAnchor: () => ViewportAnchor | null;
@@ -68,10 +66,10 @@ export const useChatTimelineController = ({
scrollRef,
messageListRef,
loadMoreMessages,
prepareForBottomResume,
scrollToBottom,
goToBottom,
releaseAutoFollow,
isPinned,
isOverflowing,
showScrollButton,
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
@@ -376,6 +374,7 @@ export const useChatTimelineController = ({
return false;
}
releaseAutoFollow();
setPendingRevealWork(true);
try {
@@ -412,7 +411,7 @@ export const useChatTimelineController = ({
} finally {
setPendingRevealWork(false);
}
}, [attemptPendingScrollRequest, sessionId]);
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
const scrollToMessage = React.useCallback(async (
messageId: string,
@@ -422,6 +421,7 @@ export const useChatTimelineController = ({
return false;
}
releaseAutoFollow();
setPendingRevealWork(true);
try {
@@ -460,13 +460,12 @@ export const useChatTimelineController = ({
} finally {
setPendingRevealWork(false);
}
}, [attemptPendingScrollRequest, sessionId]);
}, [attemptPendingScrollRequest, releaseAutoFollow, sessionId]);
const resumeToBottom = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setPendingRevealWork(false);
setIsLoadingOlder(false);
prepareForBottomResume({ force: true });
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
@@ -474,14 +473,13 @@ export const useChatTimelineController = ({
await waitForNextRenderCommit();
}
scrollToBottom({ force: true });
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
goToBottom('smooth');
}, [goToBottom, waitForNextRenderCommit]);
const resumeToBottomInstant = React.useCallback(async () => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setPendingRevealWork(false);
setIsLoadingOlder(false);
prepareForBottomResume({ instant: true, force: true });
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
@@ -489,47 +487,8 @@ export const useChatTimelineController = ({
await waitForNextRenderCommit();
}
scrollToBottom({ instant: true, force: true, followBottom: true });
}, [prepareForBottomResume, scrollToBottom, waitForNextRenderCommit]);
// Restore scroll position from a saved pixel snapshot using ratio mapping.
// Separate from resumeToBottomInstant to preserve "always go to bottom" semantics.
const restoreSavedScrollPosition = React.useCallback(async (savedPos: NonNullable<SessionMemoryState['scrollPosition']>) => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setPendingRevealWork(false);
setIsLoadingOlder(false);
const shouldWaitForRender = nextStart !== turnStartRef.current;
if (shouldWaitForRender) {
setTurnStart(nextStart);
await waitForNextRenderCommit();
}
const container = scrollRef.current;
if (!container) return;
const savedMaxScroll = Math.max(0, savedPos.scrollHeight - savedPos.clientHeight);
if (savedMaxScroll <= 0) return;
const ratio = savedPos.scrollTop / savedMaxScroll;
const currentMaxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
const restoredTop = Math.round(ratio * currentMaxScroll);
container.scrollTop = restoredTop;
// Re-persist the restored position so intermediate scroll events
// during the transition don't leave stale data for the next switch.
const sid = sessionIdRef.current;
if (sid) {
const memState = useViewportStore.getState().sessionMemoryState.get(sid);
if (memState) {
useViewportStore.getState().updateViewportAnchor(sid, memState.viewportAnchor, {
scrollTop: restoredTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
}
}
}, [scrollRef, waitForNextRenderCommit]);
goToBottom('instant');
}, [goToBottom, waitForNextRenderCommit]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId);
@@ -543,13 +502,12 @@ export const useChatTimelineController = ({
isLoadingOlder,
pendingRevealWork,
activeTurnId,
showScrollToBottom: isOverflowing && !isPinned && !pendingRevealWork,
showScrollToBottom: showScrollButton && !pendingRevealWork,
turnWindowModel,
loadEarlier,
revealBufferedTurns,
resumeToBottom,
resumeToBottomInstant,
restoreSavedScrollPosition,
scrollToTurn,
scrollToMessage,
captureViewportAnchor,
@@ -18,7 +18,7 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine, RiGlobalLine, RiInformationLine } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -2,7 +2,7 @@ import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
@@ -1,6 +1,6 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useUIStore } from '@/stores/useUIStore';
import { ReasoningTimelineBlock } from './ReasoningPart';
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import ToolPart from './ToolPart';
import { MinDurationShineText } from './MinDurationShineText';
@@ -3,7 +3,7 @@ import type { ComponentType } from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useUIStore } from '@/stores/useUIStore';
import { useDurationTickerNow } from './useDurationTicker';
@@ -21,7 +21,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Text } from '@/components/ui/text';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
@@ -85,11 +85,6 @@ export const useSessionActions = (args: Args) => {
if (sessionId === args.currentSessionId) {
if (args.allowReselect) {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<string>('openchamber:session-reselected', {
detail: sessionId,
}));
}
args.onSessionSelected?.(sessionId);
}
resetSessionSearch();
@@ -322,6 +322,7 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
{showVertical && (
<div
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--vertical"
data-overlay-scrollbar-thumb="vertical"
style={{
height: `${vertical.length}px`,
top: `${trackInset + vertical.offset}px`,
@@ -338,6 +339,7 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
{showHorizontal && (
<div
className="overlay-scrollbar__thumb overlay-scrollbar__thumb--horizontal"
data-overlay-scrollbar-thumb="horizontal"
style={{
width: `${horizontal.length}px`,
left: `${trackInset + horizontal.offset}px`,
+706
View File
@@ -0,0 +1,706 @@
import React from 'react';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore, type SessionMemoryState } from '@/sync/viewport-store';
export type AutoFollowState = 'following' | 'released';
export type ContentChangeReason = 'text' | 'structural' | 'permission';
export interface AnimationHandlers {
onChunk: () => void;
onComplete: () => void;
onStreamingCandidate?: () => void;
onAnimationStart?: () => void;
onReservationCancelled?: () => void;
onReasoningBlock?: () => void;
onAnimatedHeightChange?: (height: number) => void;
}
interface UseChatAutoFollowOptions {
currentSessionId: string | null;
sessionMessageCount: number;
sessionIsWorking: boolean;
isMobile: boolean;
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface UseChatAutoFollowResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
state: AutoFollowState;
isPinned: boolean;
isOverflowing: boolean;
isFollowingProgrammatically: boolean;
showScrollButton: boolean;
notifyContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
goToBottom: (mode?: 'instant' | 'smooth') => void;
releaseAutoFollow: () => void;
saveSnapshotNow: () => void;
restoreSnapshot: () => Promise<boolean>;
}
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
const BOTTOM_SPACER_MOBILE_PX = 40;
const PROGRAMMATIC_WRITE_WINDOW_MS = 200;
const SAVE_DEBOUNCE_MS = 150;
const LERP = 0.18;
const SETTLE_EPSILON = 0.5;
const SETTLE_FRAMES = 4;
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
const SETTLE_BURST_DURATION_MS = 280;
const REPIN_GRACE_AFTER_RELEASE_MS = 1200;
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
// — its height is exactly how far above scrollHeight the user can be while still
// looking at "empty" space. We use that same value as the threshold for both
// re-pinning auto-follow and showing the scroll-to-bottom button.
const computeBottomZoneThreshold = (isMobile: boolean): number => {
if (isMobile) return BOTTOM_SPACER_MOBILE_PX;
if (typeof window === 'undefined') return 96;
return Math.max(48, window.innerHeight * BOTTOM_SPACER_DESKTOP_VH);
};
const distanceFromBottom = (el: HTMLElement): number => {
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile);
};
const isReleaseKey = (event: KeyboardEvent): boolean => {
if (event.altKey || event.ctrlKey || event.metaKey) {
return false;
}
switch (event.key) {
case 'ArrowUp':
case 'PageUp':
case 'Home':
return true;
default:
return false;
}
};
const targetIsNestedScrollable = (root: HTMLElement, target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
const nested = target.closest('[data-scrollable]');
return Boolean(nested) && nested !== root;
};
const isAtBottomSnapshot = (snapshot: NonNullable<SessionMemoryState['scrollPosition']>, isMobile: boolean): boolean => {
const max = Math.max(0, snapshot.scrollHeight - snapshot.clientHeight);
if (max <= 0) return true;
const threshold = computeBottomZoneThreshold(isMobile);
return max - snapshot.scrollTop <= threshold;
};
export const useChatAutoFollow = ({
currentSessionId,
sessionMessageCount,
sessionIsWorking,
isMobile,
onActiveTurnChange,
}: UseChatAutoFollowOptions): UseChatAutoFollowResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [containerEl, setContainerEl] = React.useState<HTMLDivElement | null>(null);
const lastSeenContainerRef = React.useRef<HTMLDivElement | null>(null);
const [state, setState] = React.useState<AutoFollowState>('following');
const [isOverflowing, setIsOverflowing] = React.useState(false);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
const stateRef = React.useRef<AutoFollowState>('following');
const sessionWorkingRef = React.useRef(sessionIsWorking);
sessionWorkingRef.current = sessionIsWorking;
const sessionMessageCountRef = React.useRef(sessionMessageCount);
sessionMessageCountRef.current = sessionMessageCount;
const currentSessionIdRef = React.useRef(currentSessionId);
currentSessionIdRef.current = currentSessionId;
const lastSessionIdRef = React.useRef<string | null>(null);
const programmaticWriteUntilRef = React.useRef(0);
const followRafRef = React.useRef<number | null>(null);
const settledFramesRef = React.useRef(0);
const lastScrollTopRef = React.useRef(0);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const settleBurstRafRef = React.useRef<number | null>(null);
const lastUserReleaseAtRef = React.useRef(0);
// When restoreSnapshot is invoked while ChatViewport is still hydrating
// (skeleton rendered, no scroll container yet), we record the session here
// so a follow-up effect can replay the restore once the container mounts.
const pendingInitialRestoreRef = React.useRef<string | null>(null);
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
// Detect when the scroll container DOM element changes (mount, unmount, remount).
// Without this, listener-attach effects would only ever bind to the element that
// existed at the hook's first render, missing later mounts (e.g. after first send
// promotes a draft session to a real chat with messages).
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (scrollRef.current !== lastSeenContainerRef.current) {
lastSeenContainerRef.current = scrollRef.current;
setContainerEl(scrollRef.current);
}
});
const setStateValue = React.useCallback((next: AutoFollowState) => {
if (stateRef.current === next) return;
stateRef.current = next;
setState(next);
}, []);
const markProgrammaticWrite = React.useCallback(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
programmaticWriteUntilRef.current = now + PROGRAMMATIC_WRITE_WINDOW_MS;
}, []);
const isInProgrammaticWindow = React.useCallback(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
return now < programmaticWriteUntilRef.current;
}, []);
const stopFollowLoop = React.useCallback(() => {
if (followRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(followRafRef.current);
}
followRafRef.current = null;
settledFramesRef.current = 0;
setIsFollowingProgrammatically(false);
}, []);
const tickFollow = React.useCallback(() => {
followRafRef.current = null;
const container = scrollRef.current;
if (!container) {
stopFollowLoop();
return;
}
if (stateRef.current !== 'following' || !sessionWorkingRef.current) {
stopFollowLoop();
return;
}
const target = Math.max(0, container.scrollHeight - container.clientHeight);
const current = container.scrollTop;
const delta = target - current;
if (Math.abs(delta) <= SETTLE_EPSILON) {
if (current !== target) {
markProgrammaticWrite();
container.scrollTop = target;
lastScrollTopRef.current = target;
}
settledFramesRef.current += 1;
if (settledFramesRef.current >= SETTLE_FRAMES) {
stopFollowLoop();
return;
}
followRafRef.current = window.requestAnimationFrame(tickFollow);
return;
}
settledFramesRef.current = 0;
const next = current + delta * LERP;
markProgrammaticWrite();
container.scrollTop = next;
lastScrollTopRef.current = container.scrollTop;
followRafRef.current = window.requestAnimationFrame(tickFollow);
}, [markProgrammaticWrite, stopFollowLoop]);
const startFollowLoop = React.useCallback(() => {
if (typeof window === 'undefined') return;
if (followRafRef.current !== null) return;
if (stateRef.current !== 'following' || !sessionWorkingRef.current) return;
settledFramesRef.current = 0;
setIsFollowingProgrammatically(true);
followRafRef.current = window.requestAnimationFrame(tickFollow);
}, [tickFollow]);
const writeScrollTopInstant = React.useCallback((target: number) => {
const container = scrollRef.current;
if (!container) return;
const max = Math.max(0, container.scrollHeight - container.clientHeight);
const clamped = Math.max(0, Math.min(target, max));
markProgrammaticWrite();
container.scrollTop = clamped;
lastScrollTopRef.current = container.scrollTop;
}, [markProgrammaticWrite]);
const stopSettleBurst = React.useCallback(() => {
if (settleBurstRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(settleBurstRafRef.current);
}
settleBurstRafRef.current = null;
}, []);
const startSettleBurst = React.useCallback(() => {
if (typeof window === 'undefined') return;
stopSettleBurst();
const until = (typeof performance !== 'undefined' ? performance.now() : Date.now()) + SETTLE_BURST_DURATION_MS;
const tick = () => {
settleBurstRafRef.current = null;
if (stateRef.current !== 'following') return;
const c = scrollRef.current;
if (!c) return;
const target = Math.max(0, c.scrollHeight - c.clientHeight);
if (Math.abs(c.scrollTop - target) > SETTLE_EPSILON) {
markProgrammaticWrite();
c.scrollTop = target;
lastScrollTopRef.current = target;
}
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (now < until) {
settleBurstRafRef.current = window.requestAnimationFrame(tick);
}
};
settleBurstRafRef.current = window.requestAnimationFrame(tick);
}, [markProgrammaticWrite, stopSettleBurst]);
const releaseAutoFollow = React.useCallback(() => {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
setStateValue('released');
}, [setStateValue, stopFollowLoop, stopSettleBurst]);
const releaseFromUserIntent = React.useCallback(() => {
if (stateRef.current === 'following') {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
setStateValue('released');
} else {
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
}
}, [setStateValue, stopFollowLoop, stopSettleBurst]);
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
const container = scrollRef.current;
setStateValue('following');
lastUserReleaseAtRef.current = 0;
if (!container) return;
if (mode === 'smooth' && sessionWorkingRef.current) {
startFollowLoop();
return;
}
const target = Math.max(0, container.scrollHeight - container.clientHeight);
writeScrollTopInstant(target);
if (sessionWorkingRef.current) {
startFollowLoop();
} else {
startSettleBurst();
}
}, [setStateValue, startFollowLoop, startSettleBurst, writeScrollTopInstant]);
const flushSave = React.useCallback(() => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const pending = pendingSaveRef.current;
if (!pending) return;
const container = scrollRef.current;
if (!container) {
pendingSaveRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
pendingSaveRef.current = null;
}, [updateViewportAnchor]);
const queueSave = React.useCallback(() => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) return;
const container = scrollRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const anchorRatio = scrollHeight > 0
? (scrollTop + clientHeight / 2) / scrollHeight
: 0;
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
pendingSaveRef.current = { sessionId, anchor };
if (saveTimerRef.current !== null) return;
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
flushSave();
}, SAVE_DEBOUNCE_MS);
}, [flushSave]);
const saveSnapshotNow = React.useCallback(() => {
flushSave();
}, [flushSave]);
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) return false;
const container = scrollRef.current;
if (!container) {
// ChatViewport not mounted yet (e.g., session still hydrating).
// Record the request so the container-attach effect can replay it.
pendingInitialRestoreRef.current = sessionId;
setStateValue('following');
return false;
}
pendingInitialRestoreRef.current = null;
const saved = useViewportStore.getState().sessionMemoryState.get(sessionId)?.scrollPosition;
if (!saved || isAtBottomSnapshot(saved, isMobile)) {
setStateValue('following');
lastUserReleaseAtRef.current = 0;
const target = Math.max(0, container.scrollHeight - container.clientHeight);
writeScrollTopInstant(target);
if (sessionWorkingRef.current) {
startFollowLoop();
}
startSettleBurst();
return false;
}
const savedMaxScroll = Math.max(0, saved.scrollHeight - saved.clientHeight);
const ratio = savedMaxScroll > 0 ? saved.scrollTop / savedMaxScroll : 0;
const currentMaxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
const targetTop = Math.round(ratio * currentMaxScroll);
setStateValue('released');
writeScrollTopInstant(targetTop);
const memState = useViewportStore.getState().sessionMemoryState.get(sessionId);
updateViewportAnchor(sessionId, memState?.viewportAnchor ?? 0, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
return true;
}, [isMobile, setStateValue, startFollowLoop, startSettleBurst, updateViewportAnchor, writeScrollTopInstant]);
React.useEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return;
}
lastSessionIdRef.current = currentSessionId;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
flushSave();
stopFollowLoop();
stopSettleBurst();
markProgrammaticWrite();
// Drop any pending restore request inherited from a different session.
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionId) {
pendingInitialRestoreRef.current = null;
}
}, [currentSessionId, flushSave, markProgrammaticWrite, stopFollowLoop, stopSettleBurst]);
React.useEffect(() => {
if (!sessionIsWorking) {
stopFollowLoop();
} else if (stateRef.current === 'following') {
startFollowLoop();
}
}, [sessionIsWorking, startFollowLoop, stopFollowLoop]);
// Replay a deferred restoreSnapshot once ChatViewport mounts.
React.useEffect(() => {
if (!containerEl) return;
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionId) {
void restoreSnapshot();
}
}, [containerEl, currentSessionId, restoreSnapshot]);
const updateOverflowAndButton = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
setIsOverflowing(false);
setShowScrollButton(false);
return;
}
const overflowing = container.scrollHeight > container.clientHeight + 1;
setIsOverflowing(overflowing);
if (!overflowing) {
setShowScrollButton(false);
return;
}
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobile);
setShowScrollButton(showButton);
}, [isMobile]);
const handleScrollEvent = React.useCallback(() => {
const container = scrollRef.current;
if (!container) return;
const programmatic = isInProgrammaticWindow();
const currentTop = container.scrollTop;
const previousTop = lastScrollTopRef.current;
lastScrollTopRef.current = currentTop;
updateOverflowAndButton();
if (programmatic) {
return;
}
if (currentTop < previousTop && stateRef.current === 'following') {
stopFollowLoop();
stopSettleBurst();
lastUserReleaseAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
setStateValue('released');
}
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const inGrace = (now - lastUserReleaseAtRef.current) < REPIN_GRACE_AFTER_RELEASE_MS;
if (stateRef.current === 'released' && isNearBottom(container, isMobile) && !inGrace) {
setStateValue('following');
if (sessionWorkingRef.current) {
startFollowLoop();
}
}
queueSave();
}, [
isInProgrammaticWindow,
isMobile,
queueSave,
setStateValue,
startFollowLoop,
stopFollowLoop,
stopSettleBurst,
updateOverflowAndButton,
]);
React.useEffect(() => {
const container = containerEl;
if (!container) return;
const handleWheel = (event: WheelEvent) => {
if (event.deltaY >= 0) return;
if (targetIsNestedScrollable(container, event.target)) return;
releaseFromUserIntent();
};
let touchLastY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches.item(0);
touchLastY = touch ? touch.clientY : null;
};
const handleTouchMove = (event: TouchEvent) => {
const touch = event.touches.item(0);
if (!touch) {
touchLastY = null;
return;
}
const previousY = touchLastY;
touchLastY = touch.clientY;
if (previousY === null) return;
const fingerDelta = touch.clientY - previousY;
if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return;
if (targetIsNestedScrollable(container, event.target)) return;
releaseFromUserIntent();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isReleaseKey(event)) return;
releaseFromUserIntent();
};
const handlePointerDownIntent = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest('[data-overlay-scrollbar-thumb]')) return;
releaseFromUserIntent();
};
container.addEventListener('scroll', handleScrollEvent, { passive: true });
container.addEventListener('wheel', handleWheel, { passive: true });
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchmove', handleTouchMove, { passive: true });
container.addEventListener('touchend', handleTouchEnd, { passive: true });
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
container.addEventListener('keydown', handleKeyDown);
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', handlePointerDownIntent, true);
}
return () => {
container.removeEventListener('scroll', handleScrollEvent);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
container.removeEventListener('keydown', handleKeyDown);
if (typeof window !== 'undefined') {
window.removeEventListener('pointerdown', handlePointerDownIntent, true);
}
};
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
React.useEffect(() => {
const container = containerEl;
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
updateOverflowAndButton();
if (stateRef.current === 'following' && sessionWorkingRef.current) {
startFollowLoop();
}
});
observer.observe(container);
const inner = container.firstElementChild;
if (inner instanceof Element) {
observer.observe(inner);
}
return () => observer.disconnect();
}, [containerEl, startFollowLoop, updateOverflowAndButton]);
React.useEffect(() => {
updateOverflowAndButton();
}, [sessionMessageCount, updateOverflowAndButton]);
const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => {
void _reason;
updateOverflowAndButton();
if (stateRef.current === 'following' && sessionWorkingRef.current) {
startFollowLoop();
}
}, [startFollowLoop, updateOverflowAndButton]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const cached = animationHandlersRef.current.get(messageId);
if (cached) return cached;
const kick = () => {
if (stateRef.current === 'following' && sessionWorkingRef.current) {
startFollowLoop();
}
};
const handlers: AnimationHandlers = {
onChunk: kick,
onComplete: () => {
updateOverflowAndButton();
},
onStreamingCandidate: () => {},
onAnimationStart: () => {},
onAnimatedHeightChange: kick,
onReservationCancelled: () => {},
onReasoningBlock: () => {},
};
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [startFollowLoop, updateOverflowAndButton]);
React.useEffect(() => {
return () => {
stopFollowLoop();
stopSettleBurst();
flushSave();
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [flushSave, stopFollowLoop, stopSettleBurst]);
React.useEffect(() => {
if (!onActiveTurnChange) return;
const container = containerEl;
if (!container) return;
let lastActiveTurnId: string | null = null;
const spy = createScrollSpy({
onActive: (turnId) => {
if (turnId === lastActiveTurnId) return;
lastActiveTurnId = turnId;
onActiveTurnChange(turnId);
},
});
spy.setContainer(container);
const elementByTurnId = new Map<string, HTMLElement>();
const registerTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
elementByTurnId.set(turnId, node);
spy.register(node, turnId);
return true;
};
const unregisterTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
if (elementByTurnId.get(turnId) !== node) return false;
elementByTurnId.delete(turnId);
spy.unregister(turnId);
return true;
};
const collectTurnNodes = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) return [];
const collected: HTMLElement[] = [];
if (node.matches('[data-turn-id]')) collected.push(node);
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
return collected;
};
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
spy.markDirty();
const mutationObserver = new MutationObserver((records) => {
let changed = false;
records.forEach((record) => {
record.removedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (unregisterTurnNode(turnNode)) changed = true;
});
});
record.addedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (registerTurnNode(turnNode)) changed = true;
});
});
});
if (changed) spy.markDirty();
});
mutationObserver.observe(container, { subtree: true, childList: true });
const onScroll = () => spy.onScroll();
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
mutationObserver.disconnect();
spy.destroy();
};
}, [containerEl, onActiveTurnChange]);
return {
scrollRef,
state,
isPinned: state === 'following',
isOverflowing,
isFollowingProgrammatically,
showScrollButton,
notifyContentChange,
getAnimationHandlers,
goToBottom,
releaseAutoFollow,
saveSnapshotNow,
restoreSnapshot,
};
};
@@ -1,864 +0,0 @@
import React from 'react';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import {
isNearBottom,
normalizeWheelDelta,
shouldPauseAutoScrollOnWheel,
} from '@/components/chat/lib/scroll/scrollIntent';
import { useScrollEngine } from './useScrollEngine';
import { useViewportStore } from '@/sync/viewport-store';
export type ContentChangeReason = 'text' | 'structural' | 'permission';
interface SessionMemoryState {
viewportAnchor: number;
scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number };
isStreaming: boolean;
lastAccessedAt: number;
backgroundMessageCount: number;
totalAvailableMessages?: number;
hasMoreAbove?: boolean;
streamStartTime?: number;
isZombie?: boolean;
}
interface UseChatScrollManagerOptions {
currentSessionId: string | null;
sessionMessageCount: number;
sessionPermissions: unknown[];
sessionIsWorking: boolean;
sessionMemoryState: Map<string, SessionMemoryState>;
updateViewportAnchor: (sessionId: string, anchor: number, scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
isSyncing: boolean;
isMobile: boolean;
chatRenderMode?: 'sorted' | 'live';
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface AnimationHandlers {
onChunk: () => void;
onComplete: () => void;
onStreamingCandidate?: () => void;
onAnimationStart?: () => void;
onReservationCancelled?: () => void;
onReasoningBlock?: () => void;
onAnimatedHeightChange?: (height: number) => void;
}
type FollowMode = 'none' | 'smooth';
type AutoScrollMarker = {
top: number;
at: number;
};
interface UseChatScrollManagerResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
showScrollButton: boolean;
prepareForBottomResume: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
releasePinnedScroll: () => void;
isPinned: boolean;
isOverflowing: boolean;
isProgrammaticFollowActive: boolean;
clearRestoreInProgress: (sessionId: string) => void;
}
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
const PIN_THRESHOLD_RATIO = 0.10;
const VIEWPORT_ANCHOR_MIN_UPDATE_MS = 150;
export const useChatScrollManager = ({
currentSessionId,
sessionMessageCount,
sessionIsWorking,
updateViewportAnchor,
isSyncing,
isMobile,
onActiveTurnChange,
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
const cancelScrollFollow = scrollEngine.cancelFollow;
const getPinThreshold = React.useCallback(() => {
const container = scrollRef.current;
if (!container || container.clientHeight <= 0) {
return 0;
}
const raw = container.clientHeight * PIN_THRESHOLD_RATIO;
return Math.max(24, Math.min(200, raw));
}, []);
const getAutoFollowThreshold = React.useCallback(() => {
return getPinThreshold();
}, [getPinThreshold]);
const getAutoFollowSnapThreshold = React.useCallback(() => {
const container = scrollRef.current;
if (!container || container.clientHeight <= 0) {
return 96;
}
const raw = container.clientHeight * 0.2;
return Math.max(72, Math.min(192, raw));
}, []);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isPinned, setIsPinned] = React.useState(true);
const [isOverflowing, setIsOverflowing] = React.useState(false);
const showScrollButtonRef = React.useRef(false);
const isOverflowingRef = React.useRef(false);
const lastSessionIdRef = React.useRef<string | null>(null);
const isPinnedRef = React.useRef(true);
const lastScrollTopRef = React.useRef<number>(0);
const touchLastYRef = React.useRef<number | null>(null);
const pinnedSyncRafRef = React.useRef<number | null>(null);
const followModeRef = React.useRef<FollowMode>('none');
const autoScrollMarkerRef = React.useRef<AutoScrollMarker | null>(null);
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number; scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number } } | null>(null);
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number; scrollTop: number } | null>(null);
const lastViewportAnchorWriteAtRef = React.useRef<number>(0);
// Guard: suppress scroll-position saving during session transition window.
// Without this, scroll events from content reshaping after session switch
// overwrite the saved position before restoreSavedScrollPosition reads it.
// Stores the session ID being restored; cleared after restoration completes.
const restoreInProgressForRef = React.useRef<string | null>(null);
const markAutoScroll = React.useCallback((top: number) => {
autoScrollMarkerRef.current = {
top,
at: Date.now(),
};
}, []);
const isMarkedAutoScroll = React.useCallback((scrollTop: number) => {
const marker = autoScrollMarkerRef.current;
if (!marker) {
return false;
}
if (Date.now() - marker.at > PROGRAMMATIC_SCROLL_SUPPRESS_MS) {
autoScrollMarkerRef.current = null;
return false;
}
if (Math.abs(scrollTop - marker.top) > 2) {
return false;
}
return true;
}, []);
const getDistanceFromBottom = React.useCallback(() => {
const container = scrollRef.current;
if (!container) return 0;
return container.scrollHeight - container.scrollTop - container.clientHeight;
}, []);
const updatePinnedState = React.useCallback((newPinned: boolean) => {
if (isPinnedRef.current !== newPinned) {
isPinnedRef.current = newPinned;
setIsPinned(newPinned);
}
}, []);
const setShowScrollButtonState = React.useCallback((next: boolean) => {
showScrollButtonRef.current = next;
setShowScrollButton((previous) => (previous === next ? previous : next));
}, []);
const setIsOverflowingState = React.useCallback((next: boolean) => {
isOverflowingRef.current = next;
setIsOverflowing((previous) => (previous === next ? previous : next));
}, []);
const setFollowMode = React.useCallback((next: FollowMode) => {
followModeRef.current = next;
}, []);
const shouldSkipLiveContentSync = React.useCallback(() => {
return !isPinnedRef.current && showScrollButtonRef.current && isOverflowingRef.current;
}, []);
const scrollToBottomInternal = React.useCallback((options?: { instant?: boolean; followBottom?: boolean }) => {
const container = scrollRef.current;
if (!container) return;
const bottom = container.scrollHeight - container.clientHeight;
markAutoScroll(Math.max(0, bottom));
scrollEngine.scrollToPosition(Math.max(0, bottom), {
...options,
persistFollow: Boolean(options?.followBottom),
});
}, [markAutoScroll, scrollEngine]);
const scrollPinnedToBottom = React.useCallback((distanceFromBottom: number) => {
if (sessionIsWorking) {
if (followModeRef.current === 'smooth' || scrollEngine.isFollowingBottom) {
scrollToBottomInternal({ followBottom: true });
return;
}
if (distanceFromBottom > getAutoFollowSnapThreshold()) {
scrollToBottomInternal({ instant: true });
return;
}
setFollowMode('smooth');
scrollToBottomInternal({ followBottom: true });
return;
}
if (followModeRef.current === 'smooth' || scrollEngine.isFollowingBottom) {
scrollToBottomInternal({ followBottom: true });
return;
}
setFollowMode('none');
scrollToBottomInternal({ instant: true });
}, [getAutoFollowSnapThreshold, scrollEngine.isFollowingBottom, scrollToBottomInternal, sessionIsWorking, setFollowMode]);
const updateScrollButtonVisibility = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
setShowScrollButtonState(false);
setIsOverflowingState(false);
return;
}
const hasScrollableContent = container.scrollHeight > container.clientHeight;
setIsOverflowingState(hasScrollableContent);
if (!hasScrollableContent) {
setShowScrollButtonState(false);
return;
}
// Show scroll button when scrolled above the 10vh threshold
const distanceFromBottom = getDistanceFromBottom();
setShowScrollButtonState(!isNearBottom(distanceFromBottom, getPinThreshold()));
}, [getDistanceFromBottom, getPinThreshold, setIsOverflowingState, setShowScrollButtonState]);
const syncPinnedStateAndIndicators = React.useCallback(() => {
pinnedSyncRafRef.current = null;
updateScrollButtonVisibility();
if (!isPinnedRef.current) {
setFollowMode('none');
return;
}
const distanceFromBottom = getDistanceFromBottom();
if (sessionIsWorking) {
if (distanceFromBottom <= getAutoFollowThreshold()) {
return;
}
scrollPinnedToBottom(distanceFromBottom);
return;
}
if (distanceFromBottom <= getAutoFollowThreshold()) {
if (followModeRef.current !== 'smooth') {
setFollowMode('none');
}
return;
}
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom(distanceFromBottom);
}
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, sessionIsWorking, setFollowMode, updateScrollButtonVisibility]);
const schedulePinnedStateAndIndicators = React.useCallback(() => {
if (typeof window === 'undefined') {
syncPinnedStateAndIndicators();
return;
}
if (pinnedSyncRafRef.current !== null) {
return;
}
pinnedSyncRafRef.current = window.requestAnimationFrame(() => {
syncPinnedStateAndIndicators();
});
}, [syncPinnedStateAndIndicators]);
const flushViewportAnchor = React.useCallback(() => {
if (viewportAnchorTimerRef.current !== null) {
clearTimeout(viewportAnchorTimerRef.current);
viewportAnchorTimerRef.current = null;
}
const pending = pendingViewportAnchorRef.current;
if (!pending) {
return;
}
// Skip only when both anchor AND pixel position are unchanged.
const lastPersisted = lastViewportAnchorRef.current;
if (lastPersisted
&& lastPersisted.sessionId === pending.sessionId
&& lastPersisted.anchor === pending.anchor
&& lastPersisted.scrollTop === (pending.scrollPosition?.scrollTop ?? 0)) {
pendingViewportAnchorRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor, pending.scrollPosition);
lastViewportAnchorRef.current = { sessionId: pending.sessionId, anchor: pending.anchor, scrollTop: pending.scrollPosition?.scrollTop ?? 0 };
pendingViewportAnchorRef.current = null;
lastViewportAnchorWriteAtRef.current = Date.now();
}, [updateViewportAnchor]);
const queueViewportAnchor = React.useCallback((sessionId: string, anchor: number, scrollPosition?: { scrollTop: number; scrollHeight: number; clientHeight: number }) => {
// Always update pending with latest pixel position, even if anchor
// hasn't changed — the user may have scrolled within the same
// coarse message-index bucket.
pendingViewportAnchorRef.current = { sessionId, anchor, scrollPosition };
const now = Date.now();
const elapsed = now - lastViewportAnchorWriteAtRef.current;
if (elapsed >= VIEWPORT_ANCHOR_MIN_UPDATE_MS) {
flushViewportAnchor();
return;
}
if (viewportAnchorTimerRef.current !== null) {
return;
}
viewportAnchorTimerRef.current = setTimeout(() => {
viewportAnchorTimerRef.current = null;
flushViewportAnchor();
}, VIEWPORT_ANCHOR_MIN_UPDATE_MS - elapsed);
}, [flushViewportAnchor]);
const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
const container = scrollRef.current;
if (!container) return;
markAutoScroll(Math.max(0, position));
scrollEngine.scrollToPosition(Math.max(0, position), options);
}, [markAutoScroll, scrollEngine]);
const prepareForBottomResume = React.useCallback(() => {
updatePinnedState(true);
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
setShowScrollButtonState(false);
}, [sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean; followBottom?: boolean }) => {
const container = scrollRef.current;
if (!container) return;
prepareForBottomResume();
scrollToBottomInternal(options);
}, [prepareForBottomResume, scrollToBottomInternal]);
const releasePinnedScroll = React.useCallback(() => {
scrollEngine.cancelFollow();
setFollowMode('none');
updatePinnedState(false);
schedulePinnedStateAndIndicators();
}, [schedulePinnedStateAndIndicators, scrollEngine, setFollowMode, updatePinnedState]);
const handleScrollEvent = React.useCallback((event?: Event) => {
const container = scrollRef.current;
if (!container || !currentSessionId) {
return;
}
const currentScrollTop = container.scrollTop;
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
const isTrustedScrollUp = Boolean(event?.isTrusted && scrollingUp);
const isProgrammatic = !isTrustedScrollUp && isMarkedAutoScroll(currentScrollTop);
if (isProgrammatic) {
autoScrollMarkerRef.current = null;
}
scrollEngine.handleScroll();
// During session restore, skip all pin/unpin and position-save logic.
// The session-switch effect and restoreSavedScrollPosition handle
// restoration; intermediate scroll events from content reshaping must
// not override the saved position or pinned state.
if (restoreInProgressForRef.current === currentSessionId) {
return;
}
schedulePinnedStateAndIndicators();
// Handle pin/unpin logic
if (event?.isTrusted && !isProgrammatic) {
if (scrollingUp && isPinnedRef.current) {
setFollowMode('none');
updatePinnedState(false);
}
}
// Re-pin at bottom should always work (even momentum scroll)
if (!isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom <= getPinThreshold()) {
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
updatePinnedState(true);
}
}
lastScrollTopRef.current = currentScrollTop;
const { scrollTop, scrollHeight, clientHeight } = container;
const position = (scrollTop + clientHeight / 2) / Math.max(scrollHeight, 1);
const estimatedIndex = Math.floor(position * sessionMessageCount);
queueViewportAnchor(currentSessionId, estimatedIndex, { scrollTop, scrollHeight, clientHeight });
}, [
currentSessionId,
getDistanceFromBottom,
getPinThreshold,
isMarkedAutoScroll,
queueViewportAnchor,
schedulePinnedStateAndIndicators,
scrollEngine,
setFollowMode,
sessionMessageCount,
sessionIsWorking,
updatePinnedState,
]);
const handleWheelIntent = React.useCallback((event: WheelEvent) => {
const container = scrollRef.current;
if (!container) {
return;
}
const delta = normalizeWheelDelta({
deltaY: event.deltaY,
deltaMode: event.deltaMode,
rootHeight: container.clientHeight,
});
if (isPinnedRef.current && shouldPauseAutoScrollOnWheel({
root: container,
target: event.target,
delta,
})) {
scrollEngine.cancelFollow();
setFollowMode('none');
updatePinnedState(false);
}
}, [scrollEngine, setFollowMode, updatePinnedState]);
React.useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const handleTouchStartIntent = (event: TouchEvent) => {
const touch = event.touches.item(0);
touchLastYRef.current = touch ? touch.clientY : null;
};
const handleTouchMoveIntent = (event: TouchEvent) => {
const touch = event.touches.item(0);
if (!touch) {
touchLastYRef.current = null;
return;
}
const previousY = touchLastYRef.current;
touchLastYRef.current = touch.clientY;
if (previousY === null || !isPinnedRef.current) {
return;
}
const fingerDelta = touch.clientY - previousY;
if (Math.abs(fingerDelta) < 2) {
return;
}
const syntheticWheelDelta = -fingerDelta;
if (syntheticWheelDelta >= 0) {
return;
}
if (shouldPauseAutoScrollOnWheel({
root: container,
target: event.target,
delta: syntheticWheelDelta,
})) {
scrollEngine.cancelFollow();
setFollowMode('none');
updatePinnedState(false);
}
};
const handleTouchEndIntent = () => {
touchLastYRef.current = null;
};
container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true });
container.addEventListener('touchstart', handleTouchStartIntent as EventListener, { passive: true });
container.addEventListener('touchmove', handleTouchMoveIntent as EventListener, { passive: true });
container.addEventListener('touchend', handleTouchEndIntent as EventListener, { passive: true });
container.addEventListener('touchcancel', handleTouchEndIntent as EventListener, { passive: true });
container.addEventListener('wheel', handleWheelIntent as EventListener, { passive: true });
return () => {
container.removeEventListener('scroll', handleScrollEvent as EventListener);
container.removeEventListener('touchstart', handleTouchStartIntent as EventListener);
container.removeEventListener('touchmove', handleTouchMoveIntent as EventListener);
container.removeEventListener('touchend', handleTouchEndIntent as EventListener);
container.removeEventListener('touchcancel', handleTouchEndIntent as EventListener);
container.removeEventListener('wheel', handleWheelIntent as EventListener);
};
}, [handleScrollEvent, handleWheelIntent, scrollEngine, setFollowMode, updatePinnedState]);
// Session switch — decide initial pinned state based on saved scroll position.
// If the user had scrolled away from bottom in this session before, start unpinned
// so that the restore logic in ChatContainer can set the position without
// being overridden by pinned-to-bottom logic.
React.useEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return;
}
lastSessionIdRef.current = currentSessionId;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
flushViewportAnchor();
pendingViewportAnchorRef.current = null;
// Kill any in-flight scroll animations/follow-loops from the previous session.
// Without this, a spring animation or follow-burst from the old session
// continues driving scrollTop and triggers repin via handleScrollEvent.
scrollEngine.cancelAll();
// Mark session transition — suppresses scrollPosition saves
// so intermediate scroll events don't overwrite the saved position
// before the restore logic in ChatContainer reads it.
restoreInProgressForRef.current = currentSessionId;
// Check if this session has a saved non-bottom scroll position.
const savedMemState = useViewportStore.getState().sessionMemoryState.get(currentSessionId);
const savedScrollPos = savedMemState?.scrollPosition;
let hasNonBottomPosition = false;
if (savedScrollPos) {
const savedMaxScroll = Math.max(0, savedScrollPos.scrollHeight - savedScrollPos.clientHeight);
// Use the same pixel threshold as the pin logic for consistency.
const threshold = Math.max(24, Math.min(200, savedScrollPos.clientHeight * 0.10));
const distanceFromSavedBottom = savedMaxScroll - savedScrollPos.scrollTop;
if (savedMaxScroll > 0 && distanceFromSavedBottom > threshold) {
hasNonBottomPosition = true;
}
}
if (hasNonBottomPosition && !sessionIsWorking) {
setFollowMode('none');
updatePinnedState(false);
} else {
setFollowMode(sessionIsWorking ? 'smooth' : 'none');
updatePinnedState(true);
setShowScrollButtonState(false);
}
}, [currentSessionId, flushViewportAnchor, scrollEngine, sessionIsWorking, setFollowMode, setShowScrollButtonState, updatePinnedState]);
// Clear the restore-in-progress flag after the restore has committed.
// Maintain pin-to-bottom when content changes
React.useEffect(() => {
if (!sessionIsWorking) {
cancelScrollFollow();
setFollowMode('none');
}
}, [cancelScrollFollow, sessionIsWorking, setFollowMode]);
React.useEffect(() => {
if (isSyncing) {
return;
}
if (shouldSkipLiveContentSync()) {
return;
}
schedulePinnedStateAndIndicators();
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessageCount, shouldSkipLiveContentSync]);
// Use ResizeObserver to detect content changes and maintain pin
React.useEffect(() => {
const container = scrollRef.current;
if (!container || typeof ResizeObserver === 'undefined') return;
let lastScrollHeight = container.scrollHeight;
let lastClientHeight = container.clientHeight;
const observer = new ResizeObserver(() => {
const nextScrollHeight = container.scrollHeight;
const nextClientHeight = container.clientHeight;
const scrollHeightChanged = nextScrollHeight !== lastScrollHeight;
const clientHeightChanged = nextClientHeight !== lastClientHeight;
if (scrollHeightChanged && isPinnedRef.current && sessionIsWorking) {
setFollowMode('smooth');
scrollToBottomInternal({ followBottom: true });
lastScrollHeight = nextScrollHeight;
lastClientHeight = nextClientHeight;
updateScrollButtonVisibility();
return;
}
if (clientHeightChanged) {
const previousDistanceFromBottom = Math.max(
0,
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
);
if (isPinnedRef.current) {
const targetScrollTop = Math.max(
0,
nextScrollHeight - nextClientHeight - previousDistanceFromBottom,
);
if (Math.abs(container.scrollTop - targetScrollTop) > 0.5) {
markAutoScroll(targetScrollTop);
container.scrollTop = targetScrollTop;
lastScrollTopRef.current = targetScrollTop;
}
lastScrollHeight = nextScrollHeight;
lastClientHeight = nextClientHeight;
updateScrollButtonVisibility();
return;
}
}
lastScrollHeight = nextScrollHeight;
lastClientHeight = nextClientHeight;
if (clientHeightChanged && !scrollHeightChanged) {
updateScrollButtonVisibility();
return;
}
if (scrollHeightChanged && shouldSkipLiveContentSync()) {
return;
}
schedulePinnedStateAndIndicators();
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, [markAutoScroll, schedulePinnedStateAndIndicators, scrollToBottomInternal, sessionIsWorking, setFollowMode, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
React.useEffect(() => {
if (typeof window === 'undefined') {
if (shouldSkipLiveContentSync()) {
return;
}
schedulePinnedStateAndIndicators();
return;
}
const rafId = window.requestAnimationFrame(() => {
if (shouldSkipLiveContentSync()) {
return;
}
schedulePinnedStateAndIndicators();
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessageCount, shouldSkipLiveContentSync]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const handleMessageContentChange = React.useCallback(() => {
if (shouldSkipLiveContentSync()) {
return;
}
schedulePinnedStateAndIndicators();
}, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]);
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const existing = animationHandlersRef.current.get(messageId);
if (existing) {
return existing;
}
const handlers: AnimationHandlers = {
onChunk: () => {
if (shouldSkipLiveContentSync()) {
return;
}
schedulePinnedStateAndIndicators();
},
onComplete: () => {
schedulePinnedStateAndIndicators();
},
onStreamingCandidate: () => {},
onAnimationStart: () => {},
onAnimatedHeightChange: () => {
if (shouldSkipLiveContentSync()) {
return;
}
schedulePinnedStateAndIndicators();
},
onReservationCancelled: () => {},
onReasoningBlock: () => {},
};
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]);
React.useEffect(() => {
return () => {
if (pinnedSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(pinnedSyncRafRef.current);
pinnedSyncRafRef.current = null;
}
flushViewportAnchor();
if (viewportAnchorTimerRef.current !== null) {
clearTimeout(viewportAnchorTimerRef.current);
viewportAnchorTimerRef.current = null;
}
};
}, [flushViewportAnchor]);
React.useEffect(() => {
if (!onActiveTurnChange) {
return;
}
const container = scrollRef.current;
if (!container) {
return;
}
let lastActiveTurnId: string | null = null;
const spy = createScrollSpy({
onActive: (turnId) => {
if (turnId === lastActiveTurnId) {
return;
}
lastActiveTurnId = turnId;
onActiveTurnChange(turnId);
},
});
spy.setContainer(container);
const elementByTurnId = new Map<string, HTMLElement>();
const registerTurnNode = (node: HTMLElement): boolean => {
const turnId = node.dataset.turnId;
if (!turnId) {
return false;
}
elementByTurnId.set(turnId, node);
spy.register(node, turnId);
return true;
};
const unregisterTurnNode = (node: HTMLElement): boolean => {
const turnId = node.dataset.turnId;
if (!turnId) {
return false;
}
if (elementByTurnId.get(turnId) !== node) {
return false;
}
elementByTurnId.delete(turnId);
spy.unregister(turnId);
return true;
};
const collectTurnNodes = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) {
return [];
}
const collected: HTMLElement[] = [];
if (node.matches('[data-turn-id]')) {
collected.push(node);
}
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((turnNode) => {
collected.push(turnNode);
});
return collected;
};
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((node) => {
registerTurnNode(node);
});
spy.markDirty();
const mutationObserver = new MutationObserver((records) => {
let changed = false;
records.forEach((record) => {
record.removedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (unregisterTurnNode(turnNode)) {
changed = true;
}
});
});
record.addedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (registerTurnNode(turnNode)) {
changed = true;
}
});
});
});
if (changed) {
spy.markDirty();
}
});
mutationObserver.observe(container, { subtree: true, childList: true });
const handleScroll = () => {
spy.onScroll();
};
container.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container.removeEventListener('scroll', handleScroll);
mutationObserver.disconnect();
spy.destroy();
};
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessageCount]);
const clearRestoreInProgress = React.useCallback((sessionId: string) => {
if (restoreInProgressForRef.current === sessionId) {
restoreInProgressForRef.current = null;
}
}, []);
return {
scrollRef,
handleMessageContentChange,
getAnimationHandlers,
showScrollButton,
prepareForBottomResume,
scrollToBottom,
scrollToPosition,
releasePinnedScroll,
isPinned,
isOverflowing,
isProgrammaticFollowActive: scrollEngine.isFollowingBottom,
clearRestoreInProgress,
};
};
-332
View File
@@ -1,332 +0,0 @@
import React from 'react';
import { animate, type AnimationPlaybackControls } from 'motion';
type ScrollEngineOptions = {
containerRef: React.RefObject<HTMLDivElement | null>;
isMobile: boolean;
};
type ScrollOptions = {
instant?: boolean;
followBottom?: boolean; // Dynamically track bottom during streaming
persistFollow?: boolean;
};
type ScrollEngineResult = {
handleScroll: () => void;
scrollToPosition: (position: number, options?: ScrollOptions) => void;
forceManualMode: () => void;
cancelFollow: () => void;
cancelAll: () => void;
isAtTop: boolean;
isFollowingBottom: boolean;
isManualOverrideActive: () => boolean;
getScrollTop: () => number;
getScrollHeight: () => number;
getClientHeight: () => number;
};
// Spring config for one-shot scroll-to-bottom (button click, session switch).
const FAST_SPRING = {
type: 'spring' as const,
visualDuration: 0.35,
bounce: 0,
};
// Exponential smoothing factor for the follow-bottom rAF loop.
// Each frame: scrollTop += (target - scrollTop) * LERP_FACTOR
// ~0.12-0.18 gives a smooth camera-follow feel at 60fps.
const LERP_FACTOR = 0.14;
// When the remaining distance is below this, snap exactly to bottom.
const SNAP_EPSILON = 0.5;
const FOLLOW_STABLE_FRAME_LIMIT = 8;
export const useScrollEngine = ({
containerRef,
}: ScrollEngineOptions): ScrollEngineResult => {
const [isAtTop, setIsAtTop] = React.useState(true);
const [isFollowingBottom, setIsFollowingBottom] = React.useState(false);
const atTopRef = React.useRef(true);
const manualOverrideRef = React.useRef(false);
// One-shot spring animation (for scroll-to-bottom button etc.)
const scrollAnimRef = React.useRef<AnimationPlaybackControls | undefined>(undefined);
// Continuous follow-bottom rAF loop (for streaming)
const followRafRef = React.useRef<number | null>(null);
const followActiveRef = React.useRef(false);
const followPersistRef = React.useRef(false);
const followObserversRef = React.useRef<{ resize: ResizeObserver; mutation: MutationObserver } | null>(null);
const cancelSpring = React.useCallback(() => {
if (scrollAnimRef.current) {
scrollAnimRef.current.stop();
scrollAnimRef.current = undefined;
}
}, []);
const teardownFollowObservers = React.useCallback(() => {
const observers = followObserversRef.current;
if (!observers) return;
observers.resize.disconnect();
observers.mutation.disconnect();
followObserversRef.current = null;
}, []);
const cancelFollow = React.useCallback(() => {
teardownFollowObservers();
if (followRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(followRafRef.current);
followRafRef.current = null;
}
followActiveRef.current = false;
followPersistRef.current = false;
setIsFollowingBottom(false);
}, [teardownFollowObservers]);
const cancelAll = React.useCallback(() => {
cancelSpring();
cancelFollow();
}, [cancelSpring, cancelFollow]);
// One burst of the lerp loop — runs until scrollTop catches up to bottom, then stops.
// Re-invoked by observers when persist mode content grows.
const runFollowBurst = React.useCallback(() => {
if (followActiveRef.current) return;
const container = containerRef.current;
if (!container) return;
followActiveRef.current = true;
if (!followPersistRef.current) {
setIsFollowingBottom(true);
}
let stableFrames = 0;
const tick = () => {
const c = containerRef.current;
if (!c || !followActiveRef.current) {
followActiveRef.current = false;
followRafRef.current = null;
if (!followPersistRef.current) {
setIsFollowingBottom(false);
}
return;
}
const target = c.scrollHeight - c.clientHeight;
const current = c.scrollTop;
const delta = target - current;
if (Math.abs(delta) <= SNAP_EPSILON) {
c.scrollTop = target;
stableFrames += 1;
if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) {
followActiveRef.current = false;
followRafRef.current = null;
if (!followPersistRef.current) {
setIsFollowingBottom(false);
}
return;
}
followRafRef.current = window.requestAnimationFrame(tick);
return;
}
stableFrames = 0;
c.scrollTop = current + delta * LERP_FACTOR;
followRafRef.current = window.requestAnimationFrame(tick);
};
followRafRef.current = window.requestAnimationFrame(tick);
}, [containerRef]);
// Observer-driven persist mode — RAF bursts only fire when content actually changes.
// No idle CPU cost while waiting for the next token.
const setupFollowObservers = React.useCallback(() => {
const container = containerRef.current;
if (!container || followObserversRef.current) return;
const onChange = () => {
if (!followPersistRef.current) return;
runFollowBurst();
};
const resize = new ResizeObserver(onChange);
const inner = container.firstElementChild;
if (inner instanceof Element) {
resize.observe(inner);
}
resize.observe(container);
const mutation = new MutationObserver(onChange);
mutation.observe(container, { childList: true, subtree: true });
followObserversRef.current = { resize, mutation };
}, [containerRef, runFollowBurst]);
const startFollowLoop = React.useCallback((persist = false) => {
const wasPersist = followPersistRef.current;
followPersistRef.current = persist || wasPersist;
if (followPersistRef.current) {
if (!wasPersist) {
setIsFollowingBottom(true);
}
setupFollowObservers();
}
runFollowBurst();
}, [runFollowBurst, setupFollowObservers]);
const scrollToPosition = React.useCallback(
(position: number, options?: ScrollOptions) => {
const container = containerRef.current;
if (!container) return;
const target = Math.max(0, position);
const preferInstant = options?.instant ?? false;
const followBottom = options?.followBottom ?? false;
const persistFollow = options?.persistFollow ?? false;
manualOverrideRef.current = false;
// Instant scroll (session switch, etc.)
if (typeof window === 'undefined' || preferInstant) {
cancelAll();
container.scrollTop = target;
if (followBottom && typeof window !== 'undefined') {
startFollowLoop(persistFollow);
}
const atTop = target <= 1;
if (atTopRef.current !== atTop) {
atTopRef.current = atTop;
setIsAtTop(atTop);
}
return;
}
// Follow-bottom mode: start the continuous lerp loop
if (followBottom) {
cancelSpring();
startFollowLoop(persistFollow);
return;
}
// One-shot scroll: stop everything and use spring animation
cancelAll();
const distance = Math.abs(target - container.scrollTop);
if (distance <= SNAP_EPSILON) {
container.scrollTop = target;
const atTop = target <= 1;
if (atTopRef.current !== atTop) {
atTopRef.current = atTop;
setIsAtTop(atTop);
}
return;
}
scrollAnimRef.current = animate(container.scrollTop, target, {
...FAST_SPRING,
onUpdate: (v) => {
container.scrollTop = v;
},
onComplete: () => {
scrollAnimRef.current = undefined;
},
});
},
[cancelAll, cancelSpring, containerRef, setIsAtTop, startFollowLoop]
);
const forceManualMode = React.useCallback(() => {
manualOverrideRef.current = true;
}, []);
const markManualOverride = React.useCallback(() => {
manualOverrideRef.current = true;
cancelFollow();
}, [cancelFollow]);
const isManualOverrideActive = React.useCallback(() => {
return manualOverrideRef.current;
}, []);
const getScrollTop = React.useCallback(() => {
return containerRef.current?.scrollTop ?? 0;
}, [containerRef]);
const getScrollHeight = React.useCallback(() => {
return containerRef.current?.scrollHeight ?? 0;
}, [containerRef]);
const getClientHeight = React.useCallback(() => {
return containerRef.current?.clientHeight ?? 0;
}, [containerRef]);
const handleScroll = React.useCallback(() => {
const container = containerRef.current;
if (!container) return;
if (manualOverrideRef.current && scrollAnimRef.current) {
cancelSpring();
}
const atTop = container.scrollTop <= 1;
if (atTopRef.current !== atTop) {
atTopRef.current = atTop;
setIsAtTop(atTop);
}
}, [cancelSpring, containerRef]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('wheel', markManualOverride, { passive: true });
container.addEventListener('touchstart', markManualOverride, { passive: true });
return () => {
container.removeEventListener('wheel', markManualOverride);
container.removeEventListener('touchstart', markManualOverride);
};
}, [containerRef, markManualOverride]);
React.useEffect(() => {
return () => {
cancelAll();
};
}, [cancelAll]);
return React.useMemo(
() => ({
handleScroll,
scrollToPosition,
forceManualMode,
cancelFollow,
cancelAll,
isAtTop,
isFollowingBottom,
isManualOverrideActive,
getScrollTop,
getScrollHeight,
getClientHeight,
}),
[
handleScroll,
scrollToPosition,
forceManualMode,
cancelFollow,
cancelAll,
isAtTop,
isFollowingBottom,
isManualOverrideActive,
getScrollTop,
getScrollHeight,
getClientHeight,
]
);
};
export type { ScrollEngineResult, ScrollEngineOptions, ScrollOptions };