diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 22320a7d..392d4dc5 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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; - 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 = ({ 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 = ({ autoOpenDraft = tr handleMessageContentChange={handleMessageContentChange} getAnimationHandlers={getAnimationHandlers} handleLoadOlder={handleLoadOlder} - scrollToBottom={scrollToBottom} + scrollToBottom={resumeToLatestInstant} sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} - isProgrammaticFollowActive={isProgrammaticFollowActive} + isProgrammaticFollowActive={isFollowingProgrammatically} />
{ interface ChatInputProps { onOpenSettings?: () => void; - scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; + scrollToBottom?: () => void; } type AutocompleteOverlayPosition = { @@ -1489,12 +1489,12 @@ const ChatInputComponent: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ onOpenSettings, scrollTo ); if (typeof window === 'undefined') { - scrollToBottom?.({ instant: true, force: true }); + scrollToBottom?.(); } else { window.requestAnimationFrame(() => { - scrollToBottom?.({ instant: true, force: true }); + scrollToBottom?.(); }); } diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 5f222faf..1eeb96a9 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -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; diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 2a2c38e3..3b73a84f 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -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; } @@ -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(({ @@ -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; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; - scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; + scrollToBottom?: () => void; stickyUserHeader: boolean; defaultActivityExpanded: boolean; turnUiStates: Map; @@ -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(({ }>({ 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(() => { diff --git a/packages/ui/src/components/chat/components/TurnActivity.tsx b/packages/ui/src/components/chat/components/TurnActivity.tsx index 2523ff03..4d04abfd 100644 --- a/packages/ui/src/components/chat/components/TurnActivity.tsx +++ b/packages/ui/src/components/chat/components/TurnActivity.tsx @@ -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; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 2f6f3d7e..5a8a6b28 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -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; messageListRef: React.RefObject; loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise; - 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; revealBufferedTurns: () => Promise; resumeToBottom: () => void; - resumeToBottomInstant: () => void; - restoreSavedScrollPosition: (savedPos: NonNullable) => Promise; + resumeToBottomInstant: () => Promise; scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise; scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise; 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(null); const previousMessagesRef = React.useRef(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) => { - 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, diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index b7ac3e62..d98ce164 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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'; diff --git a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx index 21240164..f3c0ea3e 100644 --- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx @@ -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'; diff --git a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx index 41d7b74c..b3620dc1 100644 --- a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx @@ -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'; diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index 26aadcae..9e838999 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -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'; diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 65eba19b..f2891221 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -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'; diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index d0ce31b3..6ed35d25 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -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'; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts index 4d2dea42..1f85c325 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts @@ -85,11 +85,6 @@ export const useSessionActions = (args: Args) => { if (sessionId === args.currentSessionId) { if (args.allowReselect) { - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('openchamber:session-reselected', { - detail: sessionId, - })); - } args.onSessionSelected?.(sessionId); } resetSessionSearch(); diff --git a/packages/ui/src/components/ui/OverlayScrollbar.tsx b/packages/ui/src/components/ui/OverlayScrollbar.tsx index ae57162b..d1026abb 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.tsx @@ -322,6 +322,7 @@ const OverlayScrollbarComponent: React.FC = ({ {showVertical && (
= ({ {showHorizontal && (
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; + 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; +} + +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, 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(null); + const [containerEl, setContainerEl] = React.useState(null); + const lastSeenContainerRef = React.useRef(null); + + const [state, setState] = React.useState('following'); + const [isOverflowing, setIsOverflowing] = React.useState(false); + const [showScrollButton, setShowScrollButton] = React.useState(false); + const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false); + + const stateRef = React.useRef('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(null); + const programmaticWriteUntilRef = React.useRef(0); + const followRafRef = React.useRef(null); + const settledFramesRef = React.useRef(0); + const lastScrollTopRef = React.useRef(0); + const saveTimerRef = React.useRef | null>(null); + const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); + const settleBurstRafRef = React.useRef(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(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 => { + 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>(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(); + 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('[data-turn-id]').forEach((el) => collected.push(el)); + return collected; + }; + + container.querySelectorAll('[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, + }; +}; diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts deleted file mode 100644 index ab12c151..00000000 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ /dev/null @@ -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; - 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; - 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(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(null); - const isPinnedRef = React.useRef(true); - const lastScrollTopRef = React.useRef(0); - const touchLastYRef = React.useRef(null); - const pinnedSyncRafRef = React.useRef(null); - const followModeRef = React.useRef('none'); - const autoScrollMarkerRef = React.useRef(null); - const viewportAnchorTimerRef = React.useRef | 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(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(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>(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(); - - 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('[data-turn-id]').forEach((turnNode) => { - collected.push(turnNode); - }); - return collected; - }; - - container.querySelectorAll('[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, - }; -}; diff --git a/packages/ui/src/hooks/useScrollEngine.ts b/packages/ui/src/hooks/useScrollEngine.ts deleted file mode 100644 index 9011d47d..00000000 --- a/packages/ui/src/hooks/useScrollEngine.ts +++ /dev/null @@ -1,332 +0,0 @@ -import React from 'react'; -import { animate, type AnimationPlaybackControls } from 'motion'; - -type ScrollEngineOptions = { - containerRef: React.RefObject; - 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(undefined); - - // Continuous follow-bottom rAF loop (for streaming) - const followRafRef = React.useRef(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 };