import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react' import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual' import { type ComponentProps, type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { cn } from '@/lib/utils' import { setThreadScrolledUp } from '@/store/thread-scroll' const ESTIMATED_ITEM_HEIGHT = 220 const OVERSCAN = 4 const AT_BOTTOM_THRESHOLD = 4 type ThreadMessageComponents = ComponentProps['components'] type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' } interface VirtualizedThreadProps { clampToComposer: boolean components: ThreadMessageComponents emptyPlaceholder?: ReactNode loadingIndicator?: ReactNode sessionKey?: string | null } function buildGroups(signature: string): MessageGroup[] { if (!signature) { return [] } const messages = signature.split('\n').map(row => { const [index, id, role] = row.split(':') return { id, index: Number(index), role } }) const groups: MessageGroup[] = [] for (let i = 0; i < messages.length; i++) { const message = messages[i] if (message.role !== 'user') { groups.push({ id: message.id, index: message.index, kind: 'standalone' }) continue } const indices = [message.index] while (i + 1 < messages.length && messages[i + 1].role !== 'user') { indices.push(messages[++i].index) } groups.push({ id: message.id, indices, kind: 'turn' }) } return groups } export const VirtualizedThread: FC = ({ clampToComposer, components, emptyPlaceholder, loadingIndicator, sessionKey }) => { const messageSignature = useAuiState(s => s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n') ) const groups = useMemo(() => buildGroups(messageSignature), [messageSignature]) const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder) const scrollerRef = useRef(null) const virtualizer = useVirtualizer({ count: groups.length, estimateSize: () => ESTIMATED_ITEM_HEIGHT, getItemKey: index => groups[index]?.id ?? index, getScrollElement: () => scrollerRef.current, // Seed the rect so the initial range mounts something before // `observeElementRect` reports the real layout (it overrides this). initialRect: { height: 600, width: 800 }, overscan: OVERSCAN }) useThreadScrollAnchor({ enabled: !renderEmpty, groupCount: groups.length, scrollerRef, sessionKey: sessionKey ?? null, virtualizer }) const virtualItems = virtualizer.getVirtualItems() const totalSize = virtualizer.getTotalSize() const paddingTop = virtualItems[0]?.start ?? 0 const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0)) return (
{renderEmpty ? (
{emptyPlaceholder}
) : (
{/* Natural-flow virtualization: mounted items render as normal flex siblings so `position: sticky` on the human bubble resolves against the scroller without transform interference. Padding spacers reserve scroll space for unmounted items. */}
{virtualItems.map(virtualItem => { const group = groups[virtualItem.index] if (!group) { return null } return (
{group.kind === 'turn' ? (
{group.indices.map(index => ( ))}
) : ( )}
) })}
{loadingIndicator} {clampToComposer && ( )}
) } interface ScrollAnchorOptions { enabled: boolean groupCount: number scrollerRef: React.RefObject sessionKey: string | null virtualizer: Virtualizer } function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, virtualizer }: ScrollAnchorOptions) { // `armed` = parked at bottom, content growth should follow. Cleared on // user-driven upward scroll; re-armed when they reach bottom again. const armedRef = useRef(true) const lastTopRef = useRef(0) // Counter that tracks how many scroll events we expect to be ours rather // than the user's. `pinToBottom` writes `el.scrollTop`, which fires an // async `scroll` event; without this guard the on-scroll handler can race // with the programmatic write (because content also grew, the *resulting* // scrollTop can be lower than `lastTopRef` from the previous frame) and // misread the programmatic pin as the user scrolling up — which disarms // sticky-bottom and the user's just-submitted message slides above the // fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro // (distFromBottom 0 → 49 within one frame, sticking forever). const programmaticScrollPendingRef = useRef(0) const prevSessionKeyRef = useRef(sessionKey) const prevGroupCountRef = useRef(0) // Track repins-in-a-row to break runaway loops during rapid layout churn. // In healthy paths this drains to zero between frames; we only need the // ceiling for pathological streaming bursts where content height keeps // growing every frame. const inFlightPinDepthRef = useRef(0) const pinToBottom = useCallback(() => { const el = scrollerRef.current if (!el) { return } // Hold the disarm gate across the scroll event the next line will fire. programmaticScrollPendingRef.current += 1 el.scrollTop = el.scrollHeight lastTopRef.current = el.scrollTop }, [scrollerRef]) const jumpToBottom = useCallback(() => { armedRef.current = true if (groupCount > 0) { virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' }) } requestAnimationFrame(() => { if (armedRef.current) { pinToBottom() } }) }, [groupCount, pinToBottom, virtualizer]) useEffect(() => () => setThreadScrolledUp(false), []) // Track at-bottom state, dim composer when scrolled up, disarm on user // scroll/wheel/touch. useEffect(() => { const el = scrollerRef.current if (!el) { return undefined } const disarm = () => { armedRef.current = false } const onScroll = () => { const top = el.scrollTop // If this scroll event is the consequence of `pinToBottom` writing // `el.scrollTop`, treat it as ours: never disarm, just consume the // gate. If we landed short of bottom (because content also grew in // the same frame and the browser clamped our scrollTop = scrollHeight // write to the now-stale scrollHeight - clientHeight), schedule // another pin on the next frame. Without this the post-pin scrollTop // gets misread as the user scrolling up, disarming sticky-bottom // permanently and leaving the just-submitted message below the fold. if (programmaticScrollPendingRef.current > 0) { programmaticScrollPendingRef.current -= 1 lastTopRef.current = top // Stay armed regardless — sticky-bottom should hold through clamp // races. armedRef.current = true const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD setThreadScrolledUp(!atBottom) if (atBottom) { inFlightPinDepthRef.current = 0 } else if (inFlightPinDepthRef.current < 8) { // Re-pin synchronously: the browser already laid out for this // scroll event, so reading scrollHeight now gives us the up-to-date // value and writing scrollTop lands us at the actual bottom in the // same frame. Doing this in a rAF causes a 1-frame visual flicker // (distFromBottom briefly nonzero), so we accept one extra // synchronous pin cycle (which goes back through this very // handler with the counter incremented and arm preserved). The // depth guard prevents pathological runaway loops if content // height keeps growing every frame; 8 is generous for any // realistic rendering pattern. inFlightPinDepthRef.current += 1 pinToBottom() } else { inFlightPinDepthRef.current = 0 } return } if (top + 1 < lastTopRef.current) { armedRef.current = false } lastTopRef.current = top const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD if (atBottom) { armedRef.current = true } setThreadScrolledUp(!atBottom) } const onWheel = (event: WheelEvent) => { if (event.deltaY < 0) { disarm() } } el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener('wheel', onWheel, { passive: true }) el.addEventListener('touchmove', disarm, { passive: true }) return () => { el.removeEventListener('scroll', onScroll) el.removeEventListener('wheel', onWheel) el.removeEventListener('touchmove', disarm) } }, [scrollerRef]) // Follow content growth (streaming, item measurements, loading indicator) // while armed. useEffect(() => { if (!enabled) { return undefined } const el = scrollerRef.current if (!el) { return undefined } const observer = new ResizeObserver(() => { if (armedRef.current) { pinToBottom() } }) observer.observe(el) if (el.firstElementChild) { observer.observe(el.firstElementChild) } return () => observer.disconnect() }, [enabled, pinToBottom, scrollerRef]) // Jump to bottom on session change OR when an empty thread first gets // content. Both share the same intent and the same effect. useEffect(() => { const sessionChanged = prevSessionKeyRef.current !== sessionKey const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0 prevSessionKeyRef.current = sessionKey prevGroupCountRef.current = groupCount if (enabled && (sessionChanged || becameNonEmpty)) { jumpToBottom() } }, [enabled, groupCount, jumpToBottom, sessionKey]) // Pre-paint pin: when groupCount increases while armed (optimistic user // message insert, streaming assistant turn arriving, etc.), pin BEFORE // the browser commits the layout to screen. Using useLayoutEffect rather // than useEffect so this runs synchronously after React commits the DOM // mutation but before the browser paints. Without this, there's a ~50ms // visual window where the new message sits below the fold while we wait // for the ResizeObserver / scroll event chain to fire and re-pin. const prevGroupCountForLayoutRef = useRef(groupCount) useLayoutEffect(() => { if (!enabled) { return } if (groupCount > prevGroupCountForLayoutRef.current && armedRef.current) { pinToBottom() } prevGroupCountForLayoutRef.current = groupCount }, [enabled, groupCount, pinToBottom]) useAuiEvent('thread.runStart', jumpToBottom) }