// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file ChatMessageList.tsx * @input Uses React, StyleX, ChatListContext, theme tokens, spacing step utilities * @output Exports ChatMessageList component and ChatMessageListProps * @position Presentational message container — holds ChatMessage children * * Renders a container with role="log" for chat message histories. * Handles density context, configurable gap, empty state, * a configurable spacer (align) that pushes messages to the bottom, * and an infinite scroll sentinel. * * Auto-scroll and the scroll-to-bottom button are owned by * ChatLayout. When used standalone (without a layout), the list * is purely presentational — compose useChatStreamScroll yourself if needed. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Chat/index.ts (exports) * - /apps/storybook/stories/Chat.stories.tsx * - /packages/cli/assets/templates/blocks/components/ChatMessageList/ (block examples) */ import { type ReactNode, useEffect, useMemo, useRef, useTransition } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { spacingVars } from "../theme/tokens.stylex.js"; import { ChatListContext, type ChatDensity, useChatLayoutContext } from "./ChatContext.js"; import { mergeProps } from "../utils/index.js"; import { Spinner } from "../Spinner/index.js"; import type { BaseProps } from "../BaseProps.js"; import type { SpacingStep } from "../utils/types.js"; import { themeProps } from "../utils/themeProps.js"; export interface ChatMessageListProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Message elements — typically ChatMessage components. * Also accepts Divider (date separators) or any ReactNode. */ children: ReactNode; /** * Custom content when the list has no messages. */ emptyState?: ReactNode; /** * Async action when the user scrolls to the top. * Use for loading older messages. Wrapped in useTransition — * shows a spinner at the top while pending. */ scrollToTopAction?: () => Promise; /** * Visual density — flows to child messages via context. * Individual messages can override. * @default 'balanced' */ density?: ChatDensity; /** * Gap between top-level message rows, using the spacing scale. * Defaults to the selected density's gap. Override this when each * row is independent (for example, LLM event streams where messages cannot * be grouped) and row spacing should be tuned separately from density. */ gap?: SpacingStep; /** * Vertical alignment of messages when the list is shorter than its * container. * * - `'bottom'` (default): a spacer fills the free space and pushes * messages to the bottom, so a short conversation sits just above the * composer — the familiar messaging-app layout. * - `'top'`: the spacer is omitted, so messages start at the top and grow * downward — better for document-style or log-style lists. * * This only changes the resting position of a non-full list. Once messages * overflow the container the spacer collapses to zero in both modes, so * ChatLayout auto-scroll-to-bottom behavior is identical either way. * * @default 'bottom' */ align?: 'top' | 'bottom'; /** * Whether an assistant message is actively streaming into the list. * * The list is a `role="log"` / `aria-live="polite"` region, so while a * message streams in token-by-token, screen readers would otherwise * re-announce the accumulating partial text on every mutation. Set * `isStreaming` to `true` for the duration of a stream: it marks the log * `aria-busy="true"` so assistive tech waits and announces the completed * message once, when `isStreaming` returns to `false`. * * @default false */ isStreaming?: boolean; } // ============================================================================= // Styles // ============================================================================= const styles = { root: { k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kUk6DE: "x98rzlu", kAzted: "x2lwn1j", $$css: true as const }, inner: { k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kUk6DE: "x98rzlu", kAzted: "x2lwn1j", $$css: true as const }, gapCompact: { kOIVth: "x1txdalj", k8WAf4: "xce4md1", kg3NbH: "xrrkdod", $$css: true as const }, gapBalanced: { kOIVth: "x18g69wz", k8WAf4: "x1na6nto", kg3NbH: "x1pzlopt", $$css: true as const }, gapSpacious: { kOIVth: "x1qh66ti", k8WAf4: "xq6koh6", kg3NbH: "xm7rs69", $$css: true as const } }; const gapStyles = { "0": { kOIVth: "xsn7fz1", $$css: true as const }, "1": { kOIVth: "xzye2dw", $$css: true as const }, "2": { kOIVth: "x1txdalj", $$css: true as const }, "3": { kOIVth: "xjcht0a", $$css: true as const }, "4": { kOIVth: "x18g69wz", $$css: true as const }, "5": { kOIVth: "x9mgr7n", $$css: true as const }, "6": { kOIVth: "x1qh66ti", $$css: true as const }, "8": { kOIVth: "x4t41sb", $$css: true as const }, "10": { kOIVth: "x3hoi3v", $$css: true as const }, "0.5": { kOIVth: "x1lsbc85", $$css: true as const }, "1.5": { kOIVth: "x1s4dlld", $$css: true as const } }; // ============================================================================= // Component // ============================================================================= /** * Presentational container for chat messages. * * Renders messages in a flex column with density-based spacing. * Override gap to tune row spacing separately from density. * By default a spacer pushes content to the bottom when the list isn't full; * set `align='top'` to start messages at the top instead. * Supports loading older messages via `scrollToTopAction`. * * Auto-scroll and the scroll-to-bottom button are owned by * ChatLayout. Use useChatStreamScroll for standalone scroll control. * * @example * ``` * * }> * Hello! * * * ``` */ export function ChatMessageList({ children, emptyState, scrollToTopAction, density = 'balanced', gap, align = 'bottom', isStreaming = false, xstyle, className, style, 'data-testid': testId, ref, ...rest }: ChatMessageListProps) { const layoutContext = useChatLayoutContext(); const sentinelRef = useRef(null); const innerRef = useRef(null); const [isLoadingTop, startTransition] = useTransition(); // Register inner content element with the layout for height observation useEffect(() => { if (layoutContext?.contentRef && innerRef.current) { layoutContext.contentRef(innerRef.current); return () => layoutContext.contentRef(null); } }, [layoutContext]); const hasChildren = children != null && children !== false && !(Array.isArray(children) && children.length === 0); // IntersectionObserver for scroll-to-top infinite scroll useEffect(() => { const scrollContainer = layoutContext?.scrollContainerRef?.current; if (!scrollToTopAction || !sentinelRef.current) { return; } const observer = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting) { startTransition(async () => { await scrollToTopAction(); }); } }, { root: scrollContainer ?? null, threshold: 0 }); observer.observe(sentinelRef.current); return () => observer.disconnect(); }, [scrollToTopAction, layoutContext]); const contextValue = useMemo(() => ({ density }), [density]); const densityGapStyle = density === 'compact' ? styles.gapCompact : density === 'spacious' ? styles.gapSpacious : styles.gapBalanced; const gapOverrideStyle = gap == null ? null : gapStyles[gap]; return
{/* Sentinel for infinite scroll */} {scrollToTopAction &&
} {/* Loading spinner at top */} {isLoadingTop &&
} {/* Spacer pushes messages to bottom when the list isn't full. Omitted for top alignment so messages start at the top. */} {align === 'bottom' &&
} {/* Messages or empty state */} {hasChildren ? children : emptyState ?
{emptyState}
: null}
; } ChatMessageList.displayName = 'ChatMessageList';