learning_ai_common_plat/packages/ai-ui/src/MessageBubble.tsx
2026-05-27 12:07:23 -07:00

164 lines
5.2 KiB
TypeScript

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 `<html>`.
*
* 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 (
<article
data-testid={`bl-message-${message.id}`}
data-role={message.role}
data-streaming={message.isStreaming ? 'true' : 'false'}
className={className}
style={{
maxWidth: isSystem || isTool ? '100%' : '85%',
padding: 'var(--bl-space-3, 12px) var(--bl-space-4, 16px)',
borderRadius: 'var(--bl-radius-card, 12px)',
fontSize: '0.95rem',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
...variantStyle,
...style,
}}
>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '0.75rem',
color: 'var(--bl-text-tertiary, #888)',
marginBottom: message.content || thinking ? 'var(--bl-space-1, 4px)' : 0,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
<span>{roleLabel ?? capitalize(message.role)}</span>
{message.createdAt && (
<time dateTime={message.createdAt.toISOString()}>
{formatTime(message.createdAt)}
</time>
)}
</header>
{thinking ? <ThinkingDots /> : <div>{message.content}</div>}
{actions && (
<footer
style={{
marginTop: 'var(--bl-space-2, 8px)',
display: 'flex',
gap: 'var(--bl-space-2, 8px)',
}}
>
{actions(message)}
</footer>
)}
</article>
);
}
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 (
<span
aria-label="Assistant is typing"
role="status"
style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 0' }}
>
<span style={{ ...dot, animationDelay: '0ms' }} />
<span style={{ ...dot, animationDelay: '180ms' }} />
<span style={{ ...dot, animationDelay: '360ms' }} />
<style>{`@keyframes bl-ai-pulse{0%,80%,100%{opacity:.25;transform:scale(0.85)}40%{opacity:1;transform:scale(1)}}`}</style>
</span>
);
}