From ae1e2475f3271b1d75bd5da5ccb40e9aff86b416 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 08:13:13 +0000 Subject: [PATCH] feat(web): restore TaskRunCard from phase snapshot on refresh Co-authored-by: Cursor --- web/src/api/chatWindow.ts | 48 +++++++++++++++++++++++ web/src/components/chat/MessageBubble.tsx | 2 +- web/src/hooks/useChatWindow.test.ts | 36 +++++++++++++++++ web/src/lib/taskPhaseReplay.test.ts | 25 ++++++++++++ web/src/lib/taskPhaseReplay.ts | 27 +++++++++++-- 5 files changed, 133 insertions(+), 5 deletions(-) diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 70c31a38..b73e48e5 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -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; + 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; + 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, }; diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 5bd324d7..7d40a608 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -436,7 +436,7 @@ const MessageBubbleInner: React.FC = ({ ) : ( <> - {taskStatusLabel && !isWaiting && ( + {taskStatusLabel && !isWaiting && !showTaskRunCard && (
{ + 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, '门禁'); +}); diff --git a/web/src/lib/taskPhaseReplay.test.ts b/web/src/lib/taskPhaseReplay.test.ts index ee1b36ab..e59e177e 100644 --- a/web/src/lib/taskPhaseReplay.test.ts +++ b/web/src/lib/taskPhaseReplay.test.ts @@ -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([ diff --git a/web/src/lib/taskPhaseReplay.ts b/web/src/lib/taskPhaseReplay.ts index 484609ba..e57f78a0 100644 --- a/web/src/lib/taskPhaseReplay.ts +++ b/web/src/lib/taskPhaseReplay.ts @@ -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, + 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[], + messages: Pick< + ChatMessage, + 'id' | 'role' | 'taskRunId' | 'phaseEvents' | 'phaseSnapshot' | 'status' | 'errorInfo' + >[], ): string { return messages .filter(messageNeedsPhaseReplay)