import { forwardRef, type ReactNode, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { ArrowDown } from "lucide-react"; import { useTranslation } from "react-i18next"; import { PromptRail } from "@/components/thread/PromptRail"; import { ThreadMessages } from "@/components/thread/ThreadMessages"; import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster"; import { ThreadCameraController } from "@/components/thread/thread-camera"; import { ThreadMotionCoordinator, type ThreadMotionGeometry, } from "@/components/thread/thread-motion"; import { Button } from "@/components/ui/button"; import { findPromptElement, promptTop, } from "@/components/thread/promptNavigation"; import { cn } from "@/lib/utils"; import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types"; export interface ThreadViewportHandle { jumpToUserPrompt: (promptId: string) => void; cancelAutoScroll: () => void; } interface ThreadViewportProps { messages: UIMessage[]; isStreaming: boolean; composer: ReactNode; emptyState?: ReactNode; scrollToBottomSignal?: number; activeTurnId?: string | null; activeTurnStartedHere?: boolean; conversationKey?: string | null; conversationReady?: boolean; showScrollToBottomButton?: boolean; cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; slashCommands?: SlashCommand[]; forkBoundaryMessageCount?: number | null; hasMoreBefore?: boolean; loadingOlder?: boolean; userMessageOffset?: number; onLoadOlder?: () => Promise | void; onOpenFilePreview?: (path: string) => void; onForkFromMessage?: (beforeUserIndex: number) => void; onQuoteSelection?: (text: string) => void; } const NEAR_BOTTOM_PX = 48; const NEAR_TOP_PX = 96; const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192; const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; const SOFT_KEYBOARD_MIN_INSET_PX = 80; export const INITIAL_HISTORY_WINDOW = 160; export const HISTORY_WINDOW_INCREMENT = 120; export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] { if (messages.length <= visibleCount) return messages; let start = Math.max(0, messages.length - visibleCount); while ( start > 0 && isAgentActivityMember(messages[start]) && isAgentActivityMember(messages[start - 1]) ) { start -= 1; } return messages.slice(start); } function isKeyboardEditableElement(element: Element | null): element is HTMLElement { if (!(element instanceof HTMLElement)) return false; if (element.isContentEditable) return true; if (element instanceof HTMLTextAreaElement) return true; if (!(element instanceof HTMLInputElement)) return false; return ![ "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit", ].includes(element.type); } type ThreadScrollDirection = "backward" | "forward"; const KEYBOARD_SCROLL_DIRECTIONS: Readonly< Partial> > = { ArrowUp: "backward", PageUp: "backward", Home: "backward", ArrowDown: "forward", PageDown: "forward", End: "forward", }; function directionFromDelta(deltaY: number): ThreadScrollDirection | null { return deltaY < 0 ? "backward" : deltaY > 0 ? "forward" : null; } function keyboardScrollDirection( event: KeyboardEvent, ): ThreadScrollDirection | null { if (event.key === " ") { return event.shiftKey ? "backward" : "forward"; } return KEYBOARD_SCROLL_DIRECTIONS[event.key] ?? null; } function canScrollInDirection( element: HTMLElement, direction: ThreadScrollDirection | null, ): boolean { switch (direction) { case "backward": return element.scrollTop > 0; case "forward": return ( element.scrollTop < Math.max(0, element.scrollHeight - element.clientHeight) ); default: return false; } } function readSoftKeyboardInsetBottom(container: HTMLElement | null): number { const viewport = window.visualViewport; if (!viewport) return 0; const active = document.activeElement; if (!isKeyboardEditableElement(active) || !container?.contains(active)) return 0; const layoutHeight = window.innerHeight || document.documentElement.clientHeight; const inset = layoutHeight - viewport.height - viewport.offsetTop; return inset >= SOFT_KEYBOARD_MIN_INSET_PX ? Math.ceil(inset) : 0; } export const ThreadViewport = forwardRef(function ThreadViewport({ messages, isStreaming, composer, emptyState, scrollToBottomSignal = 0, activeTurnId = null, activeTurnStartedHere = false, conversationKey = null, conversationReady = true, showScrollToBottomButton = true, cliApps = [], mcpPresets = [], slashCommands = [], forkBoundaryMessageCount = null, hasMoreBefore = false, loadingOlder = false, userMessageOffset = 0, onLoadOlder, onOpenFilePreview, onForkFromMessage, onQuoteSelection, }, ref) { const { t } = useTranslation(); const scrollRef = useRef(null); const contentRef = useRef(null); const messageRegionRef = useRef(null); const messageContentRef = useRef(null); const composerDockRef = useRef(null); const bottomRef = useRef(null); const lastConversationKeyRef = useRef(conversationKey); const pendingConversationScrollRef = useRef(true); const pendingPromptJumpRef = useRef(null); const restoreScrollAfterPrependRef = useRef<{ height: number; top: number } | null>(null); const composerDockHeightRef = useRef(0); const [atBottom, setAtBottom] = useState(true); const [composerDockHeight, setComposerDockHeight] = useState(0); const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0); const [hasVerticalOverflow, setHasVerticalOverflow] = useState(false); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_HISTORY_WINDOW); const threadMotionRef = useRef(null); if (threadMotionRef.current === null) { const camera = new ThreadCameraController(() => scrollRef.current); threadMotionRef.current = new ThreadMotionCoordinator({ camera, measure: (promptId) => { const scrollEl = scrollRef.current; const composerDock = composerDockRef.current; if (!scrollEl) return null; const composerHeight = composerDock ? composerDock.getBoundingClientRect().height || composerDock.offsetHeight : 0; const prompt = promptId ? findPromptElement(scrollEl, promptId) : null; const scrollHeight = scrollEl.scrollHeight; const clientHeight = scrollEl.clientHeight; const maxScrollTop = Math.max(0, scrollHeight - clientHeight); return { scrollTop: scrollEl.scrollTop, scrollHeight, clientHeight, maxScrollTop, composerHeight, promptTop: prompt ? Math.min( maxScrollTop, Math.max(0, promptTop(scrollEl, prompt) - 16), ) : null, }; }, onGeometry: (geometry: ThreadMotionGeometry) => { if (Math.abs(composerDockHeightRef.current - geometry.composerHeight) >= 1) { composerDockHeightRef.current = geometry.composerHeight; setComposerDockHeight(geometry.composerHeight); } const nextOverflow = geometry.scrollHeight > geometry.clientHeight + 1; setHasVerticalOverflow((current) => current === nextOverflow ? current : nextOverflow, ); }, onAutoFollow: () => setAtBottom(true), }); } const hasMessages = messages.length > 0; const visibleMessages = useMemo( () => windowMessages(messages, visibleMessageCount), [messages, visibleMessageCount], ); const hiddenMessageCount = messages.length - visibleMessages.length; const hiddenUserMessageCount = userMessageOffset + (hiddenMessageCount > 0 ? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length : 0); const visibleForkBoundaryMessageCount = forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount ? forkBoundaryMessageCount - hiddenMessageCount : null; const scrollButtonBottom = keyboardInsetBottom + (composerDockHeight > 0 ? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX : DEFAULT_SCROLL_BUTTON_BOTTOM_PX); const scrollViewportStyle = keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined; const yieldCameraToUser = useCallback(() => { threadMotionRef.current?.takeUserControl(); }, []); const scrollToBottomNow = useCallback((smooth = false) => { const el = scrollRef.current; const marker = bottomRef.current; const behavior: ScrollBehavior = smooth ? "smooth" : "auto"; if (el) { const top = Math.max(0, el.scrollHeight - el.clientHeight); if (smooth) { threadMotionRef.current?.animateTo(top); } else { threadMotionRef.current?.jumpTo(top); } } else if (marker) { marker.scrollIntoView({ block: "end", behavior }); } setAtBottom(true); }, []); const scrollToBottom = useCallback( (smooth = false, options?: { force?: boolean }) => { const force = options?.force ?? false; if (!force && threadMotionRef.current?.isAutoFollowPaused()) return; threadMotionRef.current?.resumeAutoFollow(); scrollToBottomNow(smooth); }, [scrollToBottomNow], ); const loadEarlierMessages = useCallback(() => { const el = scrollRef.current; if (el) { restoreScrollAfterPrependRef.current = { height: el.scrollHeight, top: el.scrollTop, }; } threadMotionRef.current?.takeUserControl(); setAtBottom(false); if (hiddenMessageCount > 0) { setVisibleMessageCount((count) => Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT), ); return; } if (hasMoreBefore && onLoadOlder && !loadingOlder) { setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT); void onLoadOlder(); } }, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]); const maybeLoadEarlierFromScroll = useCallback(() => { const el = scrollRef.current; if (!el || !hasMessages || pendingConversationScrollRef.current) return; if (!threadMotionRef.current?.isBrowsingHistory()) return; if (el.scrollTop > NEAR_TOP_PX) return; if (hiddenMessageCount <= 0 && !hasMoreBefore) return; loadEarlierMessages(); }, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]); const navigateToVisiblePrompt = useCallback((promptId: string) => { const scrollEl = scrollRef.current; const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null; if (!scrollEl || !prompt) return false; setAtBottom(false); const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight); threadMotionRef.current?.navigateHistoryTo( Math.min( maxScrollTop, Math.max(0, promptTop(scrollEl, prompt) - 16), ), ); return true; }, []); const jumpToUserPrompt = useCallback((promptId: string) => { if (navigateToVisiblePrompt(promptId)) return; const index = messages.findIndex((message) => message.id === promptId); if (index < 0) return; threadMotionRef.current?.takeUserControl(); pendingPromptJumpRef.current = promptId; setAtBottom(false); setVisibleMessageCount((count) => Math.max(count, messages.length - index)); }, [messages, navigateToVisiblePrompt]); const cancelAutoScroll = useCallback(() => { threadMotionRef.current?.takeUserControl(); }, []); useImperativeHandle( ref, () => ({ jumpToUserPrompt, cancelAutoScroll, }), [cancelAutoScroll, jumpToUserPrompt], ); useLayoutEffect(() => { const updateKeyboardInset = () => { const scrollEl = scrollRef.current; const next = readSoftKeyboardInsetBottom(scrollEl); const active = document.activeElement; const composerFocused = hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active)); setKeyboardInsetBottom((current) => Math.abs(current - next) < 1 ? current : next, ); if (composerFocused) { // Focusing the composer establishes a new reference frame at the // latest message. This is one immediate positioning command; viewport // events may issue a fresh command, but no command survives into a // later render as a train of retry frames. scrollToBottom(false, { force: true }); } }; updateKeyboardInset(); const viewport = window.visualViewport; viewport?.addEventListener("resize", updateKeyboardInset); viewport?.addEventListener("scroll", updateKeyboardInset); window.addEventListener("resize", updateKeyboardInset); document.addEventListener("focusin", updateKeyboardInset); document.addEventListener("focusout", updateKeyboardInset); return () => { viewport?.removeEventListener("resize", updateKeyboardInset); viewport?.removeEventListener("scroll", updateKeyboardInset); window.removeEventListener("resize", updateKeyboardInset); document.removeEventListener("focusin", updateKeyboardInset); document.removeEventListener("focusout", updateKeyboardInset); }; }, [hasMessages, scrollToBottom]); useEffect(() => { if (scrollToBottomSignal <= 0) return; scrollToBottom(false, { force: true }); }, [scrollToBottomSignal, scrollToBottom]); useLayoutEffect(() => { if (lastConversationKeyRef.current === conversationKey) return; lastConversationKeyRef.current = conversationKey; pendingConversationScrollRef.current = true; threadMotionRef.current?.reset(); setAtBottom(true); setVisibleMessageCount(INITIAL_HISTORY_WINDOW); }, [conversationKey]); useLayoutEffect(() => { if (!conversationReady) { threadMotionRef.current?.reset(); return; } if (!activeTurnId) { threadMotionRef.current?.completeTurn(); return; } const promptIndex = messages.findIndex( (message) => message.role === "user" && message.turnId === activeTurnId, ); if ( activeTurnStartedHere && promptIndex >= 0 && threadMotionRef.current?.snapshot().turnId !== activeTurnId ) { // A turn submitted in this mounted viewport establishes a new prompt // origin. A restored turn must first complete open-at-bottom instead. pendingConversationScrollRef.current = false; } const prompt = promptIndex >= 0 ? messages[promptIndex] : null; const hasOutput = promptIndex >= 0 ? messages .slice(promptIndex + 1) .some( (message) => message.role !== "user" && (!message.turnId || message.turnId === activeTurnId), ) : !activeTurnStartedHere && messages.some( (message) => message.role !== "user" && message.turnId === activeTurnId, ); threadMotionRef.current?.updateTurn({ id: activeTurnId, promptId: prompt?.id ?? null, hasOutput, entry: activeTurnStartedHere ? "submitted" : "restored", }); }, [activeTurnId, activeTurnStartedHere, conversationReady, messages]); useLayoutEffect(() => { const pending = restoreScrollAfterPrependRef.current; if (!pending) return; const el = scrollRef.current; restoreScrollAfterPrependRef.current = null; if (!el) return; const delta = el.scrollHeight - pending.height; const nextTop = Math.min( Math.max(0, el.scrollHeight - el.clientHeight), Math.max(0, pending.top + delta), ); threadMotionRef.current?.jumpTo(nextTop); }, [visibleMessages.length, messages.length]); useLayoutEffect(() => { const promptId = pendingPromptJumpRef.current; const scrollEl = scrollRef.current; if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return; pendingPromptJumpRef.current = null; const frame = window.requestAnimationFrame(() => navigateToVisiblePrompt(promptId)); return () => window.cancelAnimationFrame(frame); }, [navigateToVisiblePrompt, visibleMessages.length]); useLayoutEffect(() => { if (!pendingConversationScrollRef.current) return; if (!conversationReady) return; if (!conversationKey) { pendingConversationScrollRef.current = false; scrollToBottom(false, { force: true }); return; } scrollToBottom(false, { force: true }); if (!hasMessages) return; pendingConversationScrollRef.current = false; }, [ conversationKey, conversationReady, hasMessages, messages, scrollToBottom, ]); useLayoutEffect(() => { threadMotionRef.current?.invalidateGeometry(); }, [composer, hasMessages, visibleMessages.length]); useEffect(() => () => threadMotionRef.current?.dispose(), []); useLayoutEffect(() => { const el = scrollRef.current; const content = contentRef.current; const messageRegion = messageRegionRef.current; const messageContent = messageContentRef.current; const composerDock = composerDockRef.current; if (!el) return; const invalidateGeometry = () => { threadMotionRef.current?.invalidateGeometry(); }; invalidateGeometry(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(invalidateGeometry); observer?.observe(el); if (content) observer?.observe(content); if (messageRegion) observer?.observe(messageRegion); if (messageContent) observer?.observe(messageContent); if (composerDock) observer?.observe(composerDock); window.addEventListener("resize", invalidateGeometry); return () => { observer?.disconnect(); window.removeEventListener("resize", invalidateGeometry); }; }, [hasMessages]); useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = (allowHistoryLoad = true) => { const distance = el.scrollHeight - el.scrollTop - el.clientHeight; const near = distance < NEAR_BOTTOM_PX; const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic"; const logicallyAtBottom = owner === "automatic" || near; setAtBottom((current) => current === logicallyAtBottom ? current : logicallyAtBottom, ); if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll(); }; onScroll(false); const handleScroll = () => onScroll(true); const handleDirectionalInput = ( direction: ThreadScrollDirection | null, ) => { if (!direction) return; threadMotionRef.current?.handleUserScrollIntent( canScrollInDirection(el, direction), ); }; const handleWheel = (event: WheelEvent) => { if ( event.defaultPrevented || event.ctrlKey || Math.abs(event.deltaY) <= Math.abs(event.deltaX) ) { return; } handleDirectionalInput(directionFromDelta(event.deltaY)); }; const handlePointerDown = (event: PointerEvent) => { if (event.button === 0 && event.target === el) yieldCameraToUser(); }; let touchStartY: number | null = null; const handleTouchStart = (event: TouchEvent) => { touchStartY = event.touches[0]?.clientY ?? null; }; const handleTouchMove = (event: TouchEvent) => { const currentY = event.touches[0]?.clientY; const scrollDeltaY = touchStartY !== null && currentY !== undefined ? touchStartY - currentY : 0; handleDirectionalInput(directionFromDelta(scrollDeltaY)); }; const handleTouchEnd = () => { touchStartY = null; }; const handleKeyDown = (event: KeyboardEvent) => { if ( event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || isKeyboardEditableElement(event.target as Element | null) ) { return; } handleDirectionalInput(keyboardScrollDirection(event)); }; el.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener("wheel", handleWheel, { passive: true }); el.addEventListener("touchstart", handleTouchStart, { passive: true }); el.addEventListener("touchmove", handleTouchMove, { passive: true }); el.addEventListener("touchend", handleTouchEnd, { passive: true }); el.addEventListener("touchcancel", handleTouchEnd, { passive: true }); el.addEventListener("pointerdown", handlePointerDown); el.addEventListener("keydown", handleKeyDown); return () => { el.removeEventListener("scroll", handleScroll); el.removeEventListener("wheel", handleWheel); el.removeEventListener("touchstart", handleTouchStart); el.removeEventListener("touchmove", handleTouchMove); el.removeEventListener("touchend", handleTouchEnd); el.removeEventListener("touchcancel", handleTouchEnd); el.removeEventListener("pointerdown", handlePointerDown); el.removeEventListener("keydown", handleKeyDown); }; }, [maybeLoadEarlierFromScroll, yieldCameraToUser]); return (
{hasMessages ? (
) : (
{emptyState}
)}
{composer}
{hasMessages ? ( ) : null} {showScrollToBottomButton && !atBottom && (
)}
); });