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:
+31
-8
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(一次发送的用户+助手共享) */
|
||||
|
||||
Reference in New Issue
Block a user