449 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|