164 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|