Files
beaver_project/app-instance/frontend/components/task-detail/TaskTimelineCard.tsx

241 lines
9.3 KiB
TypeScript

'use client';
import {
AlertTriangle,
ArrowRightCircle,
Bot,
CheckCircle2,
ClipboardList,
ChevronDown,
FileText,
GitBranch,
History,
ListChecks,
Sparkles,
TerminalSquare,
ThumbsUp,
Users,
Wrench,
} from 'lucide-react';
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import { containedJsonTextClass, containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
type Props = {
card: TaskTimelineCardView;
resultAcceptance?: TaskResultAcceptance;
reviewTargetId?: string;
};
export type TaskResultAcceptance = {
sessionId: string;
runId: string | null;
taskStatus: string;
feedbackItems: TaskFeedbackItem[];
actionBusy: string | null;
revision?: string;
onRevisionChange?: (value: string) => void;
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
};
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
return RUNTIME_STATUSES.has(status);
}
function iconForType(type: TaskTimelineCardType) {
switch (type) {
case 'task_created':
return ClipboardList;
case 'plan':
return ListChecks;
case 'skill':
return Sparkles;
case 'tool_call':
return Wrench;
case 'tool_result':
return TerminalSquare;
case 'next_step':
return ArrowRightCircle;
case 'agent_team':
return Users;
case 'agent_progress':
return Bot;
case 'agent_handoff':
return GitBranch;
case 'artifact':
return FileText;
case 'error':
return AlertTriangle;
case 'result':
return CheckCircle2;
case 'result_history':
return History;
case 'acceptance':
return ThumbsUp;
}
}
function detailsJson(details: Record<string, unknown>): string {
try {
return JSON.stringify(details, null, 2);
} catch {
return String(details);
}
}
function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
const labels: Record<TaskTimelineCardType, [string, string]> = {
task_created: ['任务', 'Task'],
plan: ['计划', 'Plan'],
skill: ['Skill', 'Skill'],
tool_call: ['工具调用', 'Tool call'],
tool_result: ['工具结果', 'Tool result'],
next_step: ['下一步', 'Next step'],
agent_team: ['Agent Team', 'Agent team'],
agent_progress: ['Agent', 'Agent'],
agent_handoff: ['交接', 'Handoff'],
artifact: ['产物', 'Artifact'],
error: ['异常', 'Error'],
result: ['结果', 'Result'],
result_history: ['历史结果', 'Result history'],
acceptance: ['验收', 'Acceptance'],
};
const label = labels[type];
return pickAppText(locale, label[0], label[1]);
}
function humanStatus(status: string, locale: 'zh-CN' | 'en-US') {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],
accept: ['接受', 'Accepted'],
satisfied: ['接受', 'Accepted'],
revise: ['请求修改', 'Revision requested'],
abandon: ['放弃任务', 'Abandoned'],
warning: ['提醒', 'Warning'],
};
const label = labels[status];
return label ? pickAppText(locale, label[0], label[1]) : status;
}
function historyVersions(details: Record<string, unknown> | undefined): Array<Record<string, unknown>> {
const versions = details?.versions;
return Array.isArray(versions) ? versions.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object') : [];
}
function renderHistoryStatus(version: Record<string, unknown>, locale: 'zh-CN' | 'en-US') {
const status = String(version.acceptanceType || version.status || '');
return status ? humanStatus(status, locale) : pickAppText(locale, '历史版本', 'Previous version');
}
function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
const { locale } = useAppI18n();
const versions = historyVersions(card.details);
return (
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</summary>
<div className="mt-3 space-y-3">
{versions.map((version, index) => (
<div key={String(version.runId || index)} className="min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium">
{pickAppText(locale, `${index + 1} 轮结果`, `Version ${index + 1}`)}
</div>
<Badge variant="outline" className="text-[11px]">
{renderHistoryStatus(version, locale)}
</Badge>
</div>
{version.result ? <p className={`mt-2 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(version.result)}</p> : null}
{version.comment ? (
<div className={`mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground ${containedLongTextClass}`}>
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
</div>
) : null}
</div>
))}
</div>
</details>
);
}
export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Props) {
const { locale } = useAppI18n();
const Icon = iconForType(card.type);
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
return (
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
<CardContent className="p-4">
<div className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold">{card.title}</h3>
<Badge variant="secondary" className="shrink-0 text-[11px]">
{cardTypeLabel(card.type, locale)}
</Badge>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{card.actorName ? <span className={containedLongTextClass}>{card.actorName}</span> : null}
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
</div>
</div>
{card.status ? (
isRuntimeStatus(card.status) ? (
<TaskRuntimeStatusBadge status={card.status} />
) : (
<Badge variant="outline" className="shrink-0 text-[11px]">
{humanStatus(card.status, locale)}
</Badge>
)
) : null}
</div>
{card.summary ? <p className={`mt-3 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{card.summary}</p> : null}
{shouldRenderResultAcceptance ? (
<div className="mt-4 border-t border-border pt-4">
<TaskAcceptanceControls {...resultAcceptance!} />
</div>
) : null}
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
{pickAppText(locale, '详情 JSON', 'Details JSON')}
</summary>
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>
{detailsJson(card.details)}
</pre>
</details>
) : null}
</div>
</div>
</CardContent>
</Card>
);
}