'use client'; import { memo, useMemo, type ReactNode } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@djangocfg/ui-core/components'; import { cn } from '@djangocfg/ui-core/lib'; import { MarkdownMessage, urlChipRule, type LinkRule } from '../../dev/code/MarkdownMessage'; import type { ChatAssistantContext, ChatAttachment, ChatMessage, ChatSource, ChatToolCall, ChatUserContext, } from '../types'; import { resolvePersona, deriveInitials } from '../core/persona'; import { useChatContextOptional } from '../context'; import { useChatBubbleStyles } from '../styles'; import { StreamingIndicator } from './StreamingIndicator'; import { Sources } from './Sources'; import { ToolCalls } from './ToolCalls'; import { AttachmentsGrid, AttachmentsList, type AttachmentRendererMap, } from './Attachments'; import { MessageActions } from './MessageActions'; import { BubbleContextMenu, buildDefaultBubbleMenuItems, useResolvedBubbleMenuItems, type BubbleMenuItem, } from './BubbleContextMenu'; import { MessageBlocks } from './blocks'; import { AutoLinkPreview } from './AutoLinkPreview'; import type { BlockRegistry } from './blocks'; import type { ToolCallsProps } from './ToolCalls'; import type { ComposerAppearance } from '../composer/types'; export interface MessageBubbleProps { message: ChatMessage; isUser?: boolean; showAvatar?: boolean; /** Override avatar URL (skips persona resolution). */ avatarSrc?: string; /** Override avatar fallback (skips persona resolution). */ avatarFallback?: ReactNode; /** Personas — when provided, take precedence over context. */ user?: ChatUserContext; assistant?: ChatAssistantContext; showTimestamp?: boolean; /** * Render the legacy under-bubble action button row (copy / regenerate / * edit / delete). Default `false` now — those actions live in the * right-click / long-press context menu (see `bubbleMenuItems`). Set * `true` to bring the hover button row back alongside the menu. */ showActions?: boolean; /** * Disable the right-click / long-press context menu entirely (e.g. a * read-only transcript). Default `false` — the menu is on. */ disableContextMenu?: boolean; /** * Host-injected EXTRA context-menu items, merged after the built-in * Copy / Regenerate / Delete defaults (a divider is inserted between). * Each carries its own typed `onSelect(message)` callback — e.g. * "Reveal in Finder", "Copy as markdown", "Inspect". Ignored when * `getBubbleMenuItems` is set (that gives the host full control). */ bubbleMenuItems?: BubbleMenuItem[]; /** * Full control over the bubble's context menu. Receives the message and * the built-in default items so the host can keep / reorder / drop / * extend them and return the final ordered list. Beats `bubbleMenuItems`. */ getBubbleMenuItems?: (message: ChatMessage, defaults: BubbleMenuItem[]) => BubbleMenuItem[]; isCompact?: boolean; /** Spaciousness of the bubble — `full` scales padding, text, avatar up * for full-page chat. `compact` (default) keeps the current geometry. */ appearance?: ComposerAppearance; className?: string; beforeContent?: ReactNode; afterContent?: ReactNode; toolCallsRenderer?: (calls: ChatToolCall[]) => ReactNode; /** Forwarded to the default `` when `toolCallsRenderer` is not set. */ toolCallsProps?: Omit; sourcesRenderer?: (sources: ChatSource[]) => ReactNode; attachmentsRenderer?: (atts: ChatAttachment[]) => ReactNode; /** Per-type attachment renderers forwarded to default ``. */ attachmentRenderers?: AttachmentRendererMap; /** * Registry of `kind` → renderer for `message.blocks`. Prop beats * context; both fall back to `BUILTIN_BLOCK_REGISTRY`. */ blockRegistry?: BlockRegistry; /** Click handler for attachment tiles (e.g. open lightbox). */ onAttachmentOpen?: (a: ChatAttachment) => void; onCopy?: () => void; onRegenerate?: () => void; onEdit?: () => void; onDelete?: () => void; /** * Extra content rendered alongside the default copy/regenerate/edit/ * delete actions (after them, on the same row). Hosts pass app- * specific affordances here — e.g. "Send to plan", "Add note", * "Open in inspector" — without having to fork ``. * Receives the message so renderers can branch by id / role / etc. * Plan64. */ messageActionsExtra?: (m: ChatMessage) => ReactNode; /** * Override the default streaming indicator (the dots / pulse + tool * activity label). Receives the message so the host can read * `toolActivity`, the running tool call name, etc., and render a * richer affordance ("Running cmd_execute on vps-audi…"). Plan64. * * Renders in two slots: as the in-bubble pre-token affordance, and * as the inline label above the bubble when `toolActivity` is set. * The render-prop is called for both — branch on `m.content` if * you need different behaviour per slot. */ streamingIndicator?: (m: ChatMessage) => ReactNode; /** * Render arbitrary content beneath the message body, regardless of * whether the message carries tool calls. Used for product widgets * that ride a side channel (e.g. `ui_payload` SSE frames driving * vehicle cards, tax tables, charts) and therefore can't piggy-back * on the `toolCallsRenderer` slot — the latter is gated on the * message having a non-empty `toolCalls` array, which doesn't hold * when raw tool events are hidden from public clients. * * Receives the message so the host can scope the widget by id / * role / timing. Renders even on streaming messages, so progressive * UI hints (skeletons that fill in as payloads arrive) work as * expected. */ renderAfterMessage?: (m: ChatMessage) => ReactNode; } /** Extra spaciousness layered on top of the default bubble geometry when * `appearance="full"`. Empty strings for `compact` keep every pixel of the * current geometry untouched (backward-compat). */ const APPEARANCE_CLASSES: Record = { compact: { row: '', avatar: '', avatarFallback: '', content: 'max-w-[34rem]', bubble: '', meta: '' }, full: { row: 'gap-4', avatar: 'size-10', avatarFallback: 'text-sm', content: 'max-w-[44rem]', // Roomy ChatGPT-style bubble: larger text with generous line-height // and padding so a full-page chat reads spacious, not just bigger. bubble: 'rounded-3xl px-5 py-3.5 text-base leading-relaxed', meta: 'text-xs', }, }; const MessageBubbleInner = ({ message, isUser: isUserProp, showAvatar = true, avatarSrc, avatarFallback, user, assistant, showTimestamp = false, showActions = false, disableContextMenu = false, bubbleMenuItems, getBubbleMenuItems, isCompact = false, appearance = 'compact', className, beforeContent, afterContent, toolCallsRenderer, toolCallsProps, sourcesRenderer, attachmentsRenderer, attachmentRenderers, blockRegistry, onAttachmentOpen, onCopy, onRegenerate, onEdit, onDelete, messageActionsExtra, streamingIndicator, renderAfterMessage, }: MessageBubbleProps) => { const isUser = isUserProp ?? message.role === 'user'; const isStreaming = !!message.isStreaming; const isErr = !!message.isError; const { surface: bubbleSurface } = useChatBubbleStyles( isUser ? 'user' : 'assistant', isErr, ); const ctx = useChatContextOptional(); // Prop beats context; `` falls back to the built-in // registry when both are absent. const resolvedBlockRegistry = blockRegistry ?? ctx?.blockRegistry; // Auto link-preview (Telegram-style) is rendered below the content via the // shared slot (so a forked bubble shares the exact same // detect+render and can't drift). See AutoLinkPreview.tsx. // URL chips (plan22): when the host opts in via `config.linkChips`, render // bare web URLs in the bubble as compact favicon chips (matching the // composer). The chip rule only claims bare `http(s)` autolinks; labeled // links keep their text. Appended last so any host rules would win. const linkRules = useMemo( () => (ctx?.config.linkChips ? [urlChipRule] : undefined), [ctx?.config.linkChips], ); const persona = resolvePersona( message, user ?? ctx?.config.user, assistant ?? ctx?.config.assistant, ); const initials = deriveInitials(persona, message.role); const personaName = persona.name ?? (isUser ? 'You' : 'Assistant'); // `appearance` scales the whole bubble (padding, text, avatar) so a // full-page chat reads like ChatGPT/Gemini. `compact` = no changes. const ap = APPEARANCE_CLASSES[appearance]; // Right-click / long-press menu. The built-in Copy / Regenerate / // Delete items wire to the same handlers the old button row used; // hosts merge extras via `bubbleMenuItems` or take full control via // `getBubbleMenuItems`. Suppressed while streaming (no actions yet) and // when the host opts out. Memoised so the menu doesn't rebuild every // token delta. const defaultMenuItems = useMemo( () => buildDefaultBubbleMenuItems({ role: message.role, onCopy, onRegenerate, // Edit — user bubbles only (gated inside the builder). Just fires // the host trigger; the host loads the text into the composer. onEdit, onDelete, }), [message.role, onCopy, onRegenerate, onEdit, onDelete], ); const menuItems = useResolvedBubbleMenuItems( message, defaultMenuItems, bubbleMenuItems, getBubbleMenuItems, ); const menuEnabled = !disableContextMenu && !isStreaming && menuItems.length > 0; return (
`) re-enables // `select-text`. Putting `select-text` on the bubble DIV (the // old approach) made the bubble's own padding selectable, so a // drag could ride out of one bubble's text and grab the next // bubble's top-padding strip — the two bubbles were one // contiguous selectable region. With the text island as the // ONLY selectable node, the range can't bridge the // non-selectable padding/gap between two messages. 'group/msg flex select-none gap-2.5 px-2.5 py-2', ap.row, isUser ? 'flex-row-reverse' : 'flex-row', className, )} > {showAvatar ? ( {avatarSrc || persona.avatarUrl ? ( ) : null} {avatarFallback ?? initials} ) : null}
{beforeContent} {message.attachments?.length ? attachmentsRenderer ? attachmentsRenderer(message.attachments) : (
{attachmentRenderers ? ( ) : ( )}
) : null}
` inside re-enables // `select-text` on the prose itself: the text is the only // selection island, bounded to this one message. 'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm', ap.bubble, bubbleSurface, )} > {isStreaming && message.toolActivity ? (
{streamingIndicator ? streamingIndicator(message) : }
) : null} {message.content || !isStreaming ? ( ) : ( streamingIndicator ? streamingIndicator(message) : )}
{message.blocks?.length ? ( ) : null} {message.toolCalls?.length ? toolCallsRenderer ? toolCallsRenderer(message.toolCalls) : : null} {renderAfterMessage ? renderAfterMessage(message) : null} {message.sources?.length && !isStreaming ? sourcesRenderer ? sourcesRenderer(message.sources) : : null} {showActions && !isStreaming ? (
{messageActionsExtra ? messageActionsExtra(message) : null}
) : null} {showTimestamp ? (
{new Date(message.createdAt).toLocaleTimeString()}
) : null} {afterContent}
); }; export const MessageBubble = memo(MessageBubbleInner, (prev, next) => { const a = prev.message; const b = next.message; return ( a.id === b.id && a.content === b.content && a.isStreaming === b.isStreaming && a.isError === b.isError && (a.version ?? 0) === (b.version ?? 0) && a.toolActivity === b.toolActivity && a.toolCalls === b.toolCalls && a.sources === b.sources && a.attachments === b.attachments && a.blocks === b.blocks && // Context-menu config — handlers/extras are expected to be stable // (useCallback / module const), so a reference change means the host // wants a different menu and we must re-render. prev.bubbleMenuItems === next.bubbleMenuItems && prev.getBubbleMenuItems === next.getBubbleMenuItems && prev.disableContextMenu === next.disableContextMenu && prev.showActions === next.showActions ); }); MessageBubble.displayName = 'MessageBubble';