Show the final reply apart from the work timeline

Why: Deliverable cards appeared as soon as a file was registered, and the last draft was shown as the answer before the run had finished composing one.
Evidence: vitest run test/live.test.ts test/toolkind.test.ts test/messageparts.test.ts test/design-guard.test.ts
Impact: Cards wait until the run ends. A finalizing phase keeps earlier text in the process block, and check rows render there.
Refs: feature/final-reply-compose
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-02 13:41:36 +00:00
co-authored by Cursor
parent ed95f64890
commit 794c3e1657
9 changed files with 78 additions and 16 deletions
+2 -2
View File
@@ -216,8 +216,8 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
useEffect(() => { useEffect(() => {
if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true) if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true)
}, [live, msg.runId]) }, [live, msg.runId])
const extras = citedArtifactCards(split.final) const extras = live ? [] : citedArtifactCards(split.final)
const cards = dedupeDeliverables(split.final) const cards = live ? [] : dedupeDeliverables(split.final)
const lastText = (() => { const lastText = (() => {
for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i
return -1 return -1
+4
View File
@@ -33,6 +33,10 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
if (row.kind === 'text') { if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div> return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
} }
if (row.kind === 'check') {
const text = row.passed ? '终轮检查通过' : `终轮检查未通过:${row.reason}`
return <div className="tl-check" key={`${idKey}:ck:${i}`}>{text}</div>
}
if (row.kind === 'toolblock') { if (row.kind === 'toolblock') {
return <ToolCallBlock key={`${idKey}:tb:${i}`} item={row.item} /> return <ToolCallBlock key={`${idKey}:tb:${i}`} item={row.item} />
} }
+5 -3
View File
@@ -23,7 +23,7 @@ export type Role = 'user' | 'assistant' | 'system';
export type PartType = export type PartType =
| 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref' | 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref'
| 'hitl' | 'plan' | 'error' | 'card'; | 'hitl' | 'plan' | 'error' | 'card' | 'check';
export interface Part { export interface Part {
type: PartType; type: PartType;
@@ -35,6 +35,7 @@ export interface Part {
plan?: PlanSnapshot; plan?: PlanSnapshot;
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card) card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
error?: { code: string; message: string }; error?: { code: string; message: string };
check?: { passed: boolean; reason?: string }; // 终轮自动检查,过程区一行
process?: boolean; // run 终态后折叠为摘要行 process?: boolean; // run 终态后折叠为摘要行
durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs
} }
@@ -48,7 +49,7 @@ export type RunKind = 'conversation' | 'subagent' | 'tool';
export type RunStatus = export type RunStatus =
| 'pending' | 'running' | 'waiting_hitl' | 'completed' | 'pending' | 'running' | 'waiting_hitl' | 'completed'
| 'failed' | 'cancelled' | 'interrupted'; | 'failed' | 'cancelled' | 'interrupted';
export type RunPhase = 'thinking' | 'acting' | 'responding' | 'done'; export type RunPhase = 'thinking' | 'acting' | 'responding' | 'finalizing' | 'done';
export interface Usage { export interface Usage {
promptTokens: number; completionTokens: number; totalTokens: number; promptTokens: number; completionTokens: number; totalTokens: number;
@@ -82,7 +83,7 @@ export interface TodoList {
export type StreamEventType = export type StreamEventType =
| 'message.delta' | 'thinking.delta' | 'message.delta' | 'thinking.delta'
| 'tool.call.delta' | 'tool.call' | 'tool.output' | 'tool.result' | 'tool.call.delta' | 'tool.call' | 'tool.output' | 'tool.result'
| 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update' | 'run.phase' | 'run.check' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
| 'agent.spawn' | 'agent.progress' | 'agent.result' | 'agent.spawn' | 'agent.progress' | 'agent.result'
| 'artifact.created' | 'artifact.updated' | 'artifact.created' | 'artifact.updated'
| 'run.completed' | 'run.failed' | 'error' | 'run.completed' | 'run.failed' | 'error'
@@ -112,6 +113,7 @@ export interface ContextObservation {
truncations?: string[] truncations?: string[]
estChars: number estChars: number
} }
export interface RunCheckPayload { passed: boolean; reason?: string }
export interface RunFailedPayload { code: string; message: string } export interface RunFailedPayload { code: string; message: string }
// ---- 收件箱卡片 ---- // ---- 收件箱卡片 ----
+25 -5
View File
@@ -26,6 +26,8 @@ export type LiveItem =
summary?: string summary?: string
} }
| { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } } | { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } }
| { kind: 'check'; passed: boolean; reason: string }
| { kind: 'final' }
export type LiveTool = Extract<LiveItem, { kind: 'tool' }> export type LiveTool = Extract<LiveItem, { kind: 'tool' }>
@@ -143,6 +145,16 @@ export function applyToolResult(items: LiveItem[], callId: string | undefined, s
return items return items
} }
export function applyCheck(items: LiveItem[], passed: boolean, reason: string): LiveItem[] {
return [...items, { kind: 'check', passed, reason }]
}
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
if (items.some(it => it.kind === 'final')) return items
return [...items, { kind: 'final' }]
}
export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] { export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
return [...items, { kind: 'artifact', artifact }] return [...items, { kind: 'artifact', artifact }]
} }
@@ -161,17 +173,26 @@ export function applyArtifactUpdated(items: LiveItem[], artifact: { artifactId:
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息。 // liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息。
export function liveToMessage(live: LiveState): Message { export function liveToMessage(live: LiveState): Message {
const finalAt = live.items.findIndex(it => it.kind === 'final')
const textProcess = new Map<number, boolean>() const textProcess = new Map<number, boolean>()
for (let i = 0; i < live.items.length; i++) { for (let i = 0; i < live.items.length; i++) {
if (live.items[i].kind !== 'text') continue if (live.items[i].kind !== 'text') continue
if (finalAt >= 0) {
textProcess.set(i, i < finalAt)
continue
}
let hasCalls = false let hasCalls = false
for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true
textProcess.set(i, hasCalls) textProcess.set(i, hasCalls)
} }
const parts: Part[] = [] const parts: Part[] = []
const deliverables: Part[] = []
for (let i = 0; i < live.items.length; i++) { for (let i = 0; i < live.items.length; i++) {
const it = live.items[i] const it = live.items[i]
if (it.kind === 'final' || it.kind === 'artifact') continue
if (it.kind === 'check') {
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
continue
}
if (it.kind === 'thinking') { if (it.kind === 'thinking') {
const durationMs = it.endedAt - it.startedAt const durationMs = it.endedAt - it.startedAt
if (it.text) parts.push({ if (it.text) parts.push({
@@ -196,21 +217,20 @@ export function liveToMessage(live: LiveState): Message {
toolResult: { callId: call.id, status: it.status === 'error' ? 'error' : 'ok', summary: it.summary }, toolResult: { callId: call.id, status: it.status === 'error' ? 'error' : 'ok', summary: it.summary },
}) })
} }
} else if (it.kind === 'artifact') {
deliverables.push({ type: 'artifact_ref', artifact: it.artifact })
} else { } else {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined }) parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
} }
} }
parts.push(...deliverables)
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' } return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
} }
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。 // livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
export function livePhaseLabel(live: LiveState, now = Date.now()): string { export function livePhaseLabel(live: LiveState, now = Date.now()): string {
if (live.phase === 'finalizing') return '整理回复中'
let last: LiveItem | undefined let last: LiveItem | undefined
for (let i = live.items.length - 1; i >= 0; i--) { for (let i = live.items.length - 1; i >= 0; i--) {
if (live.items[i].kind !== 'artifact') { last = live.items[i]; break } const kind = live.items[i].kind
if (kind !== 'artifact' && kind !== 'check' && kind !== 'final') { last = live.items[i]; break }
} }
if (!last || last.kind === 'thinking') return '思考中' if (!last || last.kind === 'thinking') return '思考中'
if (last.kind === 'tool') { if (last.kind === 'tool') {
+10 -2
View File
@@ -2,7 +2,7 @@
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api' import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, type LiveItem } from './live' import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs' import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox' import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { flashElement } from './scrollsync' import { flashElement } from './scrollsync'
@@ -376,7 +376,15 @@ export const store = {
} }
case 'run.phase': { case 'run.phase': {
const p = e.payload as { phase: string } const p = e.payload as { phase: string }
set({ live: touchLive(e.runId ?? '', l => { l.phase = p.phase }) }) set({ live: touchLive(e.runId ?? '', l => {
l.phase = p.phase
if (p.phase === 'finalizing') l.items = applyFinalizing(l.items)
}) })
break
}
case 'run.check': {
const p = e.payload as { passed?: boolean; reason?: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyCheck(l.items, !!p.passed, p.reason ?? '') }) })
break break
} }
case 'hitl.request': { case 'hitl.request': {
+1
View File
@@ -191,6 +191,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-tools-list li { margin: 1px 0; } .tl-tools-list li { margin: 1px 0; }
.tl-tools-name { color: var(--text-dim); } .tl-tools-name { color: var(--text-dim); }
.tl-tools-sum { margin-left: 8px; color: var(--text-faint); } .tl-tools-sum { margin-left: 8px; color: var(--text-faint); }
.tl-check { color: var(--text-faint); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err); } .tl-tools .status-error { margin-left: 8px; color: var(--err); }
/* 对话右侧预览区 */ /* 对话右侧预览区 */
+6
View File
@@ -104,6 +104,7 @@ export type TimelineEntry =
| { kind: 'text'; text: string } | { kind: 'text'; text: string }
| { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] } | { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] }
| { kind: 'toolblock'; toolKind: ToolKind; item: ToolGroupItem } | { kind: 'toolblock'; toolKind: ToolKind; item: ToolGroupItem }
| { kind: 'check'; passed: boolean; reason: string }
| { kind: 'other'; part: Part } | { kind: 'other'; part: Part }
const SOLO: ReadonlySet<ToolKind> = new Set(['exec', 'write', 'delegate']) const SOLO: ReadonlySet<ToolKind> = new Set(['exec', 'write', 'delegate'])
@@ -169,6 +170,11 @@ export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] {
i++ i++
continue continue
} }
if (p.type === 'check') {
out.push({ kind: 'check', passed: !!p.check?.passed, reason: p.check?.reason ?? '' })
i++
continue
}
out.push({ kind: 'other', part: p }) out.push({ kind: 'other', part: p })
i++ i++
} }
+14 -4
View File
@@ -3,6 +3,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { import {
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
applyCheck, applyFinalizing,
liveToMessage, livePhaseLabel, showProcessInline, type LiveItem, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
} from '../src/live' } from '../src/live'
import type { LiveState } from '../src/store' import type { LiveState } from '../src/store'
@@ -135,8 +136,7 @@ describe('liveToMessage', () => {
const msg = liveToMessage(live({ items })) const msg = liveToMessage(live({ items }))
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true) for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
}) })
it('deliverable cards are content parts pinned after the reply text', () => { it('does not project deliverable cards while the run is live', () => {
// 与落库序同构(llm.go 交付物钉尾):artifact_ref 在终轮正文之后、不折叠。
let items: LiveItem[] = [] let items: LiveItem[] = []
items = applyTextDelta(items, 0, '生成报表。') items = applyTextDelta(items, 0, '生成报表。')
items = applyToolCall(items, 'make_report', 'c1') items = applyToolCall(items, 'make_report', 'c1')
@@ -144,10 +144,19 @@ describe('liveToMessage', () => {
items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' }) items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' })
items = applyTextDelta(items, 1, '完成。') items = applyTextDelta(items, 1, '完成。')
const msg = liveToMessage(live({ items })) const msg = liveToMessage(live({ items }))
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'artifact_ref']) expect(msg.parts.some(p => p.type === 'artifact_ref')).toBe(false)
expect(msg.parts.find(p => p.type === 'artifact_ref')?.process).toBeFalsy() // 内容部件,不折叠
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined]) expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
}) })
it('moves earlier text into the process area once finalizing starts', () => {
let items: LiveItem[] = applyTextDelta([], 0, '草稿回复')
items = applyCheck(items, false, '纸张不是 A4')
items = applyFinalizing(items)
items = applyTextDelta(items, 1, '纪要已写好。')
const msg = liveToMessage(live({ phase: 'finalizing', items }))
expect(msg.parts.map(p => p.type)).toEqual(['text', 'check', 'text'])
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
expect(msg.parts[1]).toMatchObject({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } })
})
}) })
describe('livePhaseLabel', () => { describe('livePhaseLabel', () => {
@@ -161,6 +170,7 @@ describe('livePhaseLabel', () => {
items = applyToolResult(items, 'c', 'ok', 'done', 4000) items = applyToolResult(items, 'c', 'ok', 'done', 4000)
items = applyTextDelta(items, 1, '好了') items = applyTextDelta(items, 1, '好了')
expect(livePhaseLabel(live({ items }), 4000)).toBe('回复中') expect(livePhaseLabel(live({ items }), 4000)).toBe('回复中')
expect(livePhaseLabel(live({ items, phase: 'finalizing' }), 4000)).toBe('整理回复中')
}) })
}) })
+11
View File
@@ -54,6 +54,17 @@ describe('timelineEntries', () => {
expect(exec.item.headline).toBe('print(1)') expect(exec.item.headline).toBe('print(1)')
}) })
it('renders a check row instead of narration', () => {
const rows = timelineEntries([
p({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }),
p({ type: 'check', process: true, check: { passed: true } }),
], false)
expect(rows).toEqual([
{ kind: 'check', passed: false, reason: '纸张不是 A4' },
{ kind: 'check', passed: true, reason: '' },
])
})
it('marks the trailing thinking row active while the run is live', () => { it('marks the trailing thinking row active while the run is live', () => {
const rows = timelineEntries([p({ type: 'thinking', process: true, text: '还在想' })], true) const rows = timelineEntries([p({ type: 'thinking', process: true, text: '还在想' })], true)
expect(rows[0]).toMatchObject({ kind: 'thinking', active: true }) expect(rows[0]).toMatchObject({ kind: 'thinking', active: true })