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 (
system line {m.lineNo}
); } 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; } }