import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { LocalTaskComment, getLocalFileRawUrl } from '../../api'; import { MarkdownPreview } from '../files/FilePreview'; import { MarkdownText } from '../../lib/markdown-text'; import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection'; // We delegate spacing to MarkdownText's built-in COMPACT_PROSE default // (4px-ish paragraph margins, leading-snug, `!important` to beat the // prose plugin). Don't pass a className here — that would replace // the compact preset with whatever string we pass, which is exactly // the bug we kept reintroducing earlier. interface ChatMessageProps { comment: LocalTaskComment; taskId: number; /** Override the base URL used for inline Markdown images (e.g. for shared view) */ imageBaseUrl?: string; /** When true, this thinking comment has been superseded — show static dot instead of spinner */ isStaleThinking?: boolean; } interface ProgressData { movement: string; tools: Record; durationMs: number; } interface ThinkingData { type: 'thinking'; text: string; movement?: string; } function tryParseInterjectionAck(body: string): { commentIds: number[]; movement: string } | null { try { const data = JSON.parse(body); if (data && data.type === 'interjection_ack' && Array.isArray(data.commentIds)) { return data; } } catch { /* not ack JSON */ } return null; } function tryParseThinking(body: string): ThinkingData | null { try { const data = JSON.parse(body); if (data && data.type === 'thinking' && typeof data.text === 'string') { return data as ThinkingData; } } catch { /* not thinking JSON */ } return null; } interface ChecklistProgressData { type: 'checklist'; name: string; items: Array<{ id: string; label: string; status: 'pending' | 'done' | 'failed' | 'skipped'; result: string | null; error: string | null; }>; summary: { total: number; done: number; failed: number; skipped: number; remaining: number; }; } function tryParseChecklistProgress(body: string): ChecklistProgressData | null { try { const data = JSON.parse(body); if (data && data.type === 'checklist' && data.name && data.items && data.summary) { return data as ChecklistProgressData; } } catch { /* not checklist JSON */ } return null; } function tryParseProgress(body: string): ProgressData | null { try { const data = JSON.parse(body); if ( data && typeof data.movement === 'string' && typeof data.durationMs === 'number' && data.tools && typeof data.tools === 'object' && data.type !== 'tool_call' ) { return data as ProgressData; } } catch { /* not JSON */ } return null; } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const sec = Math.round(ms / 1000); if (sec < 60) return `${sec}s`; const min = Math.floor(sec / 60); return `${min}m ${sec % 60}s`; } function ChecklistCard({ comment }: { comment: LocalTaskComment }) { const { t } = useTranslation('chat'); const [expanded, setExpanded] = useState(false); const data = tryParseChecklistProgress(comment.body); if (!data) return null; const { name, items, summary } = data; const pct = summary.total > 0 ? Math.round(((summary.done + summary.failed + summary.skipped) / summary.total) * 100) : 0; // Show max 20 items in collapsed, all in expanded const displayItems = expanded ? items : items.slice(0, 20); const hasMore = !expanded && items.length > 20; // Refero refresh: replace unicode glyphs with inline SVG so icons render // consistently across font stacks and align with the new palette. const StatusIcon = ({ status }: { status: string }) => { const common = 'inline-block w-3.5 h-3.5 flex-shrink-0'; switch (status) { case 'done': return ( ); case 'failed': return ( ); case 'skipped': return ( ); default: return ( ); } }; return (
{/* Header */} {/* Summary badges */}
{summary.done > 0 && ( {summary.done} )} {summary.failed > 0 && ( {summary.failed} )} {summary.skipped > 0 && ( {summary.skipped} )} {summary.remaining > 0 && ( {summary.remaining} )}
{/* Items list */} {(expanded || items.length <= 20) && (
{displayItems.map(item => (
{item.label || item.id} {item.label && item.label !== item.id && ( {item.id} )} {item.status === 'done' && item.result && ( {'\u2192'} {item.result} )} {item.status === 'failed' && item.error && ( {item.error} )}
))} {hasMore && ( )}
)}
); } function Spinner() { return ( ); } function ProgressPill({ icon, children, variant = 'inline' }: { icon: React.ReactNode; children: React.ReactNode; variant?: 'inline' | 'block' }) { // inline: short one-liner (movement summary / fallback) — pill shape // block: potentially multi-line thinking text — rounded rectangle to avoid weird ellipse corners const shapeCls = variant === 'block' ? 'rounded-xl rounded-bl-md px-3 py-2 items-start max-w-[85%]' : 'rounded-full px-3 py-1.5 items-center max-w-[90%]'; // Use a div for the text wrapper so children can be block-level MD output // (MarkdownText renders a
). NB: NO whitespace-pre-wrap here — that // turns the literal `\n` between `

` and `
    ` (etc.) in the marked // output into a rendered newline, adding ~22px of empty vertical space // between every Markdown block. The plain-text callsites in this file // (movement summary string) are single-line, so they don't need it // either. return (
    {icon}
    {children}
    ); } function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; isStaleThinking?: boolean }) { const { t } = useTranslation('chat'); // Interjection ack → minimal centered confirmation const ackData = tryParseInterjectionAck(comment.body); if (ackData) { return (
    {'✓'} {t('message.messageAcked')}
    ); } // Tool call comment → render as single-item ToolCallsSection. // This path fires when a tool_call comment is emitted before the parent // movement-complete arrives (live tool calls during running movement). const toolCall = parseToolCallComment(comment.body); if (toolCall) { return ; } // Checklist progress → dedicated card (center, retained as per decision) const checklistData = tryParseChecklistProgress(comment.body); if (checklistData) { return ; } // Thinking / in-flight LLM text \u2014 MD render so streaming output that // contains lists / fenced code / inline backticks looks right. const thinking = tryParseThinking(comment.body); if (thinking) { const icon = isStaleThinking ? {'\u2026'} : ; return ( ); } // Movement completion summary JSON \u2014 structured one-liner, keep plain. const data = tryParseProgress(comment.body); if (data) { const toolEntries = Object.entries(data.tools); const toolSummary = toolEntries.map(([name, count]) => `${name}\u00D7${count}`).join(', '); const text = `${t('movement.complete', { movement: data.movement })}${toolSummary ? ` \u00B7 ${toolSummary}` : ''} \u00B7 ${formatDuration(data.durationMs)}`; return {'\u2713'}}>{text}; } // Fallback: free-form text, MD render return ( {'\u2022'}}> ); } /** * Download chips for files the user attached to a comment. Files live under the * task's input/ dir; we link to the raw-file endpoint with a download attribute. */ function CommentAttachments({ attachments, taskId }: { attachments?: string[]; taskId: number }) { if (!attachments || attachments.length === 0) return null; return (
    {attachments.map(name => ( {name} ))}
    ); } export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) { const { t } = useTranslation('chat'); const { kind, author, body, createdAt } = comment; // Progress card (center) if (kind === 'progress') { return ; } // Interjection (user message sent during running) if (kind === 'interjection') { const isPending = !comment.injectedAt; return (
    {author} · {new Date(createdAt).toLocaleString()}
    {isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
    ); } // User messages (right, soft slate — kit style) if (kind === 'request' || kind === 'comment') { return (
    {author} · {new Date(createdAt).toLocaleString()}
    ); } // Agent ask (left, yellow) if (kind === 'ask') { return (
    {author} · {new Date(createdAt).toLocaleString()}
    ); } // Agent result (left, green) - render with Markdown if (kind === 'result') { return (
    {author} · {new Date(createdAt).toLocaleString()}
    ); } // Fallback for unknown kinds return (
    {author} · {new Date(createdAt).toLocaleString()}
    ); }