import type { CSSProperties, ReactNode } from 'react'; import type { Message } from './types.js'; export interface MessageBubbleProps { message: Message; /** * Optional action buttons rendered in the footer (copy / retry / * feedback). Receives the message so each handler can be wired * statelessly. */ actions?: (message: Message) => ReactNode; /** Override the rendered role label (default: capitalized role). */ roleLabel?: string; /** Tailwind-friendly className escape hatch. */ className?: string; /** Inline style escape hatch. */ style?: CSSProperties; } /** * Atom: a single chat message bubble. Variants by role (user / assistant / * system / tool). Honors `--bl-*` design tokens so it inherits whatever * scheme + density is active on the surrounding ``. * * The "thinking" indicator (three pulsing dots) appears for assistant * messages while `isStreaming` is true and content is empty — i.e. the * window between request-sent and first-token-received. */ export function MessageBubble({ message, actions, roleLabel, className, style, }: MessageBubbleProps) { const isUser = message.role === 'user'; const isAssistant = message.role === 'assistant'; const isSystem = message.role === 'system'; const isTool = message.role === 'tool'; const thinking = isAssistant && message.isStreaming && !message.content; // Variant colors via design tokens — tone shifts so the eye can // separate user from assistant without relying on alignment alone. const variantStyle: CSSProperties = isUser ? { background: 'var(--bl-accent-muted, rgba(99,102,241,0.12))', color: 'var(--bl-text-primary, inherit)', marginInlineStart: 'auto', borderInlineEnd: '1px solid var(--bl-accent, #6366f1)', } : isAssistant ? { background: 'var(--bl-surface-card, #fff)', color: 'var(--bl-text-primary, inherit)', border: '1px solid var(--bl-border, rgba(0,0,0,0.08))', } : isSystem ? { background: 'transparent', color: 'var(--bl-text-tertiary, #888)', fontStyle: 'italic', border: '1px dashed var(--bl-border-subtle, rgba(0,0,0,0.06))', } : /* tool */ { background: 'var(--bl-surface-muted, #f6f6f6)', color: 'var(--bl-text-secondary, #555)', fontFamily: 'var(--bl-font-mono, ui-monospace, monospace)', border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))', }; return (
{roleLabel ?? capitalize(message.role)} {message.createdAt && ( )}
{thinking ? :
{message.content}
} {actions && ( )}
); } function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } function formatTime(d: Date): string { // 24-h HH:MM — locale-agnostic so visual-regression snapshots stay stable. const h = String(d.getHours()).padStart(2, '0'); const m = String(d.getMinutes()).padStart(2, '0'); return `${h}:${m}`; } function ThinkingDots() { // Three blinking dots; sequenced with CSS-only animation-delay so we // don't depend on @bytelyst/motion (which lands in Wave 4). const dot: CSSProperties = { display: 'inline-block', width: 6, height: 6, margin: '0 2px', borderRadius: '50%', background: 'currentColor', opacity: 0.4, animation: 'bl-ai-pulse 1.2s infinite ease-in-out', }; return ( ); }