From 7f88b6d882e7e5771d4bcd0a18ee5472dac6917d Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 10:04:05 +0000 Subject: [PATCH] fix(web): map pending_author_confirmation instead of defaulting completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/App.tsx | 39 +++++++-- web/src/api/chatWindow.ts | 59 +++++++++++--- web/src/components/chat/MessageBubble.tsx | 49 ++++++++++- .../chat/chatWindowArtifact.test.ts | 81 ++++++++++++++++++- web/src/types/workbench.ts | 5 ++ 5 files changed, 212 insertions(+), 21 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 913ecb73..3fb5f2c0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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; diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 8a1cba46..9049f9cc 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -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, diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 853d8a32..afcc5df9 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -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 = ({ !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 = ({ 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 = ({ ) : ( <> + {taskStatusLabel && !isWaiting && ( +
+ {isPendingAuthorConfirmation && } + {taskStatusLabel} + {isPendingAuthorConfirmation && message.taskArtifact?.pending_risk_level && ( + + · {message.taskArtifact.pending_risk_level} + + )} +
+ )} {isWaiting && ( /* 等待态:脉冲小图标 + 当前阶段文案,禁止把空正文当成成功渲染 */
{ 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, diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index f16a427d..2a096d29 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -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(一次发送的用户+助手共享) */