feat(web): restore TaskRunCard from phase snapshot on refresh
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -436,7 +436,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{taskStatusLabel && !isWaiting && (
|
||||
{taskStatusLabel && !isWaiting && !showTaskRunCard && (
|
||||
<div
|
||||
data-testid="assistant-task-status-label"
|
||||
style={{
|
||||
|
||||
@@ -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, '门禁');
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user