'use client'; import { type ReactNode, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { cn } from '@djangocfg/ui-core/lib'; import { Spinner } from '@djangocfg/ui-core/components'; import { useCopy } from '@djangocfg/ui-core/hooks'; import type { ChatMessage } from '../types'; import { useChatContextOptional } from '../context'; import { MessageBubble } from './MessageBubble'; import type { ComposerAppearance } from '../composer/types'; export interface MessageListProps { messages?: ChatMessage[]; renderItem?: (m: ChatMessage, i: number) => ReactNode; renderEmpty?: () => ReactNode; isLoadingMore?: boolean; /** * Fires when the user scrolls within `topThresholdPx` of the top of * the list — wire to your `loadMore()` here. Replaces the previous * `topSentinelRef` API. The callback is gated by Virtuoso, so it * won't fire repeatedly while a load is in flight (Virtuoso pauses * `startReached` until `data` length grows). */ onStartReached?: () => void; className?: string; itemClassName?: string; /** * Skip virtualization and render through plain `.map()`. Use for * stories / debugging where DevTools needs to see every node, or * when `messages` is guaranteed-tiny. Default: `false` — virtualize * always. Plan64. */ noVirtualize?: boolean; /** * Fires when the viewport sticky state changes — `true` when the * user is pinned to the bottom (streaming token deltas keep them * there), `false` once they scroll up. Wire to your "Jump to * latest" affordance via the inverse: render the pill when * `!isAtBottom`. Plan64. */ onAtBottomChange?: (isAtBottom: boolean) => void; /** * Pixel distance from the bottom that still counts as "at bottom". * Default 120 — generous enough that mid-message users keep getting * sticky-followed (matches ChatGPT / Slack feel) while still letting * a deliberate scroll-up break the lock. Plan11. */ atBottomThreshold?: number; /** * Force-scroll to the bottom when this id changes. Wire to the id of * the most-recently-sent user message: every send re-anchors the * viewport so users always see their own bubble + the incoming * reply, even if they were scrolled up. Plan11. */ scrollAnchorId?: string | number | null; /** Spaciousness of the default ``s — `full` scales the * whole bubble up for full-page chat. Default `compact` (no change). * Ignored when `renderItem` is set (the host owns bubble rendering). */ appearance?: ComposerAppearance; } export interface MessageListHandle { scrollToBottom: (smooth?: boolean) => void; scrollToIndex: (index: number, smooth?: boolean) => void; } // Breathing room under the last bubble: a spacer rendered as the // Virtuoso `Footer` keeps the final message from sitting flush against // the composer, and the same value is fed as a scroll `offset` so every // "land at bottom" path settles with this gap visible instead of pinning // the last bubble to the very edge. Tasteful — one comfortable notch. const BOTTOM_GAP_PX = 24; // Stable Footer spacer — reserves `BOTTOM_GAP_PX` of scrollable space // below the last bubble. Rendered inside the virtualized scroller so it // counts toward content height (followOutput scrolls past it) without // shifting the viewport when items mount/unmount. const FooterSpacer = () =>
; export const MessageList = forwardRef(function MessageList( { messages: messagesProp, renderItem, renderEmpty, isLoadingMore: isLoadingMoreProp, onStartReached, className, itemClassName, noVirtualize = false, onAtBottomChange, atBottomThreshold = 120, scrollAnchorId, appearance = 'compact', }, ref, ) { const ctx = useChatContextOptional(); const messages = messagesProp ?? ctx?.messages ?? []; const isLoadingMore = isLoadingMoreProp ?? ctx?.isLoadingMore ?? false; const { copyToClipboard } = useCopy(); const virtuosoRef = useRef(null); // Virtuoso's `atBottomStateChange` only fires when the list is // scrollable AND the user actually scrolls. With ≤3 short bubbles the // total height stays below the viewport, virtuoso never fires "at // bottom", and the `` pill stays stuck. Track the // scroller directly: when content fits, force `atBottom=true`. const scrollerRef = useRef(null); const [isScrollable, setIsScrollable] = useState(false); const lastReportedAtBottomRef = useRef(null); // Mirror of the latest "is the user following" state, readable from // the last-item ResizeObserver without re-subscribing it on every // sticky-state flip. This is the ONLY gate for sticky-bottom: we // follow because the user is at/near bottom, never because of message // length. The moment they scroll up, this flips false and we stop // re-pinning (the pill takes over). const isFollowingRef = useRef(true); const reportAtBottom = useCallback( (value: boolean) => { isFollowingRef.current = value; if (lastReportedAtBottomRef.current === value) return; lastReportedAtBottomRef.current = value; onAtBottomChange?.(value); }, [onAtBottomChange], ); // Pin the viewport to the absolute bottom. Used both by the last-item // ResizeObserver (to correct virtuoso's `followOutput:'auto'`, which // settles a few px short on the final streamed chunk) and any other // "land exactly at end" path. const pinToBottom = useCallback((smooth = false) => { virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', // Scroll past the last bubble by the footer gap so it lands with // breathing room above the composer, not flush against the edge. offset: BOTTOM_GAP_PX, behavior: smooth ? 'smooth' : 'auto', }); }, []); // Track whether we've already landed on the bottom for the initial // history. Virtuoso's `initialTopMostItemIndex` only fires on first // mount and uses the `messages` length at that moment. Chats almost // always mount with `messages=[]` and then receive history one // dispatch later — so we ALSO fire an imperative scroll when the // first non-empty batch arrives. Subsequent updates fall through to // `followOutput` (sticky-bottom while the user is at bottom). const didInitialScrollRef = useRef(false); useImperativeHandle( ref, () => ({ scrollToBottom: (smooth = false) => { pinToBottom(smooth); }, scrollToIndex: (index, smooth = false) => { virtuosoRef.current?.scrollToIndex({ index, behavior: smooth ? 'smooth' : 'auto', }); }, }), [pinToBottom], ); const defaultRenderItem = useCallback( (m: ChatMessage) => (
void copyToClipboard(m.content)} onRegenerate={ctx ? () => void ctx.regenerate(m.id) : undefined} onDelete={ctx ? () => ctx.deleteMessage(m.id) : undefined} />
), [itemClassName, ctx, copyToClipboard, appearance], ); const itemRenderer = renderItem ?? defaultRenderItem; // Jump to bottom on the first non-empty messages batch. Wrapped in // rAF so virtuoso has measured the new items before we ask it to // scroll — otherwise the call happens while the list is still // mid-layout and lands on the wrong offset. useEffect(() => { if (didInitialScrollRef.current) return; if (messages.length === 0) return; didInitialScrollRef.current = true; const id = requestAnimationFrame(() => { virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', offset: BOTTOM_GAP_PX, behavior: 'auto', }); }); return () => cancelAnimationFrame(id); }, [messages.length]); // Force-scroll to bottom whenever the consumer bumps `scrollAnchorId` // (typically the id of the latest user-sent message). Two rAFs so // Virtuoso has measured the freshly-pushed bubble before we land, // otherwise the call lands on the previous height and clips the new // message under the composer. The initial-mount effect above handles // first paint, so we skip until it has run. useEffect(() => { if (scrollAnchorId == null) return; if (!didInitialScrollRef.current) return; let raf1 = 0; let raf2 = 0; raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => { virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', offset: BOTTOM_GAP_PX, behavior: 'smooth', }); }); }); return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; }, [scrollAnchorId]); // Sticky-bottom corrector. Virtuoso's `followOutput:'auto'` keeps the // list near the bottom while content grows token-by-token, but on the // FINAL streamed chunk (and any late reflow — images loading, code // blocks measuring) it can settle a few px short: no further scroll // event arrives to nudge it the last stretch, so a short reply lands // slightly under-scrolled. We observe ONLY the last bubble's size and, // while the user is following (`isFollowingRef`), re-pin to the // absolute bottom on every size change. Cheap: one observer, last item // only, no work once the user scrolls up. const lastItemElRef = useRef(null); const lastItemRoRef = useRef(null); const observeLastItem = useCallback( (el: HTMLElement | null) => { if (el === lastItemElRef.current) return; lastItemRoRef.current?.disconnect(); lastItemElRef.current = el; if (!el) return; const ro = new ResizeObserver(() => { // Only stick when the user hasn't scrolled away. This is the // universal ChatGPT rule — follow = user at bottom, regardless // of how long the message is. if (isFollowingRef.current) pinToBottom(false); }); ro.observe(el); lastItemRoRef.current = ro; }, [pinToBottom], ); useEffect( () => () => { lastItemRoRef.current?.disconnect(); lastItemRoRef.current = null; lastItemElRef.current = null; }, [], ); // Watch the scroll container: when content fits (scrollHeight ≤ clientHeight) // there's nothing to scroll, the user is by definition "at bottom", and the // Jump-to-latest pill must stay hidden regardless of what virtuoso reports. useEffect(() => { const el = scrollerRef.current; if (!el || el === window || !(el instanceof HTMLElement)) return; const probe = () => { const scrollable = el.scrollHeight > el.clientHeight + 1; setIsScrollable(scrollable); if (!scrollable) reportAtBottom(true); }; probe(); const ro = new ResizeObserver(probe); ro.observe(el); return () => ro.disconnect(); }, [reportAtBottom, messages.length]); // Virtuoso may invoke `computeItemKey` for an index briefly out of // sync with `data` during fast state churn (streaming chunks + // Strict Mode double-mount). `m` arrives undefined in that window. // Falling back to the index keeps the lookup stable instead of // crashing with `undefined.id` — the next pass with the real item // re-keys it correctly. See react-virtuoso issue #532 / #1045. const computeItemKey = useCallback( (index: number, m: ChatMessage | undefined) => m?.id ?? index, [], ); // Wrap user-supplied onStartReached so we don't double-fire while a // load is already in flight. Virtuoso pauses startReached until // `data` grows, but our consumers pass `onStartReached={loadMore}` // directly — `loadMore` sets `isLoadingMore=true` synchronously, so // we can suppress further calls until that flag drops back to false. const startReachedHandler = useMemo(() => { if (!onStartReached) return undefined; let inFlight = false; return () => { if (inFlight || isLoadingMore) return; inFlight = true; try { onStartReached(); } finally { // Release on the next tick — Virtuoso re-fires startReached // only after data length changes, so the inFlight guard is // belt+suspenders against same-frame double calls. queueMicrotask(() => { inFlight = false; }); } }; }, [onStartReached, isLoadingMore]); // Empty path — render renderEmpty instead of the virtualizer to avoid // a blank Virtuoso frame and the cost of mounting it for nothing. if (messages.length === 0) { return (
{renderEmpty?.() ?? null}
); } if (noVirtualize) { return (
{isLoadingMore ? (
) : null} {messages.map((m, i) => (
{itemRenderer(m, i)}
))} {/* Mirror the virtualized Footer spacer so the last bubble keeps its breathing room above the composer in the non-virtual path. */}
); } return ( { if (!m) return null; const node = itemRenderer(m, index); // Wrap only the last bubble in a size-observed shell so we can // re-pin to the absolute bottom as the streaming reply grows // (corrects virtuoso's slight final-chunk under-scroll). The // wrapper is `display:contents` so it adds no layout box. if (index === messages.length - 1) { return
{node}
; } return node; }} // No `defaultItemHeight` — Virtuoso uses its first-item probe // pass. We previously fed it `120` which was wildly low for // chat bubbles (markdown + code blocks reach 500px+) and forced // virtuoso into a measure/reshape loop on every render: predicted // height << real height → page recompute → measure → repeat, // visible as jittery scrolling and bubbles "blinking" in and out. // The probe pass costs one extra render on mount; that's cheaper // than perpetual reshape. // // No `alignToBottom` — it only matters when the list is shorter // than the viewport (centers items near the bottom). Combined // with dynamic bubble heights it triggered the same measure loop: // virtuoso recomputes top padding on every size change to keep // the cluster bottom-aligned. `initialTopMostItemIndex` + the // mount-time imperative scroll already land us at the bottom on // open, which is what users actually want. // // No `increaseViewportBy` overscan — virtuoso's default (~0px) // is the right call for chat: every overscanned bubble re-renders // on every streaming token delta, so a 400px buffer means 3–4 // extra bubbles re-rendering at 60Hz during a stream. Default // keeps the working set tight. initialTopMostItemIndex={messages.length > 0 ? messages.length - 1 : 0} atBottomThreshold={atBottomThreshold} followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)} scrollerRef={(el) => { scrollerRef.current = el; }} atBottomStateChange={(atBottom) => { // Force `true` when the list isn't scrollable — virtuoso's signal // can stall in `false` on short transcripts (no scroll events fire). reportAtBottom(!isScrollable ? true : atBottom); }} startReached={startReachedHandler} // Spinner while older history is loading. Rendering it as the // Header keeps it inside the virtualized scroll, so it doesn't // shift the viewport when it appears/disappears. // // Always pass an object — virtuoso indexes `components[name]` // internally without a null-guard, so passing `undefined` here // crashes with `d[l]` on any render where the empty-defaults // path runs (regression: ui-tools 2.1.369 first ship). // Footer always carries the bottom-gap spacer so the last bubble // never sits flush against the composer; Header only appears while // older history is loading. components={isLoadingMore ? COMPONENTS_WITH_HEADER : FOOTER_ONLY_COMPONENTS} /> ); }); // Header spinner for the loading-older-history state. const LoadingHeader = () => (
); // Stable component maps — virtuoso indexes `components[name]` without a // null-guard, so we always pass a concrete object. Both carry the // FooterSpacer; the loading variant adds the history spinner Header. const FOOTER_ONLY_COMPONENTS = { Footer: FooterSpacer } as const; const COMPONENTS_WITH_HEADER = { Header: LoadingHeader, Footer: FooterSpacer } as const;