'use client'; import { type ReactNode, useMemo, useRef, useState } from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import type { ChatAttachment, ChatConfig, ChatMessage, ChatTransport } from '../types'; import type { ChatAudioConfig } from '../core/audio/types'; import { ChatProvider, useChatContext, useChatContextOptional, type ChatContextValue, } from '../context'; import { useChatComposer, type UseChatComposerReturn } from '../hooks/useChatComposer'; import { useFocusOnEmptyClick } from '../hooks/useFocusOnEmptyClick'; import { Composer } from '../composer/Composer'; import type { ComposerAppearance, ComposerFooterProps, ComposerLayout, ComposerSize, ComposerSlots, } from '../composer/types'; import { EmptyState } from './EmptyState'; import { ErrorBanner } from './ErrorBanner'; import { JumpToLatest } from '../messages/JumpToLatest'; import { MessageBubble } from '../messages/MessageBubble'; import type { BubbleMenuItem } from '../messages/BubbleContextMenu'; import { MessageList, type MessageListHandle } from '../messages/MessageList'; import type { AttachmentRendererMap } from '../messages/Attachments'; import type { BlockRegistry } from '../messages/blocks'; import type { ToolCallsProps } from '../messages/ToolCalls'; /** Empty-state render API — seeds + focuses the composer from prompt chips. */ export interface ChatEmptyApi { setValue: (v: string) => void; focus: () => void; } /** Full-replacement render API for the composer slot — the live composer * hook plus the resolved {@link ChatComposerConfig} that produced it. * One source of truth; no hand-mirrored field list. */ export interface ChatComposerRenderApi { composer: UseChatComposerReturn; config: ChatComposerConfig; } /** Composer-region configuration for ``. Groups * every knob that forwards into the built-in ``. */ export interface ChatComposerConfig { /** Visual size variant. Default `md`. */ size?: ComposerSize; /** Input-surface layout. Default `stacked` (`sm` → `inline`). */ layout?: ComposerLayout; /** Extra className on the `` wrapper. */ className?: string; /** Hide the composer entirely (e.g. while waiting for human approval). */ hidden?: boolean; /** Show the paperclip "attach" button. */ showAttachmentButton?: boolean; /** Called when the user clicks the attach button. */ onPickFiles?: () => void; /** Tier A declarative slots — action clusters + raw-node escape hatches. */ slots?: ComposerSlots; /** Footer toolbar below the input surface. `false` hides it. */ footer?: ComposerFooterProps | false; /** Full replacement — receives the live composer hook + this config. */ render?: (api: ChatComposerRenderApi) => ReactNode; } /** Named ReactNode slots for ``. The `header` / `empty` * slots accept either a node or a render fn — no separate `renderX` prop. */ export interface ChatSlots { /** Sticky banner above the message list (e.g. quota warning). */ banner?: ReactNode; /** Header row below the banner — node, or a fn given the chat context. */ header?: ReactNode | ((ctx: ChatContextValue) => ReactNode); /** Replaces the default `` — node, or a fn given a seed API. */ empty?: ReactNode | ((api: ChatEmptyApi) => ReactNode); /** Replaces the default `` floating pill. */ jumpToLatest?: ReactNode; } /** Message-rendering configuration for ``. */ export interface ChatMessagesConfig { /** Replace `` per message. */ render?: (m: ChatMessage, i: number) => ReactNode; /** * Render arbitrary content beneath every default `` * (not invoked when `render` is set — the host owns layout then). */ renderAfter?: (m: ChatMessage) => ReactNode; /** Forwarded into `` to swap payload renderers. */ toolCallsProps?: Omit; /** Per-type attachment renderers — `{ image, audio, video, file, default }`. */ attachmentRenderers?: AttachmentRendererMap; /** Called when an attachment tile is clicked (e.g. open lightbox). */ onAttachmentOpen?: (attachment: ChatAttachment) => void; /** * Registry of `kind` → renderer for `message.blocks`. Merge host * overrides over the defaults with `createBlockRegistry({ … })`. * Omit to use `BUILTIN_BLOCK_REGISTRY`. */ blockRegistry?: BlockRegistry; /** * Host-injected EXTRA right-click / long-press menu items per bubble, * merged after the built-in Copy / Regenerate / Delete defaults. * Forwarded to ``. */ bubbleMenuItems?: BubbleMenuItem[]; /** * Full control over each bubble's context menu — receives the message * and the built-in defaults, returns the final ordered list. Beats * `bubbleMenuItems`. Forwarded to ``. */ getBubbleMenuItems?: (message: ChatMessage, defaults: BubbleMenuItem[]) => BubbleMenuItem[]; /** * Host handler for the bubble "Edit" menu item (user messages only — the * builtin builder user-gates it). The host loads the message's content into * its composer for editing and re-runs the turn on submit via * `chat.editMessage` (engine truncate+resend). When omitted the "Edit" item * never appears. This is intentionally a host hook, not an inline editor: the * edit happens in the real composer, not the bubble. */ onEdit?: (message: ChatMessage) => void; } export interface ChatRootProps { // ---- core wiring ------------------------------------------------------- /** * Transport. Required UNLESS `` is rendered inside an * existing `` (e.g. mounted by ``), in * which case the ambient provider is reused and `transport` is * ignored. */ transport?: ChatTransport; config?: ChatConfig; /** Session wiring — pre-existing id to attach, auto-create toggle. */ session?: { initialId?: string; autoCreate?: boolean }; streaming?: boolean; /** Audio-trigger configuration. Off by default (no `sounds` map). */ audio?: ChatAudioConfig; /** * Verbose dev-mode logging via `consola` (namespace `chat:*`). * Defaults to `isDev` from `@djangocfg/ui-core/lib`. */ debug?: boolean; // ---- presentation ------------------------------------------------------ /** Spaciousness of the whole chat — scales bubbles + composer. * `compact` (default) for embedded chat, `full` for full-page. */ appearance?: ComposerAppearance; /** Extra className on the `` wrapper. */ className?: string; /** Extra className forwarded to the `` scroll container. */ listClassName?: string; // ---- composition (grouped) -------------------------------------------- /** Named ReactNode slots — banner, header, empty, jumpToLatest. */ slots?: ChatSlots; /** Composer-region configuration. */ composer?: ChatComposerConfig; /** Message-rendering configuration. */ messages?: ChatMessagesConfig; // ---- behavior ---------------------------------------------------------- /** * Click in the message area → focus the composer (Slack / ChatGPT). * @default true */ focusOnEmptyClick?: boolean; /** * Contribute extra transport metadata, computed fresh per send. * Forwarded to the `` this component creates — ignored * when mounted under an ambient provider (set it on that provider * instead). Used to attach the page-context snapshot. */ getDynamicMetadata?: () => Record | undefined; } export function ChatRoot(props: ChatRootProps) { const { transport, config, session, streaming, audio, debug, className, listClassName, getDynamicMetadata, ...rest } = props; // When mounted under a launcher-owned ``, reuse that // provider instead of wrapping in a second one. This lets host code // freely nest `` inside `` without losing // `headerSlots` access to the same session / clearMessages / audio. const ambient = useChatContextOptional(); if (ambient) { return ; } if (!transport) { throw new Error( ' requires `transport` when mounted outside a .', ); } return ( ); } type ChatRootParts = Pick< ChatRootProps, 'appearance' | 'slots' | 'composer' | 'messages' | 'focusOnEmptyClick' >; interface ChatRootShellProps { className?: string; listClassName?: string; parts: ChatRootParts; } function ChatRootShell({ className, listClassName, parts }: ChatRootShellProps) { const { slots = {}, composer: composerConfig = {}, messages = {} } = parts; const chat = useChatContext(); const composer = useChatComposer({ onSubmit: (content, attachments) => chat.sendMessage(content, attachments), disabled: chat.isStreaming, }); const onMessagesMouseUp = useFocusOnEmptyClick({ enabled: parts.focusOnEmptyClick !== false, }); // Stream-end composer re-focus is handled by `` — it // covers every usage pattern, not just `ChatRoot`. // MessageList (virtuoso) owns the scroll viewport. We talk to it via // the imperative handle (scrollToBottom) and the `onAtBottomChange` // callback (drives the pill). const listRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); // The id of the most-recent user-sent message — passed as // `scrollAnchorId` so every send re-anchors the viewport. const lastUserMessageId = useMemo(() => { const msgs = chat.messages; for (let i = msgs.length - 1; i >= 0; i -= 1) { if (msgs[i].role === 'user') return msgs[i].id; } return null; }, [chat.messages]); const handleStartReached = chat.hasMore && !chat.isLoadingMore ? () => void chat.loadMore() : undefined; const greeting = chat.config.greeting ?? 'How can I help?'; const description = chat.config.description; const suggestions = chat.config.suggestions; const headerNode = typeof slots.header === 'function' ? slots.header(chat) : slots.header; const emptyNode = typeof slots.empty === 'function' ? slots.empty({ setValue: composer.setValue, focus: composer.focus }) : (slots.empty ?? ( { composer.setValue(prompt); composer.focus(); }} /> )); // `appearance` scales the whole chat (bubbles + composer). Default // `compact` keeps every embedded chat pixel-identical. const appearance: ComposerAppearance = parts.appearance ?? 'compact'; const renderItem = messages.render ?? ((m: ChatMessage) => ( copy(m.content)} onRegenerate={() => void chat.regenerate(m.id)} // Edit (user bubbles): hand the message to the host so it can load the // text into the real composer. The host owns the edit-mode UI + the // submit branch (chat.editMessage). Omitted handler → no Edit item. // No in-bubble editor: editing happens in the composer, not the bubble. onEdit={messages.onEdit ? () => messages.onEdit?.(m) : undefined} onDelete={() => chat.deleteMessage(m.id)} /> )); return (
{slots.banner ?? null} {headerNode ?? null}
chat.clearMessages() : undefined} onRetry={chat.error ? () => void chat.regenerate() : undefined} /> <>{emptyNode}} className={listClassName} onStartReached={handleStartReached} onAtBottomChange={setIsAtBottom} scrollAnchorId={lastUserMessageId} />
{slots.jumpToLatest ?? ( listRef.current?.scrollToBottom(true)} /> )}
{!composerConfig.hidden && ( composerConfig.render ? composerConfig.render({ composer, config: composerConfig }) : ( ) )}
); } function copy(text: string) { if (typeof navigator !== 'undefined' && navigator.clipboard) { void navigator.clipboard.writeText(text); } } // re-export for convenience: composer hook return is a common slot dependency export type { UseChatComposerReturn };