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>
95 lines
3.9 KiB
TypeScript
95 lines
3.9 KiB
TypeScript
// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
|
|
// 终轮结束后由调用方套进「已工作 X」折叠。
|
|
import type { ReactNode } from 'react'
|
|
import { Collapsible, ToolBlock } from '@ui'
|
|
import type { Part } from '../contracts'
|
|
import { formatDuration, humanSize } from '../messageparts'
|
|
import { timelineEntries, toolGroupLabel, toolTail, type ToolGroupItem } from '../toolkind'
|
|
|
|
export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
|
parts: Part[]
|
|
live: boolean
|
|
idKey: string
|
|
renderPart: (part: Part, liveText: boolean) => ReactNode
|
|
}) {
|
|
const rows = timelineEntries(parts, live)
|
|
return (
|
|
<div className="tl">
|
|
{rows.map((row, i) => {
|
|
if (row.kind === 'thinking') {
|
|
const dur = formatDuration(row.durationMs)
|
|
const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
|
|
return (
|
|
<div className="part-thinking" key={`${idKey}:th:${i}`}>
|
|
<Collapsible id={`think:${idKey}:${i}:${row.active ? 'live' : 'done'}`} defaultOpen={row.active} key={row.active ? 'open' : 'shut'}
|
|
triggerClassName="part-thinking-summary"
|
|
trigger={<><span className="part-thinking-chev">▸</span>
|
|
<span className={row.active ? 'mk-sweep' : ''}>{label}</span></>}>
|
|
<div className="part-thinking-body">{row.text}</div>
|
|
</Collapsible>
|
|
</div>
|
|
)
|
|
}
|
|
if (row.kind === 'text') {
|
|
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') {
|
|
return <ToolCallBlock key={`${idKey}:tb:${i}`} item={row.item} />
|
|
}
|
|
if (row.kind === 'tools') {
|
|
const running = row.items.some(it => it.running)
|
|
return (
|
|
<div className="tl-tools" key={`${idKey}:tl:${i}`}>
|
|
<div className={`tl-tools-head${running ? ' mk-sweep' : ''}`}>{toolGroupLabel(row.toolKind, row.items.length, running)}</div>
|
|
<ul className="tl-tools-list">
|
|
{row.items.map((it, j) => (
|
|
<li key={j}>
|
|
<span className="tl-tools-name">{it.headline}</span>
|
|
{it.status === 'error' && <span className="status-error">失败</span>}
|
|
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)
|
|
}
|
|
return <div className="part" key={`${idKey}:ot:${i}`}>{renderPart(row.part, false)}</div>
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function sizeLabel(n: number): string {
|
|
if (n < 1024) return `${n} 字符`
|
|
return humanSize(n)
|
|
}
|
|
|
|
function ToolCallBlock({ item }: { item: ToolGroupItem }) {
|
|
const composing = item.phase === 'composing'
|
|
const tail = item.running ? toolTail(item.arguments, item.output) : ''
|
|
const dur = !item.running ? formatDuration(item.durationMs) : ''
|
|
const detail = composing ? undefined : (dur ? `${item.headline} · ${dur}` : item.headline)
|
|
return (
|
|
<div className="tl-toolblock">
|
|
<ToolBlock
|
|
toolId={item.id}
|
|
name={item.name}
|
|
detail={detail}
|
|
running={item.running}
|
|
status={item.status}
|
|
statusText={composing ? `编写中 · ${sizeLabel(item.arguments?.length ?? 0)}` : undefined}
|
|
statusDetail={item.summary}
|
|
autoCollapseOnComplete
|
|
>
|
|
{item.arguments && <pre className="tool-preview">{item.arguments}</pre>}
|
|
{item.output && <pre className="tool-preview">{item.output}</pre>}
|
|
</ToolBlock>
|
|
{tail && <pre className="tool-live-preview">{tail}</pre>}
|
|
</div>
|
|
)
|
|
}
|