feat(web): add TaskRunCard vertical timeline for task runs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 08:15:10 +00:00
co-authored by Cursor
parent b376fd8383
commit 22314ff9b0
5 changed files with 583 additions and 0 deletions
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { ChatMessage } from '../../types/workbench';
import { TaskRunCard } from './TaskRunCard';
function baseMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
id: 'asst-1',
sessionId: 'sess-1',
role: 'assistant',
content: '任务执行中…',
timestamp: '12:00',
status: 'streaming_status',
taskRunId: 'run-1',
livePhaseLabel: '法规专员正在整理素材…',
phaseEvents: [
{ step: 'squad_assembly', icon: '👥', label: '组建小队', status: 'completed' },
{
step: 'step_executing#1',
icon: '⚙️',
label: '专员执行(第 1/3 步)',
status: 'running',
detail: '法规专员正在整理素材…',
},
],
taskRequest: {
taskKey: 'intake',
projectName: 'P',
customerName: 'C',
deviceType: 'D',
sessionId: 'sess-1',
userId: 1,
projectId: 1,
clientTurnId: 't1',
userMessage: '编写用户初始需求收集表',
},
...overrides,
};
}
test('TaskRunCard renders timeline with title badge and current step', () => {
const html = renderToStaticMarkup(React.createElement(TaskRunCard, { message: baseMessage() }));
assert.match(html, /data-testid="task-run-card"/);
assert.match(html, /编写用户初始需求收集表/);
assert.match(html, /执行中/);
assert.match(html, /法规专员正在整理素材…/);
assert.match(html, /data-testid="task-run-card-timeline"/);
assert.match(html, /组建小队/);
assert.match(html, /专员执行(第 1\/3 步)/);
});
test('TaskRunCard completed state keeps card with duration and artifact', () => {
const html = renderToStaticMarkup(
React.createElement(TaskRunCard, {
message: baseMessage({
status: 'completed',
content: '已完成用户初始需求收集表',
livePhaseLabel: undefined,
taskDurationMs: 95_000,
taskArtifact: { title: '用户初始需求收集表', quality_score: 92 },
phaseEvents: [
{ step: 'squad_assembly', icon: '👥', label: '组建小队', status: 'completed' },
{ step: 'quality_gate', icon: '🛡️', label: '质量门禁', status: 'completed' },
],
}),
}),
);
assert.match(html, /data-testid="task-run-card"/);
assert.match(html, /已完成/);
assert.match(html, /耗时 1m 35s/);
assert.match(html, /用户初始需求收集表/);
assert.match(html, /质量 92/);
assert.doesNotMatch(html, />执行中</);
});
test('TaskRunCard keeps shell when only taskRunId is present', () => {
const html = renderToStaticMarkup(
React.createElement(TaskRunCard, {
message: baseMessage({
status: 'completed',
phaseEvents: undefined,
livePhaseLabel: undefined,
taskRequest: undefined,
taskArtifact: { title: '调研报告' },
}),
}),
);
assert.match(html, /data-testid="task-run-card"/);
assert.match(html, /调研报告/);
assert.match(html, /暂无步骤明细/);
});
+280
View File
@@ -0,0 +1,280 @@
import React, { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Loader2, Circle, AlertTriangle, Clock3, FileText } from 'lucide-react';
import type { ChatMessage, PhaseEvent } from '../../types/workbench';
import {
formatTaskDuration,
resolveTaskRunCardBadge,
resolveTaskRunCardTitle,
runningPhaseDetail,
type TaskRunCardBadgeTone,
} from '../../lib/taskRunCard';
export interface TaskRunCardProps {
message: ChatMessage;
}
const TONE_STYLE: Record<
TaskRunCardBadgeTone,
{ color: string; background: string; border: string }
> = {
running: {
color: 'var(--brand-500)',
background: 'rgba(37, 99, 235, 0.10)',
border: '1px solid rgba(37, 99, 235, 0.28)',
},
success: {
color: 'var(--success)',
background: 'rgba(22, 163, 74, 0.10)',
border: '1px solid rgba(22, 163, 74, 0.28)',
},
warning: {
color: '#b45309',
background: 'rgba(245, 158, 11, 0.16)',
border: '1px solid rgba(245, 158, 11, 0.35)',
},
danger: {
color: 'var(--danger)',
background: 'rgba(220, 38, 38, 0.10)',
border: '1px solid rgba(220, 38, 38, 0.28)',
},
muted: {
color: 'var(--text-muted)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
},
};
function StepStatusIcon({ status }: { status: PhaseEvent['status'] }) {
if (status === 'completed') {
return <CheckCircle2 size={14} style={{ color: 'var(--success)', flexShrink: 0 }} />;
}
if (status === 'running') {
return <Loader2 size={14} className="animate-spin-fast" style={{ color: 'var(--brand-500)', flexShrink: 0 }} />;
}
if (status === 'failed') {
return <AlertTriangle size={14} style={{ color: 'var(--danger)', flexShrink: 0 }} />;
}
return <Circle size={12} style={{ color: 'var(--border-strong)', flexShrink: 0 }} />;
}
export const TaskRunCard: React.FC<TaskRunCardProps> = ({ message }) => {
const title = resolveTaskRunCardTitle(message);
const events = message.phaseEvents || message.phaseSnapshot?.steps || [];
const hasRunningStep = events.some((e) => e.status === 'running');
const badge = resolveTaskRunCardBadge(message.status, hasRunningStep);
const tone = TONE_STYLE[badge.tone];
const currentDetail = runningPhaseDetail(events, message.livePhaseLabel);
const isLive =
message.status === 'streaming_status' ||
message.status === 'pending' ||
message.status === 'auto_retrying' ||
hasRunningStep;
const [tickMs, setTickMs] = useState<number | undefined>(message.taskDurationMs);
useEffect(() => {
if (!isLive) {
setTickMs(message.taskDurationMs);
return;
}
const startedAt = message.createdAt ? Date.parse(message.createdAt) : NaN;
if (!Number.isFinite(startedAt)) {
setTickMs(message.taskDurationMs);
return;
}
const update = () => setTickMs(Math.max(0, Date.now() - startedAt));
update();
const id = window.setInterval(update, 1000);
return () => window.clearInterval(id);
}, [isLive, message.createdAt, message.taskDurationMs]);
const durationLabel = useMemo(() => formatTaskDuration(tickMs ?? message.taskDurationMs), [
tickMs,
message.taskDurationMs,
]);
const artifact = message.taskArtifact;
const artifactBits = [
artifact?.title,
artifact?.quality_score != null ? `质量 ${artifact.quality_score}` : null,
].filter(Boolean) as string[];
return (
<div
data-testid="task-run-card"
role="status"
aria-live="polite"
style={{
width: '100%',
margin: '8px 0 4px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
background: 'var(--bg-elevated, var(--bg-subtle))',
overflow: 'hidden',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '10px',
padding: '10px 12px 8px',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '13px',
fontWeight: 700,
color: 'var(--text)',
lineHeight: 1.35,
wordBreak: 'break-word',
}}
>
{title}
</div>
{durationLabel && (
<div
data-testid="task-run-card-duration"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
marginTop: '4px',
fontSize: '11px',
color: 'var(--text-faint)',
}}
>
<Clock3 size={11} />
<span>{isLive ? `已用 ${durationLabel}` : `耗时 ${durationLabel}`}</span>
</div>
)}
</div>
<span
data-testid="task-run-card-badge"
style={{
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '999px',
fontSize: '11px',
fontWeight: 700,
...tone,
}}
>
{badge.tone === 'running' && <Loader2 size={11} className="animate-spin-fast" />}
{badge.label}
</span>
</div>
{isLive && currentDetail && (
<div
data-testid="task-run-card-current"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
padding: '8px 12px',
background: 'rgba(37, 99, 235, 0.06)',
borderBottom: '1px solid var(--border)',
color: 'var(--brand-500)',
fontSize: '12px',
fontWeight: 600,
lineHeight: 1.4,
}}
>
<Loader2 size={13} className="animate-spin-fast" style={{ marginTop: '1px', flexShrink: 0 }} />
<span>{currentDetail}</span>
</div>
)}
{events.length > 0 && (
<ol
data-testid="task-run-card-timeline"
style={{
listStyle: 'none',
margin: 0,
padding: '8px 12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{events.map((evt, idx) => {
const isRunning = evt.status === 'running';
const isFailed = evt.status === 'failed';
const isDone = evt.status === 'completed';
return (
<li
key={`${evt.step}-${idx}`}
data-testid="task-run-card-step"
data-step-status={evt.status}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
padding: isRunning ? '4px 6px' : '2px 6px',
margin: isRunning ? '0 -6px' : 0,
borderRadius: '6px',
background: isRunning ? 'rgba(37, 99, 235, 0.06)' : 'transparent',
color: isDone
? 'var(--text-muted)'
: isRunning
? 'var(--brand-500)'
: isFailed
? 'var(--danger)'
: 'var(--text-faint)',
fontWeight: isRunning || isFailed ? 600 : 500,
fontSize: '12px',
lineHeight: 1.4,
}}
>
<span aria-hidden style={{ width: 16, textAlign: 'center', flexShrink: 0 }}>
{evt.icon}
</span>
<span style={{ flex: 1, minWidth: 0, wordBreak: 'break-word' }}>{evt.label}</span>
<StepStatusIcon status={evt.status} />
</li>
);
})}
</ol>
)}
{events.length === 0 && !isLive && (
<div
style={{
padding: '10px 12px',
fontSize: '12px',
color: 'var(--text-faint)',
}}
>
暂无步骤明细
</div>
)}
{artifactBits.length > 0 && (
<div
data-testid="task-run-card-artifact"
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderTop: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text-muted)',
}}
>
<FileText size={13} style={{ flexShrink: 0 }} />
<span style={{ wordBreak: 'break-word' }}>{artifactBits.join(' · ')}</span>
</div>
)}
</div>
);
};
export default TaskRunCard;
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
formatTaskDuration,
resolveTaskRunCardBadge,
resolveTaskRunCardTitle,
runningPhaseDetail,
shouldRenderTaskRunCard,
truncateTitle,
} from './taskRunCard';
test('resolveTaskRunCardTitle prefers artifact title', () => {
assert.equal(
resolveTaskRunCardTitle({
taskArtifact: { title: '用户初始需求收集表' },
taskRequest: { taskKey: 'intake', userMessage: '写需求表', projectName: '', customerName: '', deviceType: '', sessionId: '', userId: 0, projectId: 0, clientTurnId: '' },
}),
'用户初始需求收集表',
);
});
test('resolveTaskRunCardTitle falls back to user message then taskKey', () => {
assert.equal(
resolveTaskRunCardTitle({
taskRequest: {
taskKey: 'intake',
userMessage: '请编写用户初始需求收集表并补充用途',
projectName: '',
customerName: '',
deviceType: '',
sessionId: '',
userId: 0,
projectId: 0,
clientTurnId: '',
},
}),
'请编写用户初始需求收集表并补充用途',
);
assert.equal(
resolveTaskRunCardTitle({
taskRequest: {
taskKey: 'bd_intake',
projectName: '',
customerName: '',
deviceType: '',
sessionId: '',
userId: 0,
projectId: 0,
clientTurnId: '',
},
}),
'bd_intake',
);
});
test('truncateTitle ellipsizes long text', () => {
const long = '请编写一份完整的用户初始需求收集表并补充预期用途与目标市场信息以便后续法规对齐';
const out = truncateTitle(long, 20);
assert.equal(out.length, 20);
assert.ok(out.endsWith('…'));
});
test('formatTaskDuration covers seconds minutes hours', () => {
assert.equal(formatTaskDuration(undefined), null);
assert.equal(formatTaskDuration(4500), '4s');
assert.equal(formatTaskDuration(65_000), '1m 5s');
assert.equal(formatTaskDuration(3_600_000), '1h');
assert.equal(formatTaskDuration(3_900_000), '1h 5m');
});
test('resolveTaskRunCardBadge maps terminal and live states', () => {
assert.deepEqual(resolveTaskRunCardBadge('completed', false), { label: '已完成', tone: 'success' });
assert.deepEqual(resolveTaskRunCardBadge('streaming_status', true), { label: '执行中', tone: 'running' });
assert.equal(resolveTaskRunCardBadge('failed', false).tone, 'danger');
assert.equal(resolveTaskRunCardBadge('pending_author_confirmation', false).tone, 'warning');
});
test('shouldRenderTaskRunCard keeps card shape when run id exists', () => {
assert.equal(shouldRenderTaskRunCard({ role: 'assistant', taskRunId: 'run-1' }), true);
assert.equal(shouldRenderTaskRunCard({ role: 'user', taskRunId: 'run-1' }), false);
assert.equal(
shouldRenderTaskRunCard({
role: 'assistant',
phaseEvents: [{ step: 'a', icon: 'x', label: 'y', status: 'completed' }],
}),
true,
);
});
test('runningPhaseDetail prefers live label when it differs from step', () => {
assert.equal(
runningPhaseDetail(
[{ step: 'step_executing#1', icon: '⚙️', label: '专员执行', status: 'running', detail: '撰写中' }],
'法规专员正在整理素材…',
),
'法规专员正在整理素材…',
);
assert.equal(
runningPhaseDetail([{ step: 'a', icon: 'x', label: '组建小队', status: 'running', detail: '组建小队' }], '组建小队'),
'组建小队',
);
});
+93
View File
@@ -0,0 +1,93 @@
import type { AssistantMessageStatus, ChatMessage, PhaseEvent } from '../types/workbench';
import { assistantTaskStatusLabel } from '../api/chatWindow';
const TITLE_MAX = 48;
/** 卡片标题:产物名 > 用户原话截断 > taskKey > 默认 */
export function resolveTaskRunCardTitle(message: Pick<
ChatMessage,
'taskArtifact' | 'taskRequest' | 'phaseSnapshot' | 'content'
>): string {
const fromSnapshot = message.phaseSnapshot?.title?.trim();
if (fromSnapshot) return truncateTitle(fromSnapshot);
const fromArtifact = message.taskArtifact?.title?.trim();
if (fromArtifact) return truncateTitle(fromArtifact);
const userMsg = message.taskRequest?.userMessage?.trim();
if (userMsg) return truncateTitle(userMsg);
const taskKey = message.taskRequest?.taskKey?.trim() || message.taskArtifact?.task_key?.trim();
if (taskKey) return truncateTitle(taskKey);
const firstLine = (message.content || '')
.split('\n')
.map((line) => line.replace(/^#+\s*/, '').trim())
.find((line) => line.length > 0 && line !== '任务执行中…');
if (firstLine) return truncateTitle(firstLine);
return '任务执行';
}
export function truncateTitle(text: string, max = TITLE_MAX): string {
const trimmed = text.trim();
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max - 1)}…`;
}
/** 毫秒 → 可读耗时(中文短格式) */
export function formatTaskDuration(ms: number | undefined | null): string | null {
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
const totalSec = Math.floor(ms / 1000);
if (totalSec < 60) return `${totalSec}s`;
const minutes = Math.floor(totalSec / 60);
const seconds = totalSec % 60;
if (minutes < 60) {
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
return remMin > 0 ? `${hours}h ${remMin}m` : `${hours}h`;
}
export type TaskRunCardBadgeTone = 'running' | 'success' | 'warning' | 'danger' | 'muted';
export function resolveTaskRunCardBadge(
status: AssistantMessageStatus | undefined,
hasRunningStep: boolean,
): { label: string; tone: TaskRunCardBadgeTone } {
const chip = assistantTaskStatusLabel(status);
if (chip) {
if (status === 'pending_author_confirmation') return { label: chip, tone: 'warning' };
if (status === 'quality_failed' || status === 'failed') return { label: chip, tone: 'danger' };
if (status === 'cancelled' || status === 'blocked_pending_governance') {
return { label: chip, tone: 'muted' };
}
return { label: chip, tone: 'muted' };
}
if (status === 'completed') return { label: '已完成', tone: 'success' };
if (status === 'streaming_status' || status === 'pending' || status === 'auto_retrying' || hasRunningStep) {
return { label: '执行中', tone: 'running' };
}
return { label: '任务', tone: 'muted' };
}
/** 是否应渲染任务执行卡片(有 run / 进度 / 任务请求即保留卡片形态) */
export function shouldRenderTaskRunCard(
message: Pick<ChatMessage, 'role' | 'taskRunId' | 'phaseEvents' | 'taskRequest' | 'phaseSnapshot'>,
): boolean {
if (message.role !== 'assistant') return false;
if (message.taskRunId) return true;
if (message.phaseSnapshot?.steps?.length) return true;
if (message.phaseEvents && message.phaseEvents.length > 0) return true;
if (message.taskRequest?.taskKey) return true;
return false;
}
export function runningPhaseDetail(events: PhaseEvent[] | undefined, liveLabel?: string): string | undefined {
const running = events?.find((e) => e.status === 'running');
const fromStep = running?.detail?.trim() || running?.label?.trim();
const live = liveLabel?.trim();
if (live && live !== fromStep) return live;
return fromStep || live || undefined;
}
+15
View File
@@ -55,6 +55,17 @@ export interface PhaseEvent {
icon: string;
label: string;
status: 'pending' | 'running' | 'completed' | 'failed';
/** 后端 phase.message 原文(刷新快照 / 时间线当前行展开用) */
detail?: string;
}
/** 服务端写入 chat_messages.metadata.task_phase_snapshot 的折叠进度快照 */
export interface TaskPhaseSnapshot {
steps: PhaseEvent[];
label?: string;
duration_ms?: number;
status?: string;
title?: string;
}
export interface RecommendedCase {
@@ -186,6 +197,10 @@ export interface ChatMessage {
branchTag?: string;
timestamp: string;
phaseEvents?: PhaseEvent[];
/** 任务总耗时(毫秒);done 事件或 snapshot.duration_ms */
taskDurationMs?: number;
/** 历史消息带来的 phase 快照标记(有则刷新不必再 replay) */
phaseSnapshot?: TaskPhaseSnapshot;
recommendedCases?: RecommendedCase[];
interactiveCard?: InteractiveCardData;
status?: AssistantMessageStatus;