// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file ChatLayout.tsx * @input Uses React, StyleX, theme tokens, useChatStreamScroll, useChatNewMessages * @output Exports ChatLayout component and ChatLayoutProps * @position Layout shell for full chat interfaces — messages in page flow, composer fixed to bottom * * Structural layout only — scroll behavior is delegated to hooks. * Provides the scroll container ref and content ref, renders the * scroll-to-bottom button, frosted glass dock, and message area. * * Layout contract: the root is a flex column. The message area flexes * (grow 1, shrink 0) to fill the space the dock doesn't need; the sticky * dock keeps its natural height in flow. Short content therefore fills * exactly 100% with no overflow, and long content grows past the root so * self-scroll mode scrolls (#2573). In external-scrollRef mode the dock * is position: fixed (out of flow) and the message area fills the root. * * Density (compact/balanced/spacious) is controlled via a prop with * 'balanced' as the default. No JS measurement or ResizeObserver needed. * The container-type on root enables container queries in child components. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Chat/index.ts (exports) * - /apps/storybook/stories/ChatLayout.stories.tsx * - /packages/cli/assets/templates/blocks/components/ChatLayout/ (block examples) */ import { type ReactNode, useMemo, useRef } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { spacingVars } from "../theme/tokens.stylex.js"; import type { BaseProps } from "../BaseProps.js"; import { mergeProps } from "../utils/index.js"; import { useChatStreamScroll } from "./useChatStreamScroll.js"; import { useChatNewMessages } from "./useChatNewMessages.js"; import { ChatLayoutScrollButton } from "./ChatLayoutScrollButton.js"; import { ChatLayoutContext } from "./ChatContext.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; // ============================================================================= // Types // ============================================================================= type Density = 'compact' | 'balanced' | 'spacious'; export interface ChatLayoutProps extends BaseProps { /** Ref forwarded to the root element. */ ref?: React.Ref; /** * Message content — flows naturally in the page, scrolls with the page. * Typically ChatMessageList with ChatMessage children. */ children: ReactNode; /** * Composer element — fixed to the bottom with a frosted glass dock. * Typically ChatComposer. */ composer: ReactNode; /** * Content shown when children is empty. */ emptyState?: ReactNode; /** * Scroll-to-bottom button rendered above the composer in the dock. * Defaults to ChatLayoutScrollButton with useChatStreamScroll. * Pass a custom ReactNode to override, or `null` to hide. */ scrollButton?: ReactNode | null; /** * External scroll container ref. When provided, auto-scroll and * scroll-to-bottom target this element instead of the layout root. * * @example * ``` * const scrollRef = useRef(document.documentElement); * ... * ``` * * When omitted, the layout root itself is the scroll container. */ scrollRef?: React.RefObject; /** * Layout density. Controls spacing, max-width, and blur layer sizing. * * @default 'balanced' */ density?: Density; } // ============================================================================= // Styles // ============================================================================= const styles = { root: { kVAEAm: "x1n2onr6", k9g6sI: "x12h1iku", kAzted: "x2lwn1j", kUk6DE: "x98rzlu", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", $$css: true as const }, rootScrollable: { kORKVm: "x1odjw0f", kXHlph: "x6ikm8r", k5wCbM: "x1mqgsch", $$css: true as const }, messageArea: { k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kUOVxO: "xvueqy4", kzQI83: "x1iyjqo2", kmuXW: "x2lah0s", kCS8Yb: "xdl72j9", kGO01o: "xjpqqx5", kzqmXN: "xh8yej3", ks0D6T: "x193iq5w", kg3NbH: "xnjsko4", $$css: true as const }, blurLayer: { kVAEAm: "x10l6tqk", krVfgx: "x1ey2m1c", kLqNvP: "x1o0tod", kt4wiu: "xtijo5x", kfzvcC: "x47corl", k6WDB: "xvn2z4z", keRtuK: "x67t5t6", kZKoxP: "xpyat2d", kX1K2I: "xw26lqc", kAExgp: "x1bv1mxo", $$css: true as const }, dock: { kVAEAm: "x1n2onr6", kY2c9j: "x1vjfegm", kfzvcC: "x67bb7w", kg3NbH: "xrrkdod", kGO01o: "xvmdzux", $$css: true as const }, dockInner: { kUOVxO: "xvueqy4", kzqmXN: "xh8yej3", ks0D6T: "x193iq5w", $$css: true as const }, messageAreaCompact: { ks0D6T: "x193iq5w", kg3NbH: "xnjsko4", $$css: true as const }, messageAreaBalanced: { ks0D6T: "x193iq5w", kg3NbH: "xnjsko4", $$css: true as const }, messageAreaSpacious: { ks0D6T: "x1ap80js", kg3NbH: "x1pzlopt", $$css: true as const }, blurLayerCompact: { kZKoxP: "xwzfr38", kX1K2I: "xkm2don", kAExgp: "xiy8rlo", $$css: true as const }, blurLayerBalanced: { kZKoxP: "xpyat2d", kX1K2I: "xw26lqc", kAExgp: "x1bv1mxo", $$css: true as const }, blurLayerSpacious: { kZKoxP: "x1wkxgih", kX1K2I: "xr5yg4x", kAExgp: "xo9xo30", $$css: true as const }, dockCompact: { kg3NbH: "xf314gf", kGO01o: "x1wesfrj", $$css: true as const }, dockBalanced: { kg3NbH: "xrrkdod", kGO01o: "xvmdzux", $$css: true as const }, dockSpacious: { kg3NbH: "x1pzlopt", kGO01o: "xvmdzux", $$css: true as const }, dockInnerCompact: { ks0D6T: "x193iq5w", $$css: true as const }, dockInnerBalanced: { ks0D6T: "x193iq5w", $$css: true as const }, dockInnerSpacious: { ks0D6T: "x1ap80js", $$css: true as const } }; // ============================================================================= // Helpers // ============================================================================= function hasVisibleContent(children: ReactNode): boolean { if (children == null || children === false) { return false; } if (Array.isArray(children) && children.length === 0) { return false; } return true; } // ============================================================================= // Component // ============================================================================= export function ChatLayout({ children, composer, density = 'balanced', emptyState, scrollButton, scrollRef: externalScrollRef, xstyle, className, style, 'data-testid': testId, ref, ...rest }: ChatLayoutProps) { const t = useTranslator(); const rootRef = useRef(null); const scrollContainerRef = externalScrollRef ?? rootRef; const isSelfScrolling = !externalScrollRef; // --- Default scroll behavior --- const scroll = useChatStreamScroll({ scrollRef: scrollContainerRef }); const newMsgs = useChatNewMessages({ isLocked: scroll.isLocked, onResize: scroll.scrollIfLocked }); const defaultScrollButton = { newMsgs.dismiss(); scroll.scrollToBottom(); }} />; // --- Layout context --- const layoutContext = useMemo(() => ({ scrollContainerRef, contentRef: newMsgs.contentRef }), [scrollContainerRef, newMsgs.contentRef]); // --- Derived styles --- const showEmpty = !hasVisibleContent(children); const densityStyles = { compact: { messageArea: styles.messageAreaCompact, blurLayer: styles.blurLayerCompact, dock: styles.dockCompact, dockInner: styles.dockInnerCompact }, balanced: { messageArea: styles.messageAreaBalanced, blurLayer: styles.blurLayerBalanced, dock: styles.dockBalanced, dockInner: styles.dockInnerBalanced }, spacious: { messageArea: styles.messageAreaSpacious, blurLayer: styles.blurLayerSpacious, dock: styles.dockSpacious, dockInner: styles.dockInnerSpacious } } as const; const currentDensity = densityStyles[density]; return
{/* Message area */}
{showEmpty && emptyState ?
{emptyState}
: children}
{/* Dock container — sticky/fixed, holds blur + scroll button + composer */}
{/* Scroll-to-bottom button */} {scrollButton === undefined ? defaultScrollButton : scrollButton} {/* Frosted glass layer */}
{/* Composer */}
{composer}
; } ChatLayout.displayName = 'ChatLayout';