'use client'; /** * `` — walks `message.blocks`, dispatches each through the * registry, and renders it under the bubble (text/markdown in a bubble * wrapper, media full-width). Per-block error isolation + unknown-kind * fallback keep one bad block from blanking the transcript. */ import { Component, type ReactNode } from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { BlockError, safeParseBlock } from '../../../../common/blocks'; import type { MessageBlock } from '../../types/block'; import { BLOCK_SCHEMAS } from '../../types/blockSchemas'; import { useChatBubbleStyles, BLOCK_SURFACE } from '../../styles'; import { BUILTIN_BLOCK_REGISTRY, type BlockRegistry } from './registry'; export interface MessageBlocksProps { blocks?: MessageBlock[]; /** Registry of `kind` → renderer. Defaults to `BUILTIN_BLOCK_REGISTRY`. */ registry?: BlockRegistry; /** Spaciousness forwarded to renderers. */ appearance?: 'compact' | 'full'; isUser?: boolean; /** * When true, an unknown `kind` renders a visible dev notice. When * false (default) it is skipped silently — production-lenient. */ strict?: boolean; } /** Block kinds rendered inside a chat-bubble surface (bubble-tinted, inline). */ const PROSE_KINDS = new Set(['text', 'markdown']); /** * Block kinds wrapped in the shared media-card surface (`BLOCK_SURFACE`) so * every media block shares one radius / border / shadow. `prose` kinds get * the bubble surface instead (above). * * Bare kinds skip the light card and own their frame: * - `custom` — host-owned chrome. * - `link` — the LinkPreviewCard ships its own frame. * - `audio` — the player draws its own card AND a scrub-time tooltip that * sits ABOVE the bar; a clipping card would crop that tooltip, so the * player rounds itself to the shared 2xl radius instead (see AudioBlock). * - `diff` — the DiffViewer ships its own bordered panel + a floating * copy button that a clip would crop; it re-rounds to 2xl (see DiffBlock). * * `code` is NOT bare: its dark editor panel can't be reliably re-rounded * from outside (PrettyCode rounds its inner layers to `rounded-lg`), so we * let the shared `overflow-hidden rounded-2xl` card CLIP it to the uniform * corner instead. (Code has no overflowing tooltip, so clipping is safe.) */ const BARE_KINDS = new Set(['custom', 'link', 'audio', 'diff']); interface BoundaryProps { kind: string; children: ReactNode; } interface BoundaryState { failed: boolean; } /** Minimal per-block error boundary. */ class BlockErrorBoundary extends Component { state: BoundaryState = { failed: false }; static getDerivedStateFromError(): BoundaryState { return { failed: true }; } render() { if (this.state.failed) { return (
Failed to render a {this.props.kind} block.
); } return this.props.children; } } function UnknownBlock({ kind }: { kind: string }) { return (
Unknown block kind: {kind}
); } export function MessageBlocks({ blocks, registry = BUILTIN_BLOCK_REGISTRY, appearance = 'compact', isUser = false, strict = false, }: MessageBlocksProps) { const { surface: bubbleSurface } = useChatBubbleStyles( isUser ? 'user' : 'assistant', false, ); if (!blocks?.length) return null; return (
{blocks.map((block) => { const renderer = registry[block.kind] as | ((b: MessageBlock, c: { appearance: 'compact' | 'full'; isUser: boolean }) => ReactNode) | undefined; // Central payload validation — every block flows through here, so a // malformed payload (wrong-typed `src`, non-array gallery `items`, bad // map coords) renders a safe `` instead of throwing inside // its renderer and blanking the transcript. Kinds without a schema // (e.g. host-defined `custom`) skip this and render as-is. The renderer // can therefore trust its input. The error boundary below stays as a // last-resort runtime guard. const schema = BLOCK_SCHEMAS[block.kind]; let body: ReactNode; if (schema) { const parsed = safeParseBlock(schema, block); if (!parsed.ok) { body = ; } else if (renderer) { body = renderer(block, { appearance, isUser }); } else if (strict) { body = ; } else { return null; } } else if (renderer) { body = renderer(block, { appearance, isUser }); } else if (strict) { body = ; } else { return null; } const isProse = PROSE_KINDS.has(block.kind); const isBare = BARE_KINDS.has(block.kind); // Surface selection — one consistent frame per category: // prose → tinted chat bubble (inline) // bare → no frame (host-owned `custom` block) // media → shared `BLOCK_SURFACE` card (image/video/map/code/…) let framed: ReactNode; if (isProse) { framed = (
{body}
); } else if (isBare) { framed = body; } else { framed =
{body}
; } return (
{block.caption ? (
{block.caption}
) : null} {framed}
); })}
); }