maestro/ui/src/components/chat/ChatMessage.tsx
oss-sync 6a2f2cc736
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e6a4dc8)
2026-06-17 06:55:25 +00:00

449 lines
18 KiB
TypeScript

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<string, number>;
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 (
<svg className={`${common} text-emerald-600`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="12" height="12" rx="2.5" fill="currentColor" fillOpacity="0.12" stroke="currentColor" />
<path d="M5 8.5l2 2 4-4.5" />
</svg>
);
case 'failed':
return (
<svg className={`${common} text-red-600`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="12" height="12" rx="2.5" fill="currentColor" fillOpacity="0.1" stroke="currentColor" />
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" />
</svg>
);
case 'skipped':
return (
<svg className={`${common} text-slate-400`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
<rect x="2" y="2" width="12" height="12" rx="2.5" stroke="currentColor" />
<path d="M4.5 4.5l7 7" />
</svg>
);
default:
return (
<svg className={`${common} text-slate-300`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="2" width="12" height="12" rx="2.5" />
</svg>
);
}
};
return (
<div className="flex justify-center">
<div className="bg-canvas border border-hairline rounded-md px-3.5 py-2.5 max-w-[90%] w-full">
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between text-left hover:bg-surface rounded -mx-1 px-1 py-0.5 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<svg className="w-4 h-4 text-slate-500 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
<span className="text-[13px] font-semibold text-slate-900 truncate">{name}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-2xs text-slate-500 font-mono tabular-nums">
{summary.done + summary.failed + summary.skipped}/{summary.total} {'\u00B7'} {pct}%
</span>
<svg className={`w-3 h-3 text-slate-400 transition-transform ${expanded ? 'rotate-90' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4l4 4-4 4" />
</svg>
</div>
</button>
{/* Summary badges */}
<div className="flex gap-1.5 mt-2 text-2xs">
{summary.done > 0 && (
<span className="inline-flex items-center gap-1 bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-100 dark:border-emerald-500/30 px-1.5 py-0.5 rounded font-mono tabular-nums">
<StatusIcon status="done" />{summary.done}
</span>
)}
{summary.failed > 0 && (
<span className="inline-flex items-center gap-1 bg-red-50 dark:bg-red-500/15 text-red-700 dark:text-red-300 border border-red-100 dark:border-red-500/30 px-1.5 py-0.5 rounded font-mono tabular-nums">
<StatusIcon status="failed" />{summary.failed}
</span>
)}
{summary.skipped > 0 && (
<span className="inline-flex items-center gap-1 bg-surface-2 text-slate-600 border border-hairline px-1.5 py-0.5 rounded font-mono tabular-nums">
<StatusIcon status="skipped" />{summary.skipped}
</span>
)}
{summary.remaining > 0 && (
<span className="inline-flex items-center gap-1 bg-canvas text-slate-500 border border-hairline px-1.5 py-0.5 rounded font-mono tabular-nums">
<StatusIcon status="pending" />{summary.remaining}
</span>
)}
</div>
{/* Items list */}
{(expanded || items.length <= 20) && (
<div className="mt-2 pt-2 border-t border-hairline-soft max-h-[400px] overflow-y-auto">
{displayItems.map(item => (
<div key={item.id} className="flex items-start gap-2 py-1 text-xs">
<span className="mt-0.5"><StatusIcon status={item.status} /></span>
<span className="min-w-0 flex-1">
<span className="block text-slate-700 truncate" title={item.label || item.id}>
{item.label || item.id}
</span>
{item.label && item.label !== item.id && (
<span className="block text-[10px] leading-tight text-slate-400 font-mono truncate" title={item.id}>
{item.id}
</span>
)}
</span>
{item.status === 'done' && item.result && (
<span className="text-slate-400 truncate max-w-[200px] font-mono text-2xs">{'\u2192'} {item.result}</span>
)}
{item.status === 'failed' && item.error && (
<span className="text-red-500 truncate max-w-[200px] font-mono text-2xs">{item.error}</span>
)}
</div>
))}
{hasMore && (
<button
onClick={() => setExpanded(true)}
className="text-2xs text-slate-500 hover:text-slate-900 hover:underline mt-1.5"
>
{t('checklist.showMore', { count: items.length - 20 })}
</button>
)}
</div>
)}
</div>
</div>
);
}
function Spinner() {
return (
<svg className="w-3 h-3 animate-spin text-slate-500" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
}
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 <div>). NB: NO whitespace-pre-wrap here — that
// turns the literal `\n` between `</p>` and `<ol>` (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 (
<div className="flex justify-start">
<div className={`inline-flex gap-2 bg-slate-50 border border-slate-200 text-xs text-slate-600 ${shapeCls}`}>
<span className={`flex-shrink-0 ${variant === 'block' ? 'mt-0.5' : ''}`}>{icon}</span>
<div className="break-words min-w-0">{children}</div>
</div>
</div>
);
}
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 (
<div className="flex justify-center">
<div className="inline-flex items-center gap-1.5 px-3 py-1 text-[10px] text-green-600 font-medium">
<span>{'✓'}</span>
<span>{t('message.messageAcked')}</span>
</div>
</div>
);
}
// 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 <ToolCallsSection toolCalls={[toolCall]} />;
}
// Checklist progress → dedicated card (center, retained as per decision)
const checklistData = tryParseChecklistProgress(comment.body);
if (checklistData) {
return <ChecklistCard comment={comment} />;
}
// 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
? <span className="text-slate-400">{'\u2026'}</span>
: <Spinner />;
return (
<ProgressPill icon={icon} variant="block">
<MarkdownText text={thinking.text} />
</ProgressPill>
);
}
// 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 <ProgressPill icon={<span className="text-green-600">{'\u2713'}</span>}>{text}</ProgressPill>;
}
// Fallback: free-form text, MD render
return (
<ProgressPill icon={<span className="text-slate-400">{'\u2022'}</span>}>
<MarkdownText text={comment.body} />
</ProgressPill>
);
}
/**
* 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 (
<div className="mt-2 flex flex-wrap gap-1.5">
{attachments.map(name => (
<a
key={name}
href={getLocalFileRawUrl(taskId, 'input', name)}
download={name}
target="_blank"
rel="noopener noreferrer"
title={name}
className="inline-flex items-center gap-1 max-w-[200px] px-2 py-1 rounded-md bg-white/70 dark:bg-slate-900/40 border border-slate-200 dark:border-slate-600 text-2xs text-slate-700 dark:text-slate-200 hover:bg-white dark:hover:bg-slate-800 hover:border-accent transition-colors"
>
<svg className="w-3 h-3 shrink-0 text-slate-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 2.5 4.2 8.3a2 2 0 0 0 2.8 2.8l5.3-5.3a3.2 3.2 0 0 0-4.5-4.5L2.4 7.2" />
</svg>
<span className="truncate">{name}</span>
</a>
))}
</div>
);
}
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 <ProgressCard comment={comment} isStaleThinking={isStaleThinking} />;
}
// Interjection (user message sent during running)
if (kind === 'interjection') {
const isPending = !comment.injectedAt;
return (
<div className="flex justify-end">
<div className="max-w-[82%] bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-slate-900 rounded-2xl rounded-br-md px-4 py-3">
<div className="text-2xs text-amber-500 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
</div>
<MarkdownText text={body} />
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}>
{isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
</div>
</div>
</div>
);
}
// User messages (right, soft slate — kit style)
if (kind === 'request' || kind === 'comment') {
return (
<div className="flex justify-end">
<div className="max-w-[82%] bg-slate-100 text-slate-900 rounded-2xl rounded-br-md px-4 py-3">
<div className="text-2xs text-slate-400 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
</div>
<MarkdownText text={body} />
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
</div>
</div>
);
}
// Agent ask (left, yellow)
if (kind === 'ask') {
return (
<div className="flex justify-start">
<div className="max-w-[82%] bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-slate-900 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
<div className="text-2xs text-amber-500 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
</div>
<MarkdownText text={body} />
</div>
</div>
);
}
// Agent result (left, green) - render with Markdown
if (kind === 'result') {
return (
<div className="flex justify-start">
<div className="w-full bg-green-50 dark:bg-green-500/15 border border-green-200 dark:border-green-500/30 text-slate-900 rounded-xl px-4 py-3 shadow-sm">
<div className="text-2xs text-green-500 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
</div>
<div className="text-sm leading-relaxed prose prose-sm prose-slate dark:prose-invert max-w-none">
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} />
</div>
</div>
</div>
);
}
// Fallback for unknown kinds
return (
<div className="flex justify-start">
<div className="max-w-[82%] bg-canvas border border-slate-200 text-slate-900 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
<div className="text-2xs text-slate-400 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
</div>
<MarkdownText text={body} />
</div>
</div>
);
}