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 (
);
}