import { useState, type ReactNode } from 'react';
import type {
ContentPart,
ProjectedMessage,
ToolCall,
} from '../../types';
import { ImagePreview } from '../shared/ImagePreview';
import { Pill } from '../shared/Pill';
interface MessageBubbleProps {
message: ProjectedMessage;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const role = message.message.role;
if (role === 'user') return ;
if (role === 'assistant') return ;
if (role === 'tool') return ;
return ;
}
function baseClass(): string {
return 'relative flex max-w-full min-w-0 flex-col border-l-[3px] bg-surface-1 px-3 py-2';
}
function UserBubble({ m }: { m: ProjectedMessage }) {
const origin = m.message.origin;
const originKind = origin?.kind;
// Badge every origin that is not a plain user prompt. This covers
// skill_activation, task (v2; v1: background_task), cron_job, cron_missed,
// retry, system_trigger, injection, hook_result, compaction_summary, etc.
const showsOriginBadge = originKind !== undefined && originKind !== 'user';
return (
user
line {m.lineNo}
{showsOriginBadge ? (
{originKind}
) : null}
{m.message.isError ? error : null}
);
}
function AssistantBubble({ m }: { m: ProjectedMessage }) {
const thinkPart = m.message.content.find((p) => p.type === 'think');
const think = thinkPart && thinkPart.type === 'think' ? thinkPart.think : undefined;
const visibleParts = m.message.content.filter((p) => p.type !== 'think');
const toolCalls = m.message.toolCalls;
return (
assistant
line {m.lineNo}
{think ? think : null}
{toolCalls.length > 0 ? (
{toolCalls.length} tool call{toolCalls.length > 1 ? 's' : ''}
) : null}
{m.message.partial ? partial : null}
{think ? : null}
{toolCalls.length > 0 ? (
{toolCalls.map((tc) => (
))}
) : null}
);
}
function ToolBubble({ m }: { m: ProjectedMessage }) {
// Tool outputs are often huge (file contents, command stdout). Collapse
// by default so the conversation flow stays readable. Errors open by
// default — that's the case where the user actually needs to read.
const [open, setOpen] = useState(m.message.isError === true);
const totalChars = m.message.content.reduce((acc, p) => {
if (p.type === 'text') return acc + p.text.length;
return acc;
}, 0);
const preview = firstTextPreview(m.message.content);
return (
{open ? (
) : null}
);
}
function SystemBubble({ m }: { m: ProjectedMessage }) {
return (
);
}
function ThinkBlock({ text }: { text: string }) {
const [open, setOpen] = useState(false);
return (
{open ? (
{text}
) : null}
);
}
function ToolCallCard({ call }: { call: ToolCall }) {
const [open, setOpen] = useState(false);
const argsStr = call.arguments ?? '';
return (
{open ? (
{prettyJson(argsStr)}
) : null}
);
}
function MessageContent({ parts }: { parts: readonly ContentPart[] }): ReactNode {
return (
{parts.map((p, i) => {
if (p.type === 'text') {
return (
{p.text}
);
}
if (p.type === 'think') {
return
;
}
if (p.type === 'image_url') {
return
;
}
if (p.type === 'audio_url') {
return (
[audio: {p.audioUrl.url}]
);
}
if (p.type === 'video_url') {
return (
[video: {p.videoUrl.url ?? '—'}]
);
}
// Exhaustive — anything else is unexpected for ContentPart.
return (
[{(p as { type: string }).type}]
);
})}
);
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n) + '…';
}
function firstTextPreview(parts: readonly ContentPart[]): string {
for (const p of parts) {
if (p.type === 'text' && p.text.length > 0) {
const firstLine = p.text.split('\n', 1)[0] ?? '';
return truncate(firstLine, 100);
}
if (p.type === 'image_url') return '[image]';
if (p.type === 'audio_url') return '[audio]';
if (p.type === 'video_url') return '[video]';
}
return '(empty)';
}
function prettyJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}