"use client"; import { useComposedRefs } from "@radix-ui/react-compose-refs"; import { Primitive } from "../../utils/Primitive"; import { type ComponentRef, forwardRef, type ComponentPropsWithoutRef, useCallback, useLayoutEffect, useMemo, } from "react"; import { useAuiEvent, useAuiState } from "@assistant-ui/store"; import { useManagedRef } from "../../utils/hooks/useManagedRef"; import { useThreadViewportAutoScroll } from "./useThreadViewportAutoScroll"; import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider"; import { useSizeHandle } from "../../utils/hooks/useSizeHandle"; import { useThreadViewport, useThreadViewportStore, } from "../../context/react/ThreadViewportContext"; import { useTopAnchorReserve } from "./topAnchor/useTopAnchorReserve"; import { getActiveTopAnchorAnchorId, getActiveTopAnchorTargetId, } from "./topAnchor/topAnchorTurn"; export namespace ThreadPrimitiveViewport { export type Element = ComponentRef; export type Props = ComponentPropsWithoutRef & { /** * Whether to automatically scroll to the bottom when new messages are added. * When enabled, the viewport will automatically scroll to show the latest content. * * Default false if `turnAnchor` is "top", otherwise defaults to true. */ autoScroll?: boolean | undefined; /** * Controls scroll anchoring behavior for new messages. * - "bottom" (default): Messages anchor at the bottom, classic chat behavior. * - "top": New user messages anchor at the top of the viewport for a focused reading experience. */ turnAnchor?: "top" | "bottom" | undefined; /** * Clamps tall user messages so the assistant response stays in view. * * @default { tallerThan: "10em", visibleHeight: "6em" } */ topAnchorMessageClamp?: { /** * Clamp messages taller than this. Supports `px`, `em`, and `rem`. * * @default "10em" */ tallerThan?: string; /** * Visible portion of clamped messages. Supports `px`, `em`, and `rem`. * * @default "6em" */ visibleHeight?: string; }; /** * Whether to scroll to bottom when a new run starts. * * Defaults to true. */ scrollToBottomOnRunStart?: boolean | undefined; /** * Whether to scroll to bottom when thread history is first loaded. * * Defaults to true. */ scrollToBottomOnInitialize?: boolean | undefined; /** * Whether to scroll to bottom when switching to a different thread. * * Defaults to true. */ scrollToBottomOnThreadSwitch?: boolean | undefined; }; } const useViewportSizeRef = () => { const register = useThreadViewport((s) => s.registerViewport); const getHeight = useCallback((el: HTMLElement) => el.clientHeight, []); return useSizeHandle(register, getHeight); }; const useViewportElementRef = () => { const registerViewportElement = useThreadViewport( (s) => s.registerViewportElement, ); return useManagedRef(registerViewportElement); }; const useTopAnchorTurn = (enabled: boolean) => { const threadViewportStore = useThreadViewportStore(); const activeAnchorId = useAuiState((s) => { if (!enabled) return undefined; return getActiveTopAnchorAnchorId(s.thread); }); const activeTargetId = useAuiState((s) => { if (!enabled) return undefined; return getActiveTopAnchorTargetId(s.thread); }); const activeTurn = useMemo(() => { if (!activeAnchorId || !activeTargetId) return null; return { anchorId: activeAnchorId, targetId: activeTargetId }; }, [activeAnchorId, activeTargetId]); useLayoutEffect(() => { if (!activeTurn) return; const state = threadViewportStore.getState(); const current = state.topAnchorTurn; if ( current?.anchorId === activeTurn.anchorId && current.targetId === activeTurn.targetId ) { return; } state.setTopAnchorTurn(activeTurn); }, [activeTurn, threadViewportStore]); const clearTopAnchorTurn = useCallback(() => { threadViewportStore.getState().setTopAnchorTurn(null); }, [threadViewportStore]); useAuiEvent("thread.initialize", clearTopAnchorTurn); useAuiEvent("threadListItem.switchedTo", clearTopAnchorTurn); }; const ThreadPrimitiveViewportScrollable = forwardRef< ThreadPrimitiveViewport.Element, ThreadPrimitiveViewport.Props >( ( { autoScroll, scrollToBottomOnRunStart, scrollToBottomOnInitialize, scrollToBottomOnThreadSwitch, children, ...rest }, forwardedRef, ) => { const autoScrollRef = useThreadViewportAutoScroll({ autoScroll, scrollToBottomOnRunStart, scrollToBottomOnInitialize, scrollToBottomOnThreadSwitch, }); const viewportSizeRef = useViewportSizeRef(); const viewportElementRef = useViewportElementRef(); const threadViewportStore = useThreadViewportStore(); const turnAnchor = threadViewportStore.getState().turnAnchor; const topAnchorEnabled = turnAnchor === "top"; useTopAnchorTurn(topAnchorEnabled); useTopAnchorReserve(topAnchorEnabled); const ref = useComposedRefs( forwardedRef, autoScrollRef, viewportSizeRef, viewportElementRef, ); return ( {children} ); }, ); ThreadPrimitiveViewportScrollable.displayName = "ThreadPrimitive.ViewportScrollable"; /** * A scrollable viewport container for thread messages. * * This component provides a scrollable area for displaying thread messages with * automatic scrolling capabilities. It manages the viewport state and provides * context for child components to access viewport-related functionality. * * @example * ```tsx * * * {() => } * * * ``` */ export const ThreadPrimitiveViewport = forwardRef< ThreadPrimitiveViewport.Element, ThreadPrimitiveViewport.Props >(({ turnAnchor, topAnchorMessageClamp, ...props }, ref) => { return ( ); }); ThreadPrimitiveViewport.displayName = "ThreadPrimitive.Viewport";