import React, { Fragment, type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { ActivityIndicator } from "react-native"; import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual"; import { estimateStreamItemHeight } from "./web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy } from "./strategy"; interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; } type ScrollBehaviorLike = "auto" | "smooth"; const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; const USER_SCROLL_DELTA_EPSILON = 1; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const HISTORY_START_THRESHOLD_PX = 96; import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; const historyStartSlotStyle: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", minHeight: 32, paddingTop: 4, paddingBottom: 8, }; function isScrollContainerNearBottom( scrollContainer: Pick, thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX, ): boolean { const threshold = Number.isFinite(thresholdPx) ? Math.max(0, thresholdPx) : AUTO_SCROLL_BOTTOM_THRESHOLD_PX; const { scrollTop, clientHeight, scrollHeight } = scrollContainer; if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { return true; } const distanceFromBottom = scrollHeight - clientHeight - scrollTop; return distanceFromBottom <= threshold; } function isScrollContainerAtBottom( scrollContainer: Pick, ): boolean { return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX); } function scrollElementToBottom( scrollContainer: HTMLElement, behavior: ScrollBehaviorLike = "auto", ): void { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior, }); } function syncNearBottom( scrollContainer: HTMLElement | null, onNearBottomChange: (value: boolean) => void, ): boolean { if (!scrollContainer) { onNearBottomChange(true); return true; } const nextValue = isScrollContainerNearBottom(scrollContainer); onNearBottomChange(nextValue); return nextValue; } function getScrollContainerDistanceFromBottom( scrollContainer: Pick, ): number { return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop; } function isScrollContainerOverscrolledPastBottom( scrollContainer: Pick, ): boolean { return getScrollContainerDistanceFromBottom(scrollContainer) < 0; } function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments, boundary, renderers, listEmptyComponent, viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, onNearBottomChange, onNearHistoryStart, isLoadingOlderHistory, hasOlderHistory, scrollEnabled, isMobileBreakpoint, } = props; const scrollContainerRef = useRef(null); const contentRef = useRef(null); const handleScrollContainerRef = useCallback((node: HTMLElement | null) => { scrollContainerRef.current = node; }, []); const handleContentRef = useCallback((node: HTMLElement | null) => { contentRef.current = node; }, []); const [followOutput, setFollowOutputr] = useState(true); const setFollowOutput = (value: boolean) => { setFollowOutputr(value); return value; }; const followOutputRef = useRef(followOutput); const lastKnownScrollTopRef = useRef(0); const pendingUserScrollUpIntentRef = useRef(false); const isPointerScrollActiveRef = useRef(false); const lastTouchClientYRef = useRef(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); const showDesktopWebScrollbar = !isMobileBreakpoint; const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, { enabled: showDesktopWebScrollbar, contentRef, }); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary, } = renderers; followOutputRef.current = followOutput; const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId; const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady; const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, getScrollElement: () => scrollContainerRef.current, getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { const row = segments.historyVirtualized[index]; return row ? estimateStreamItemHeight(row) : 120; }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, overscan: 8, }); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; }; return () => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; }; }, [rowVirtualizer]); const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); const measureVirtualizedRowElement = useCallback( (node: HTMLDivElement | null) => { if (!node) { rowVirtualizer.measureElement(null); return; } const pendingFrames = pendingVirtualRowMeasureFramesRef.current; const existingFrame = pendingFrames.get(node); if (existingFrame !== undefined) { window.cancelAnimationFrame(existingFrame); } const frame = window.requestAnimationFrame(() => { pendingFrames.delete(node); if (node.isConnected) { rowVirtualizer.measureElement(node); } }); pendingFrames.set(node, frame); }, [rowVirtualizer], ); useEffect(() => { const pendingFrames = pendingVirtualRowMeasureFramesRef.current; return () => { for (const frame of pendingFrames.values()) { window.cancelAnimationFrame(frame); } pendingFrames.clear(); }; }, []); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame !== null) { pendingAutoScrollFrameRef.current = null; window.cancelAnimationFrame(pendingFrame); } const pendingTimeout = pendingAutoScrollTimeoutRef.current; if (pendingTimeout !== null) { pendingAutoScrollTimeoutRef.current = null; window.clearTimeout(pendingTimeout); } }, []); const scrollMessagesToBottom = useCallback( (behavior: ScrollBehaviorLike = "auto") => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; } if (isScrollContainerOverscrolledPastBottom(scrollContainer)) { return; } scrollElementToBottom(scrollContainer, behavior); lastKnownScrollTopRef.current = scrollContainer.scrollTop; syncNearBottom(scrollContainer, onNearBottomChange); }, [onNearBottomChange], ); const scheduleStickToBottom = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) { return; } if (pendingAutoScrollFrameRef.current !== null) { return; } pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { pendingAutoScrollFrameRef.current = null; if (!followOutputRef.current) { return; } scrollMessagesToBottom("auto"); }); }, [scrollMessagesToBottom]); const forceStickToBottom = useCallback(() => { cancelPendingStickToBottom(); scrollMessagesToBottom("auto"); scheduleStickToBottom(); }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); const updateScrollMetrics = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { onNearBottomChange(true); return; } syncNearBottom(scrollContainer, onNearBottomChange); }, [onNearBottomChange]); const handleDomScroll = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; } const currentScrollTop = scrollContainer.scrollTop; const isAtBottom = isScrollContainerAtBottom(scrollContainer); const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON; if (!followOutputRef.current && isAtBottom) { setFollowOutput(true); pendingUserScrollUpIntentRef.current = false; } else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) { if (scrolledUp) { cancelPendingStickToBottom(); setFollowOutput(false); } pendingUserScrollUpIntentRef.current = false; } else if (followOutputRef.current && isPointerScrollActiveRef.current) { if (scrolledUp) { cancelPendingStickToBottom(); setFollowOutput(false); } } lastKnownScrollTopRef.current = currentScrollTop; updateScrollMetrics(); if ( historyStartReadyRef.current && hasOlderHistory && currentScrollTop <= HISTORY_START_THRESHOLD_PX ) { onNearHistoryStart(); } }, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]); useEffect(() => { const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; }); return () => { window.cancelAnimationFrame(frame); historyStartReadyRef.current = false; }; }, [props.agentId]); useLayoutEffect(() => { if (!isActivationReady) { return; } setFollowOutput(true); forceStickToBottom(); const timeout = window.setTimeout(() => { if (!followOutputRef.current) { return; } const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; } if (isScrollContainerNearBottom(scrollContainer)) { return; } scheduleStickToBottom(); }, WEB_BOTTOM_SETTLE_TIMEOUT_MS); return () => { window.clearTimeout(timeout); }; }, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]); useEffect(() => { if (!followOutputRef.current) { return; } scheduleStickToBottom(); }, [ scheduleStickToBottom, segments.historyMounted, segments.historyVirtualized, segments.liveHead, ]); useEffect(() => { if (!followOutputRef.current || !shouldUseVirtualizer) { return; } scheduleStickToBottom(); }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]); useEffect(() => { updateScrollMetrics(); }, [ segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, updateScrollMetrics, virtualTotalSize, ]); useEffect(() => { const scrollContainer = scrollContainerRef.current; const contentNode = contentRef.current; if (!scrollContainer || typeof ResizeObserver === "undefined") { return; } updateScrollMetrics(); const observer = new ResizeObserver(() => { updateScrollMetrics(); if (!followOutputRef.current) { return; } scheduleStickToBottom(); }); observer.observe(scrollContainer); if (contentNode) { observer.observe(contentNode); } return () => { observer.disconnect(); }; }, [scheduleStickToBottom, updateScrollMetrics]); useEffect(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; } const handleWheel = (event: WheelEvent) => { if (event.deltaY < 0) { pendingUserScrollUpIntentRef.current = true; cancelPendingStickToBottom(); } }; const handlePointerDown = () => { isPointerScrollActiveRef.current = true; }; const handlePointerUp = () => { isPointerScrollActiveRef.current = false; }; const handleTouchStart = (event: TouchEvent) => { const touch = event.touches[0]; if (!touch) { return; } lastTouchClientYRef.current = touch.clientY; }; const handleTouchMove = (event: TouchEvent) => { const touch = event.touches[0]; if (!touch) { return; } const previousTouchY = lastTouchClientYRef.current; if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { pendingUserScrollUpIntentRef.current = true; cancelPendingStickToBottom(); } lastTouchClientYRef.current = touch.clientY; }; const handleTouchEnd = () => { lastTouchClientYRef.current = null; }; scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true }); scrollContainer.addEventListener("wheel", handleWheel, { passive: true }); scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true }); scrollContainer.addEventListener("pointerup", handlePointerUp, { passive: true }); scrollContainer.addEventListener("pointercancel", handlePointerUp, { passive: true }); scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true }); scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true }); scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true }); scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true }); return () => { scrollContainer.removeEventListener("scroll", handleDomScroll); scrollContainer.removeEventListener("wheel", handleWheel); scrollContainer.removeEventListener("pointerdown", handlePointerDown); scrollContainer.removeEventListener("pointerup", handlePointerUp); scrollContainer.removeEventListener("pointercancel", handlePointerUp); scrollContainer.removeEventListener("touchstart", handleTouchStart); scrollContainer.removeEventListener("touchmove", handleTouchMove); scrollContainer.removeEventListener("touchend", handleTouchEnd); scrollContainer.removeEventListener("touchcancel", handleTouchEnd); }; }, [cancelPendingStickToBottom, handleDomScroll]); useEffect(() => { const handle: StreamViewportHandle = { scrollToBottom: () => { setFollowOutput(true); cancelPendingStickToBottom(); forceStickToBottom(); }, prepareForViewportChange: () => { if (!followOutputRef.current) { return; } scheduleStickToBottom(); }, }; viewportRef.current = handle; return () => { if (viewportRef.current === handle) { viewportRef.current = null; } cancelPendingStickToBottom(); }; }, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]); const contentContainerStyle = useMemo((): CSSProperties => { return { display: "flex", flexDirection: "column", minHeight: "100%", paddingTop: 16, paddingBottom: 16, paddingLeft: isMobileBreakpoint ? 8 : 16, paddingRight: isMobileBreakpoint ? 8 : 16, boxSizing: "border-box", }; }, [isMobileBreakpoint]); const scrollContainerStyle = useMemo((): CSSProperties => { return { flex: 1, minHeight: 0, overflowX: "hidden", overflowY: scrollEnabled ? "auto" : "hidden", overscrollBehaviorY: "contain", }; }, [scrollEnabled]); const virtualRowsContainerStyle = useMemo((): CSSProperties => { return { position: "relative", width: "100%", height: virtualTotalSize, }; }, [virtualTotalSize]); const renderVirtualRowStyle = useCallback( (start: number): CSSProperties => ({ position: "absolute", top: 0, left: 0, display: "flex", flexDirection: "column", width: "100%", transform: `translateY(${start}px)`, }), [], ); const mountedHistoryRows = useMemo(() => { return segments.historyMounted.map((item, index) => ( {renderHistoryMountedRow(item, index, segments.historyMounted)} )); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { return segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )); }, [renderLiveHeadRow, segments.liveHead]); const liveAuxiliary = useMemo(() => { return renderLiveAuxiliary(); }, [renderLiveAuxiliary]); const historyStartSlot = useMemo(() => { if (!isLoadingOlderHistory) { return null; } return (
); }, [isLoadingOlderHistory]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && !boundary.hasLiveHead && !liveAuxiliary; return ( <>
{historyStartSlot} {shouldUseVirtualizer ? (
{virtualRows.map((virtualRow) => { const item = segments.historyVirtualized[virtualRow.index]; if (!item) { return null; } return (
{renderHistoryVirtualizedRow( item, virtualRow.index, segments.historyVirtualized, )}
); })}
) : null} {mountedHistoryRows} {liveHeadRows} {liveAuxiliary} {shouldRenderEmpty ? listEmptyComponent : null}
{scrollbarOverlay} ); } export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy { return createStreamStrategy({ render: (renderInput) => ( ), orderTailReverse: false, orderHeadReverse: false, assistantTurnTraversalStep: -1, edgeSlot: "footer", historyLiveBoundaryEdge: "last", liveHeadHistoryBoundaryEdge: "first", frameChildOrder: "content-then-footer", flatListInverted: false, overlayScrollbarInverted: false, maintainVisibleContentPosition: undefined, bottomAnchorTransportBehavior: { verificationDelayFrames: 0, verificationRetryMode: "rescroll", }, disableParentScrollOnInlineDetailsExpansion: false, anchorBottomOnContentSizeChange: true, animateManualScrollToBottom: false, useVirtualizedList: false, isNearBottom: (inputMetrics) => { const distanceFromBottom = Math.max( 0, inputMetrics.contentHeight - (inputMetrics.offsetY + inputMetrics.viewportHeight), ); return distanceFromBottom <= inputMetrics.threshold; }, getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight), }); }