feat(web): restore TaskRunCard from phase snapshot on refresh

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 32209c5e1b
commit ae1e2475f3
5 changed files with 133 additions and 5 deletions
+48
View File
@@ -5,11 +5,54 @@ import {
ChatWindowResult,
ChatDateStat,
ChatCategoryStat,
PhaseEvent,
TaskPhaseSnapshot,
} from '../types/workbench';
import { MessageContentKind, MessageExportCapability } from '../types/replyExport';
import { getBearerAuthHeaders } from './authHeaders';
import { mapErrorInfo } from './chatStream';
/** 从 metadata.task_phase_snapshot 重建前端快照(兼容 JSON 对象)。 */
export function parseTaskPhaseSnapshot(raw: unknown): TaskPhaseSnapshot | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const obj = raw as Record<string, unknown>;
const stepsRaw = Array.isArray(obj.steps) ? obj.steps : [];
const steps: PhaseEvent[] = [];
for (const item of stepsRaw) {
if (!item || typeof item !== 'object') continue;
const step = item as Record<string, unknown>;
const status = step.status;
if (
status !== 'pending' &&
status !== 'running' &&
status !== 'completed' &&
status !== 'failed'
) {
continue;
}
steps.push({
step: String(step.step || ''),
icon: String(step.icon || '⏳'),
label: String(step.label || ''),
status,
detail: typeof step.detail === 'string' ? step.detail : undefined,
});
}
const duration =
typeof obj.duration_ms === 'number'
? obj.duration_ms
: typeof obj.durationMs === 'number'
? obj.durationMs
: undefined;
return {
steps,
label: typeof obj.label === 'string' ? obj.label : undefined,
duration_ms: duration,
status: typeof obj.status === 'string' ? obj.status : undefined,
title: typeof obj.title === 'string' ? obj.title : undefined,
};
}
/** Map server metadata.task_status → assistant bubble status (history refresh path). */
export function mapTaskStatusToMessageStatus(
taskStatus: unknown,
@@ -184,6 +227,7 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
}
: undefined;
const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`);
const phaseSnapshot = parseTaskPhaseSnapshot(meta.task_phase_snapshot);
return {
id: msgId,
clientTurnId: meta.client_turn_id || undefined,
@@ -212,6 +256,10 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
meta.artifact && typeof meta.artifact === 'object'
? (meta.artifact as ChatMessage['taskArtifact'])
: undefined,
phaseSnapshot,
phaseEvents: phaseSnapshot?.steps?.length ? phaseSnapshot.steps : undefined,
livePhaseLabel: phaseSnapshot?.label,
taskDurationMs: phaseSnapshot?.duration_ms,
contentKind: r.content_kind,
exportCapability: r.export_capability,
};
+1 -1
View File
@@ -436,7 +436,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
</div>
) : (
<>
{taskStatusLabel && !isWaiting && (
{taskStatusLabel && !isWaiting && !showTaskRunCard && (
<div
data-testid="assistant-task-status-label"
style={{
+36
View File
@@ -129,3 +129,39 @@ test('Category navigation: chip 选中后准确匹配目标分类最新轮次并
const consultNormalizedId = resolveTurnAnchorId(latestConsult.serverId || latestConsult.id, allMessages);
assert.equal(consultNormalizedId, 103); // 提问本身即为用户消息
});
test('mapRecordToMessage: restores task phase snapshot into card fields', () => {
const raw = {
id: 2048,
session_id: 'sess_task',
user_id: 1,
role: 'assistant' as const,
content: '已完成用户初始需求收集表',
intent: 'TRIGGER_TASK',
metadata: {
run_id: 'run_snap_1',
task_status: 'completed',
artifact: { title: '用户初始需求收集表', quality_score: 91 },
task_phase_snapshot: {
title: '用户初始需求收集表',
duration_ms: 42000,
label: '质量门禁通过',
status: 'completed',
steps: [
{ step: 'squad_assembly', icon: '👥', label: '组建小队', status: 'completed' },
{ step: 'quality_gate', icon: '🛡️', label: '质量门禁(95%)', status: 'completed', detail: '门禁' },
],
},
},
day_key: '2026-09-21',
created_at: '2026-09-21T10:00:00+08:00',
};
const msg = mapRecordToMessage(raw);
assert.equal(msg.taskRunId, 'run_snap_1');
assert.equal(msg.status, 'completed');
assert.equal(msg.taskDurationMs, 42000);
assert.equal(msg.livePhaseLabel, '质量门禁通过');
assert.equal(msg.phaseEvents?.length, 2);
assert.equal(msg.phaseSnapshot?.title, '用户初始需求收集表');
assert.equal(msg.phaseEvents?.[1].detail, '门禁');
});
+25
View File
@@ -42,6 +42,31 @@ test('bubble that already has phases is not replayed', () => {
);
});
test('completed bubble without snapshot needs replay for legacy rows', () => {
assert.equal(
messageNeedsPhaseReplay({
role: 'assistant',
taskRunId: 'run-legacy',
status: 'completed',
}),
true,
);
});
test('completed bubble with snapshot is not replayed', () => {
assert.equal(
messageNeedsPhaseReplay({
role: 'assistant',
taskRunId: 'run-new',
status: 'completed',
phaseSnapshot: {
steps: [{ step: 'squad_assembly', icon: '👥', label: '组建小队', status: 'completed' }],
},
}),
false,
);
});
test('phase replay key ignores completed chatter', () => {
assert.equal(
phaseReplayKey([
+23 -4
View File
@@ -1,17 +1,36 @@
import type { ChatMessage, PhaseEvent } from '../types/workbench';
/** 刷新后需要从 task_run_events 重建进度条的气泡。已有步骤则不再拉。 */
const TERMINAL_REPLAY_STATUSES = new Set([
'completed',
'failed',
'quality_failed',
'cancelled',
'pending_author_confirmation',
'blocked_pending_governance',
]);
/** 刷新后需要从 task_run_events 重建进度条的气泡。已有步骤或快照则不再拉。 */
export function messageNeedsPhaseReplay(
message: Pick<ChatMessage, 'role' | 'taskRunId' | 'phaseEvents' | 'status' | 'errorInfo'>,
message: Pick<
ChatMessage,
'role' | 'taskRunId' | 'phaseEvents' | 'phaseSnapshot' | 'status' | 'errorInfo'
>,
): boolean {
if (message.role !== 'assistant' || !message.taskRunId) return false;
if (message.phaseEvents && message.phaseEvents.length > 0) return false;
if (message.phaseSnapshot?.steps && message.phaseSnapshot.steps.length > 0) return false;
if (message.status === 'streaming_status') return true;
return message.errorInfo?.code === 'TASK_INTERRUPTED';
if (message.errorInfo?.code === 'TASK_INTERRUPTED') return true;
// 存量终态消息:无 snapshot、无步骤时扩面重放,避免卡片退化成纯文本。
if (message.status && TERMINAL_REPLAY_STATUSES.has(message.status)) return true;
return false;
}
export function phaseReplayKey(
messages: Pick<ChatMessage, 'id' | 'role' | 'taskRunId' | 'phaseEvents' | 'status' | 'errorInfo'>[],
messages: Pick<
ChatMessage,
'id' | 'role' | 'taskRunId' | 'phaseEvents' | 'phaseSnapshot' | 'status' | 'errorInfo'
>[],
): string {
return messages
.filter(messageNeedsPhaseReplay)