fix(web): map pending_author_confirmation instead of defaulting completed

History refresh now preserves awaiting-author status and shows a visible
待编制人确认 label; live done path reads run terminal status before paint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 10:04:05 +00:00
co-authored by Cursor
parent 9ed5987104
commit 7f88b6d882
5 changed files with 212 additions and 21 deletions
+31 -8
View File
@@ -161,8 +161,8 @@ import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine
import { ChatExportModal } from './components/history/ChatExportModal';
import { useUnreadMarks } from './hooks/useUnreadMarks';
import { CategoryDock } from './components/chat/CategoryDock';
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench';
import { saveFile, restoreFileRawToDeliverables } from './data/fileStore';
import { uploadProjectAsset } from './api/projectAssets';
import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns';
@@ -2547,13 +2547,25 @@ export default function App() {
}
case 'done': {
settled = true;
const finishedRunId = taskRunStream.activeTaskRunId;
taskRunStream.setRunId(sessionId, null);
patchAsst({
status: 'completed',
livePhaseLabel: undefined,
taskWorkspaceId: ev.done?.workspace_id,
});
taskRunStream.setThinking(sessionId, false);
void (async () => {
let nextStatus: AssistantMessageStatus = 'completed';
if (finishedRunId) {
const remote = await getTaskRun(finishedRunId).catch(() => null);
if (remote?.status) {
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant');
if (mapped) nextStatus = mapped;
}
}
patchAsst({
status: nextStatus,
livePhaseLabel: undefined,
taskWorkspaceId: ev.done?.workspace_id,
});
await chatWindow.refresh().catch(() => undefined);
})();
break;
}
default:
@@ -2596,7 +2608,7 @@ export default function App() {
if (runId) {
try {
const remote = await getTaskRun(runId);
if (remote.status === 'completed') {
if (remote.status === 'completed' || remote.status === 'quality_failed') {
settled = true;
patchAsst({
status: 'completed',
@@ -2606,6 +2618,17 @@ export default function App() {
taskRunStream.setThinking(sessionId, false);
return;
}
if (remote.status === 'pending_author_confirmation') {
settled = true;
patchAsst({
status: 'pending_author_confirmation',
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
});
taskRunStream.setThinking(sessionId, false);
await chatWindow.refresh().catch(() => undefined);
return;
}
if (remote.status === 'failed' || remote.status === 'cancelled') {
failOrRetry(buildNetworkErrorInfo());
return;
+49 -10
View File
@@ -1,4 +1,5 @@
import {
AssistantMessageStatus,
ChatMessage,
ChatDayBucket,
ChatWindowResult,
@@ -9,6 +10,53 @@ import { MessageContentKind, MessageExportCapability } from '../types/replyExpor
import { getBearerAuthHeaders } from './authHeaders';
import { mapErrorInfo } from './chatStream';
/** Map server metadata.task_status → assistant bubble status (history refresh path). */
export function mapTaskStatusToMessageStatus(
taskStatus: unknown,
role: RawChatMessageRecord['role'] | string,
): AssistantMessageStatus | undefined {
if (role !== 'assistant') return undefined;
const status = typeof taskStatus === 'string' ? taskStatus : '';
switch (status) {
case 'running':
return 'streaming_status';
case 'failed':
case 'finalizing_failed':
return 'failed';
case 'pending_author_confirmation':
return 'pending_author_confirmation';
case 'quality_failed':
case 'completed':
case '':
return 'completed';
default:
// Never treat an unknown pending-like status as completed.
if (status.includes('pending') && status.includes('confirm')) {
return 'pending_author_confirmation';
}
return 'completed';
}
}
/** Visible status chip label for assistant task bubbles (incl. after history refresh). */
export function assistantTaskStatusLabel(status: AssistantMessageStatus | undefined): string | null {
switch (status) {
case 'pending_author_confirmation':
return '待编制人确认';
case 'failed':
return '执行失败';
case 'cancelled':
return '已停止';
case 'streaming_status':
case 'pending':
case 'auto_retrying':
return null;
case 'completed':
default:
return null;
}
}
export interface RawChatMessageRecord {
id: number;
session_id: string;
@@ -140,16 +188,7 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
interactiveCard: meta.interactive_card,
recommendedCases: meta.recommended_cases,
quotes: meta.quotes || (meta.quote ? [meta.quote] : undefined),
status:
r.role === 'assistant'
? meta.task_status === 'running'
? 'streaming_status'
: meta.task_status === 'failed' || meta.task_status === 'finalizing_failed'
? 'failed'
: meta.task_status === 'quality_failed'
? 'completed'
: 'completed'
: undefined,
status: mapTaskStatusToMessageStatus(meta.task_status, r.role),
errorInfo: persistedError || legacyBusyError || legacyTaskError,
taskRequest,
taskRunId: typeof meta.run_id === 'string' ? meta.run_id : undefined,
+47 -2
View File
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { ChatErrorInfo, ChatMessage } from '../../types/workbench';
import { assistantTaskStatusLabel } from '../../api/chatWindow';
import { PhaseEventStrip } from './PhaseEventStrip';
import { RecommendedCaseCard } from './RecommendedCaseCard';
import { InteractiveCard } from './InteractiveCard';
@@ -60,6 +61,8 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
!isUser && (status === 'pending' || status === 'streaming_status' || status === 'auto_retrying');
const isFailed = !isUser && status === 'failed' && !!message.errorInfo;
const isCancelled = !isUser && status === 'cancelled';
const isPendingAuthorConfirmation = !isUser && status === 'pending_author_confirmation';
const taskStatusLabel = assistantTaskStatusLabel(status);
const hasContent = !!message.content && message.content.trim().length > 0;
const [diagCopied, setDiagCopied] = useState(false);
const [diagCopyFailed, setDiagCopyFailed] = useState(false);
@@ -264,9 +267,21 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
style={{
padding: '12px 14px',
borderRadius: isUser ? '14px 4px 14px 14px' : '4px 14px 14px 14px',
background: isUser ? 'var(--brand-500)' : isFailed ? 'var(--danger-bg)' : 'var(--bg-panel)',
background: isUser
? 'var(--brand-500)'
: isFailed
? 'var(--danger-bg)'
: isPendingAuthorConfirmation
? 'rgba(245, 158, 11, 0.08)'
: 'var(--bg-panel)',
color: isUser ? '#ffffff' : 'var(--text-strong)',
border: isUser ? 'none' : isFailed ? '1px solid var(--danger)' : '1px solid var(--border)',
border: isUser
? 'none'
: isFailed
? '1px solid var(--danger)'
: isPendingAuthorConfirmation
? '1px solid rgba(245, 158, 11, 0.45)'
: '1px solid var(--border)',
boxShadow: isUser ? '0 2px 8px rgba(79, 107, 255, 0.25)' : 'var(--shadow-xs)',
fontSize: '13.5px',
lineHeight: 1.6,
@@ -401,6 +416,36 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
</div>
) : (
<>
{taskStatusLabel && !isWaiting && (
<div
data-testid="assistant-task-status-label"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginBottom: hasContent ? '8px' : 0,
padding: '3px 8px',
borderRadius: '999px',
fontSize: '11px',
fontWeight: 700,
color: isPendingAuthorConfirmation ? '#b45309' : 'var(--text-muted)',
background: isPendingAuthorConfirmation
? 'rgba(245, 158, 11, 0.16)'
: 'var(--bg-subtle)',
border: isPendingAuthorConfirmation
? '1px solid rgba(245, 158, 11, 0.35)'
: '1px solid var(--border)',
}}
>
{isPendingAuthorConfirmation && <AlertTriangle size={12} />}
<span>{taskStatusLabel}</span>
{isPendingAuthorConfirmation && message.taskArtifact?.pending_risk_level && (
<span style={{ fontWeight: 600, opacity: 0.85 }}>
· {message.taskArtifact.pending_risk_level}
</span>
)}
</div>
)}
{isWaiting && (
/* 等待态:脉冲小图标 + 当前阶段文案,禁止把空正文当成成功渲染 */
<div
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { mapRecordToMessage } from '../../api/chatWindow';
import {
assistantTaskStatusLabel,
mapRawBucket,
mapRecordToMessage,
mapTaskStatusToMessageStatus,
} from '../../api/chatWindow';
describe('mapRecordToMessage artifact projection', () => {
it('maps completed task message with artifact metadata', () => {
@@ -28,6 +33,80 @@ describe('mapRecordToMessage artifact projection', () => {
expect(msg.status).toBe('completed');
});
it('maps pending_author_confirmation after history refresh (not completed)', () => {
const msg = mapRecordToMessage({
id: 301,
session_id: 'sess-p56-u3',
user_id: 3,
day_key: '2026-09-13',
role: 'assistant',
content:
'《用户初始需求收集表》文档候选已生成,待编制人确认。\n\n当前状态:**待编制人确认**(风险:high)',
metadata: {
task_status: 'pending_author_confirmation',
task_key: 'intake',
project_id: 56,
run_id: 'run_pending_1',
artifact: {
doc_id: 'doc_proj56_intake',
title: '用户初始需求收集表',
pending_agent_candidate_id: 55,
pending_change_report_id: 8,
pending_risk_level: 'high',
awaiting_author_confirmation: true,
},
},
created_at: '2026-09-13T02:00:00Z',
});
expect(msg.status).toBe('pending_author_confirmation');
expect(msg.status).not.toBe('completed');
expect(assistantTaskStatusLabel(msg.status)).toBe('待编制人确认');
expect(msg.taskArtifact?.doc_id).toBe('doc_proj56_intake');
expect(msg.taskArtifact?.pending_agent_candidate_id).toBe(55);
expect(msg.taskArtifact?.awaiting_author_confirmation).toBe(true);
});
it('refresh day bucket keeps pending status label contract', () => {
const bucket = mapRawBucket({
day_key: '2026-09-13',
count: 1,
loaded_count: 1,
truncated: false,
first_message_id: 301,
last_message_id: 301,
messages: [
{
id: 301,
session_id: 'sess-refresh',
user_id: 0,
day_key: '2026-09-13',
role: 'assistant',
content: '文档候选已生成,待编制人确认。',
metadata: {
task_status: 'pending_author_confirmation',
artifact: { doc_id: 'doc_x', pending_risk_level: 'critical' },
},
created_at: '2026-09-13T04:00:00Z',
},
],
});
expect(bucket.messages).toHaveLength(1);
expect(bucket.messages[0].status).toBe('pending_author_confirmation');
expect(assistantTaskStatusLabel(bucket.messages[0].status)).toBe('待编制人确认');
});
it('mapTaskStatusToMessageStatus covers explicit pending and known terminals', () => {
expect(mapTaskStatusToMessageStatus('pending_author_confirmation', 'assistant')).toBe(
'pending_author_confirmation',
);
expect(mapTaskStatusToMessageStatus('completed', 'assistant')).toBe('completed');
expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('completed');
expect(mapTaskStatusToMessageStatus('failed', 'assistant')).toBe('failed');
expect(mapTaskStatusToMessageStatus('running', 'assistant')).toBe('streaming_status');
expect(mapTaskStatusToMessageStatus('pending_author_confirmation', 'user')).toBeUndefined();
});
it('restores structured task error and retry context after refresh', () => {
const msg = mapRecordToMessage({
id: 99,
+5
View File
@@ -114,6 +114,7 @@ export type AssistantMessageStatus =
| 'streaming_status'
| 'auto_retrying'
| 'completed'
| 'pending_author_confirmation'
| 'failed'
| 'cancelled';
@@ -200,6 +201,10 @@ export interface ChatMessage {
quality_passed?: boolean;
quality_score?: number;
quality_violations?: string[];
pending_agent_candidate_id?: number;
pending_change_report_id?: number;
pending_risk_level?: string;
awaiting_author_confirmation?: boolean;
};
/** 客户端回合 ID(一次发送的用户+助手共享) */