import { Fragment, type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react' import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual' import { estimateStreamItemHeight } from './agent-stream-web-virtualization' import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy' import { createStreamStrategy } from './stream-strategy' type 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 WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style' const WEB_STREAM_SCROLLBAR_STYLE = ` #agent-chat-scroll-web-dom-scroll, #agent-chat-scroll-web-dom-virtualized { scrollbar-width: none; -ms-overflow-style: none; } #agent-chat-scroll-web-dom-scroll::-webkit-scrollbar, #agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar { display: none; width: 0; height: 0; } ` 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, scrollEnabled, isMobileBreakpoint, } = props const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } = require('./web-desktop-scrollbar') as typeof import('./web-desktop-scrollbar') const scrollContainerRef = useRef(null) const contentRef = useRef(null) 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 streamScrollbarMetrics = useWebDesktopScrollbarMetrics() const showDesktopWebScrollbar = !isMobileBreakpoint 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 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 } streamScrollbarMetrics.onContentSizeChange( scrollContainer.clientWidth, scrollContainer.scrollHeight ) streamScrollbarMetrics.onLayout({ nativeEvent: { layout: { width: scrollContainer.clientWidth, height: scrollContainer.clientHeight, x: 0, y: 0, }, }, } as never) streamScrollbarMetrics.onScroll({ nativeEvent: { contentOffset: { x: 0, y: scrollContainer.scrollTop }, contentSize: { width: scrollContainer.clientWidth, height: scrollContainer.scrollHeight, }, layoutMeasurement: { width: scrollContainer.clientWidth, height: scrollContainer.clientHeight, }, }, } as never) syncNearBottom(scrollContainer, onNearBottomChange) }, [onNearBottomChange, streamScrollbarMetrics]) 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() }, [cancelPendingStickToBottom, updateScrollMetrics]) 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 => ({ 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 => ({ flex: 1, minHeight: 0, overflowX: 'hidden', overflowY: scrollEnabled ? 'auto' : 'hidden', overscrollBehaviorY: 'contain', }), [scrollEnabled] ) const virtualRowsContainerStyle = useMemo( (): CSSProperties => ({ 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( () => segments.historyMounted.map((item, index) => ( {renderHistoryMountedRow(item, index, segments.historyMounted)} )), [renderHistoryMountedRow, segments.historyMounted] ) const liveHeadRows = useMemo( () => segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )), [renderLiveHeadRow, segments.liveHead] ) const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]) const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && !boundary.hasLiveHead && !liveAuxiliary return ( <>
{ scrollContainerRef.current = node }} data-testid="agent-chat-scroll" id={`agent-chat-scroll-${shouldUseVirtualizer ? 'web-dom-virtualized' : 'web-dom-scroll'}`} style={scrollContainerStyle} >
{ contentRef.current = node }} style={contentContainerStyle} > {shouldUseVirtualizer ? (
{virtualRows.map((virtualRow) => { const item = segments.historyVirtualized[virtualRow.index] if (!item) { return null } return (
{renderHistoryVirtualizedRow( item, virtualRow.index, segments.historyVirtualized )}
) })}
) : null} {mountedHistoryRows} {boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
) : null} {liveHeadRows} {liveAuxiliary} {shouldRenderEmpty ? listEmptyComponent : null}
{ const scrollContainer = scrollContainerRef.current if (!scrollContainer) { return } scrollContainer.scrollTo({ top: nextOffset, behavior: 'auto' }) lastKnownScrollTopRef.current = scrollContainer.scrollTop updateScrollMetrics() }} /> ) } export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy { return createStreamStrategy({ render: (renderInput) => ( ), orderTailReverse: false, orderHeadReverse: false, assistantTurnTraversalStep: -1, edgeSlot: '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), }) }