feat(ui): render todo plans and streamed tool kinds in both views
todo/write events fold into plan targets: the chat transcript and trajectory show a checklist card (done counts, active item), and live ACP plan updates render the same card while the turn streams — the task list was previously invisible on both paths. Tool rows label themselves with the streamed ACP kind verb (read/edit/search/run…) instead of a generic noun, and touched-file locations open in the OS editor from the expanded row.
This commit is contained in:
@@ -74,12 +74,24 @@ interface LiveTurn {
|
|||||||
userText?: string
|
userText?: string
|
||||||
thinking: string
|
thinking: string
|
||||||
answer: string
|
answer: string
|
||||||
|
plan?: readonly PlanItem[]
|
||||||
tools: LiveTool[]
|
tools: LiveTool[]
|
||||||
expectedCompletedTurns: number
|
expectedCompletedTurns: number
|
||||||
status: 'sending' | 'streaming' | 'complete' | 'error'
|
status: 'sending' | 'streaming' | 'complete' | 'error'
|
||||||
errorText?: string
|
errorText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlanItem {
|
||||||
|
readonly content: string
|
||||||
|
readonly status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveToolMeta {
|
||||||
|
title?: string
|
||||||
|
kind?: string
|
||||||
|
locations?: readonly { path: string; line?: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
interface LiveTool {
|
interface LiveTool {
|
||||||
readonly callId: string
|
readonly callId: string
|
||||||
title: string
|
title: string
|
||||||
@@ -157,7 +169,7 @@ const state = {
|
|||||||
expandedActivityIds: new Set<string>(),
|
expandedActivityIds: new Set<string>(),
|
||||||
traceLoadRevision: 0,
|
traceLoadRevision: 0,
|
||||||
liveTurnCounter: 0,
|
liveTurnCounter: 0,
|
||||||
liveToolTitles: new Map<string, string>(),
|
liveToolMeta: new Map<string, LiveToolMeta>(),
|
||||||
traceCatchupTimer: undefined as number | undefined,
|
traceCatchupTimer: undefined as number | undefined,
|
||||||
traceCatchupAttempts: 0,
|
traceCatchupAttempts: 0,
|
||||||
}
|
}
|
||||||
@@ -562,9 +574,17 @@ function handleSessionUpdate(payload: SessionUpdatePayload): void {
|
|||||||
} else if (kind === 'agent_thought_chunk') {
|
} else if (kind === 'agent_thought_chunk') {
|
||||||
live.thinking += contentText(update.content)
|
live.thinking += contentText(update.content)
|
||||||
live.status = 'streaming'
|
live.status = 'streaming'
|
||||||
|
} else if (kind === 'plan') {
|
||||||
|
const entries = Array.isArray(update.entries) ? update.entries : []
|
||||||
|
live.plan = entries.map((entry): PlanItem => ({ content: String(asRecord(entry).content ?? ''), status: String(asRecord(entry).status ?? 'pending') }))
|
||||||
|
live.status = 'streaming'
|
||||||
} else if (kind === 'tool_call' || kind === 'tool_call_update') {
|
} else if (kind === 'tool_call' || kind === 'tool_call_update') {
|
||||||
const callId = String(update.toolCallId ?? `tool-${live.tools.length}`)
|
const callId = String(update.toolCallId ?? `tool-${live.tools.length}`)
|
||||||
if (typeof update.title === 'string' && update.title.length > 0) state.liveToolTitles.set(callId, update.title)
|
const meta = state.liveToolMeta.get(callId) ?? {}
|
||||||
|
if (typeof update.title === 'string' && update.title.length > 0) meta.title = update.title
|
||||||
|
if (typeof update.kind === 'string' && update.kind.length > 0) meta.kind = update.kind
|
||||||
|
if (Array.isArray(update.locations)) meta.locations = update.locations.map(location => ({ path: String(asRecord(location).path ?? ''), ...(asRecord(location).line === undefined ? {} : { line: Number(asRecord(location).line) }) }))
|
||||||
|
state.liveToolMeta.set(callId, meta)
|
||||||
const existing = live.tools.find(tool => tool.callId === callId)
|
const existing = live.tools.find(tool => tool.callId === callId)
|
||||||
const title = String(update.title ?? existing?.title ?? t('chat.toolUse'))
|
const title = String(update.title ?? existing?.title ?? t('chat.toolUse'))
|
||||||
const status = String(update.status ?? existing?.status ?? '')
|
const status = String(update.status ?? existing?.status ?? '')
|
||||||
@@ -616,6 +636,11 @@ function renderLiveTurn(): void {
|
|||||||
if (body !== null) body.textContent = tool.detail
|
if (body !== null) body.textContent = tool.detail
|
||||||
row.classList.toggle('failed', tool.status === 'failed')
|
row.classList.toggle('failed', tool.status === 'failed')
|
||||||
}
|
}
|
||||||
|
const planHost = el.liveTurn.querySelector<HTMLElement>('[data-live="plan"]')
|
||||||
|
if (planHost !== null) {
|
||||||
|
planHost.hidden = live.plan === undefined || live.plan.length === 0
|
||||||
|
planHost.innerHTML = live.plan === undefined ? '' : renderPlanList(live.plan)
|
||||||
|
}
|
||||||
const answer = el.liveTurn.querySelector<HTMLElement>('[data-live="answer"]')
|
const answer = el.liveTurn.querySelector<HTMLElement>('[data-live="answer"]')
|
||||||
if (answer !== null) {
|
if (answer !== null) {
|
||||||
answer.hidden = live.answer.length === 0
|
answer.hidden = live.answer.length === 0
|
||||||
@@ -651,6 +676,7 @@ function ensureLiveSkeleton(live: LiveTurn): void {
|
|||||||
<article class="message assistant live" data-live-key="${live.key}">
|
<article class="message assistant live" data-live-key="${live.key}">
|
||||||
<div class="avatar">A</div>
|
<div class="avatar">A</div>
|
||||||
<div class="message-card">
|
<div class="message-card">
|
||||||
|
<div class="plan-host" data-live="plan" hidden></div>
|
||||||
<div class="activity-list">
|
<div class="activity-list">
|
||||||
<details class="chat-activity thinking" data-live="thinking" hidden>
|
<details class="chat-activity thinking" data-live="thinking" hidden>
|
||||||
<summary><span>${escapeHtml(t('chat.thinking'))}</span><strong></strong></summary>
|
<summary><span>${escapeHtml(t('chat.thinking'))}</span><strong></strong></summary>
|
||||||
@@ -940,6 +966,9 @@ function renderConversationActivity(activity: ChatActivity): string {
|
|||||||
const target = state.graph.targets.get(activity.targetId)
|
const target = state.graph.targets.get(activity.targetId)
|
||||||
if (target === undefined) return ''
|
if (target === undefined) return ''
|
||||||
if (activity.kind === 'tool') return renderChatToolActivity(target)
|
if (activity.kind === 'tool') return renderChatToolActivity(target)
|
||||||
|
if (activity.kind === 'plan') {
|
||||||
|
return `<section class="plan-activity ${selectedTargetClass(target.id)}" data-target-id="${escapeHtml(target.id)}">${renderPlanList(planItemsOf(target))}</section>`
|
||||||
|
}
|
||||||
if (activity.kind === 'text') {
|
if (activity.kind === 'text') {
|
||||||
return `<section class="assistant-prose assistant-segment ${selectedTargetClass(target.id)}" data-target-id="${escapeHtml(target.id)}">${renderMarkdown(assistantText(target.output) || contentText(target.output))}</section>`
|
return `<section class="assistant-prose assistant-segment ${selectedTargetClass(target.id)}" data-target-id="${escapeHtml(target.id)}">${renderMarkdown(assistantText(target.output) || contentText(target.output))}</section>`
|
||||||
}
|
}
|
||||||
@@ -955,9 +984,45 @@ function renderConversationActivity(activity: ChatActivity): string {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One checklist card shared by the live turn and the persisted transcript. */
|
||||||
|
function renderPlanList(items: readonly PlanItem[]): string {
|
||||||
|
const done = items.filter(item => item.status === 'completed').length
|
||||||
|
const glyph = (status: string): string => status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
|
||||||
|
return `
|
||||||
|
<section class="plan-card">
|
||||||
|
<header><strong>${escapeHtml(t('chat.planList'))}</strong><span>${done}/${items.length}</span></header>
|
||||||
|
<ul>
|
||||||
|
${items.map(item => `<li class="plan-item ${escapeHtml(item.status)}"><span class="plan-glyph">${glyph(item.status)}</span><span>${escapeHtml(item.content)}</span></li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function planItemsOf(target: TraceTarget): PlanItem[] {
|
||||||
|
return (Array.isArray(target.output) ? target.output : []).map(item => ({ content: String(asRecord(item).content ?? ''), status: String(asRecord(item).status ?? 'pending') }))
|
||||||
|
}
|
||||||
|
|
||||||
/** ACP streams richer tool titles than the persisted name; keep them after the turn. */
|
/** ACP streams richer tool titles than the persisted name; keep them after the turn. */
|
||||||
|
function liveToolMetaOf(target: TraceTarget): LiveToolMeta {
|
||||||
|
return state.liveToolMeta.get(target.id.replace(/^tool:/, '')) ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
function liveToolTitle(target: TraceTarget): string | undefined {
|
function liveToolTitle(target: TraceTarget): string | undefined {
|
||||||
return state.liveToolTitles.get(target.id.replace(/^tool:/, ''))
|
return liveToolMetaOf(target).title
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verb label for a tool row: the streamed ACP kind beats the generic noun. */
|
||||||
|
function toolVerbLabel(target: TraceTarget): string {
|
||||||
|
const kind = liveToolMetaOf(target).kind
|
||||||
|
if (kind === 'read') return t('kind.verb.read')
|
||||||
|
if (kind === 'edit') return t('kind.verb.edit')
|
||||||
|
if (kind === 'delete') return t('kind.verb.delete')
|
||||||
|
if (kind === 'move') return t('kind.verb.move')
|
||||||
|
if (kind === 'search') return t('kind.verb.search')
|
||||||
|
if (kind === 'execute') return t('kind.verb.execute')
|
||||||
|
if (kind === 'fetch') return t('kind.verb.fetch')
|
||||||
|
if (kind === 'think') return t('kind.verb.think')
|
||||||
|
return t('chat.toolUse')
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChatToolActivity(target: TraceTarget): string {
|
function renderChatToolActivity(target: TraceTarget): string {
|
||||||
@@ -972,10 +1037,10 @@ function renderChatToolActivity(target: TraceTarget): string {
|
|||||||
return `
|
return `
|
||||||
<section class="chat-activity tool-use ${failed ? 'failed' : ''} ${selectedTargetClass(target.id)}">
|
<section class="chat-activity tool-use ${failed ? 'failed' : ''} ${selectedTargetClass(target.id)}">
|
||||||
<div class="activity-row">
|
<div class="activity-row">
|
||||||
<button class="activity-select" type="button" data-target-id="${escapeHtml(target.id)}"><span>${escapeHtml(failed ? t('chat.toolFailed') : t('chat.toolUse'))}</span><strong>${escapeHtml(richTitle ?? target.title)}${preview.length > 0 ? `<span class="activity-preview"> · ${escapeHtml(preview)}</span>` : ''}</strong></button>
|
<button class="activity-select" type="button" data-target-id="${escapeHtml(target.id)}"><span>${escapeHtml(failed ? t('chat.toolFailed') : toolVerbLabel(target))}</span><strong>${escapeHtml(richTitle ?? target.title)}${preview.length > 0 ? `<span class="activity-preview"> · ${escapeHtml(preview)}</span>` : ''}</strong></button>
|
||||||
<button class="activity-toggle" type="button" data-toggle-activity="${escapeHtml(target.id)}" aria-expanded="${expanded}" aria-controls="act-${escapeHtml(target.id)}" aria-label="${escapeHtml(t(expanded ? 'trace.collapseRow' : 'trace.expandRow'))}">${expanded ? '⌃' : '⌄'}</button>
|
<button class="activity-toggle" type="button" data-toggle-activity="${escapeHtml(target.id)}" aria-expanded="${expanded}" aria-controls="act-${escapeHtml(target.id)}" aria-label="${escapeHtml(t(expanded ? 'trace.collapseRow' : 'trace.expandRow'))}">${expanded ? '⌃' : '⌄'}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-body" id="act-${escapeHtml(target.id)}" ${expanded ? '' : 'hidden'} data-target-id="${escapeHtml(target.id)}">${inputHtml}${outputHtml}${spawnedHtml}</div>
|
<div class="activity-body" id="act-${escapeHtml(target.id)}" ${expanded ? '' : 'hidden'} data-target-id="${escapeHtml(target.id)}">${inputHtml}${outputHtml}${toolLocationsHtml(target)}${spawnedHtml}</div>
|
||||||
</section>
|
</section>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1071,18 @@ function spawnedSessionsFor(event: SessionEvent): SessionSummary[] {
|
|||||||
return (state.trace?.children ?? []).filter(session => session.createdAt >= start && session.createdAt <= end)
|
return (state.trace?.children ?? []).filter(session => session.createdAt >= start && session.createdAt <= end)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Touched files streamed on the ACP call; each opens in the OS editor. */
|
||||||
|
function toolLocationsHtml(target: TraceTarget): string {
|
||||||
|
const locations = liveToolMetaOf(target).locations ?? []
|
||||||
|
if (locations.length === 0) return ''
|
||||||
|
return `
|
||||||
|
<div class="tool-locations">
|
||||||
|
<span>${escapeHtml(t('chat.locations'))}</span>
|
||||||
|
${locations.map(location => `<button type="button" data-open-path="${escapeHtml(location.path)}">${escapeHtml(shortPath(location.path))}${location.line === undefined ? '' : `:${location.line}`}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
function toolCallPreview(value: unknown): string {
|
function toolCallPreview(value: unknown): string {
|
||||||
const args = parseMaybeJson(value)
|
const args = parseMaybeJson(value)
|
||||||
if (args !== null && typeof args === 'object') {
|
if (args !== null && typeof args === 'object') {
|
||||||
@@ -1088,6 +1165,13 @@ function kindLabel(kind: TraceTarget['kind']): string {
|
|||||||
return t(`kind.${kind}`)
|
return t(`kind.${kind}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function planRowTitle(target: TraceTarget): string {
|
||||||
|
const items = planItemsOf(target)
|
||||||
|
const done = items.filter(item => item.status === 'completed').length
|
||||||
|
const active = items.find(item => item.status === 'in_progress')
|
||||||
|
return `${done}/${items.length}${active === undefined ? '' : ` · ${truncate(active.content, 80)}`}`
|
||||||
|
}
|
||||||
|
|
||||||
/** Content preview beats the kind name: the chip already says what a row is. */
|
/** Content preview beats the kind name: the chip already says what a row is. */
|
||||||
function trajectoryRowTitle(target: TraceTarget): string {
|
function trajectoryRowTitle(target: TraceTarget): string {
|
||||||
if (target.kind === 'assistant') {
|
if (target.kind === 'assistant') {
|
||||||
@@ -1098,6 +1182,7 @@ function trajectoryRowTitle(target: TraceTarget): string {
|
|||||||
const preview = truncate(contentText(target.output), 120)
|
const preview = truncate(contentText(target.output), 120)
|
||||||
if (preview.length > 0) return preview
|
if (preview.length > 0) return preview
|
||||||
}
|
}
|
||||||
|
if (target.kind === 'plan') return `${t('chat.planList')} · ${planRowTitle(target)}`
|
||||||
if (target.kind === 'tool') {
|
if (target.kind === 'tool') {
|
||||||
const rich = liveToolTitle(target)
|
const rich = liveToolTitle(target)
|
||||||
if (rich !== undefined) return rich
|
if (rich !== undefined) return rich
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ const messages = {
|
|||||||
'dev.openFailed': '打开失败',
|
'dev.openFailed': '打开失败',
|
||||||
'app.resizeSidebar': '调整侧栏宽度',
|
'app.resizeSidebar': '调整侧栏宽度',
|
||||||
'app.resizeInspector': '调整检查器宽度',
|
'app.resizeInspector': '调整检查器宽度',
|
||||||
|
'kind.plan': '计划',
|
||||||
|
'kind.verb.read': '读取',
|
||||||
|
'kind.verb.edit': '编辑',
|
||||||
|
'kind.verb.delete': '删除',
|
||||||
|
'kind.verb.move': '移动',
|
||||||
|
'kind.verb.search': '搜索',
|
||||||
|
'kind.verb.execute': '执行',
|
||||||
|
'kind.verb.fetch': '抓取',
|
||||||
|
'kind.verb.think': '思考',
|
||||||
|
'chat.planList': '任务清单',
|
||||||
|
'chat.locations': '涉及文件',
|
||||||
'kind.user': '用户',
|
'kind.user': '用户',
|
||||||
'kind.reasoning': '思考',
|
'kind.reasoning': '思考',
|
||||||
'kind.assistant': '回复',
|
'kind.assistant': '回复',
|
||||||
@@ -340,6 +351,17 @@ const messages = {
|
|||||||
'dev.openFailed': 'Failed to open',
|
'dev.openFailed': 'Failed to open',
|
||||||
'app.resizeSidebar': 'Resize sidebar',
|
'app.resizeSidebar': 'Resize sidebar',
|
||||||
'app.resizeInspector': 'Resize inspector',
|
'app.resizeInspector': 'Resize inspector',
|
||||||
|
'kind.plan': 'Plan',
|
||||||
|
'kind.verb.read': 'Read',
|
||||||
|
'kind.verb.edit': 'Edit',
|
||||||
|
'kind.verb.delete': 'Delete',
|
||||||
|
'kind.verb.move': 'Move',
|
||||||
|
'kind.verb.search': 'Search',
|
||||||
|
'kind.verb.execute': 'Run',
|
||||||
|
'kind.verb.fetch': 'Fetch',
|
||||||
|
'kind.verb.think': 'Think',
|
||||||
|
'chat.planList': 'Plan',
|
||||||
|
'chat.locations': 'Files touched',
|
||||||
'kind.user': 'User',
|
'kind.user': 'User',
|
||||||
'kind.reasoning': 'Thinking',
|
'kind.reasoning': 'Thinking',
|
||||||
'kind.assistant': 'Response',
|
'kind.assistant': 'Response',
|
||||||
|
|||||||
@@ -2994,3 +2994,97 @@ dd {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Plan checklist card (todo/write in transcripts, plan updates live) ───── */
|
||||||
|
|
||||||
|
.plan-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card header span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 12px 10px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-item.completed span:last-child {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-item.in_progress {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-glyph {
|
||||||
|
width: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-item.completed .plan-glyph {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-item.in_progress .plan-glyph {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-host,
|
||||||
|
.plan-activity {
|
||||||
|
display: block;
|
||||||
|
max-width: min(680px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-activity.is-selected .plan-card {
|
||||||
|
border-color: rgba(31, 111, 235, 0.32);
|
||||||
|
box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touched-file chips inside an expanded tool row. */
|
||||||
|
.tool-locations {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-locations span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-locations button {
|
||||||
|
height: var(--control-sm);
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface TraceEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Logical object classes shared by Chat, Trajectory, Waterfall, and Inspector. */
|
/** Logical object classes shared by Chat, Trajectory, Waterfall, and Inspector. */
|
||||||
export type TraceTargetKind = 'session' | 'turn' | 'step' | 'request' | 'user' | 'reasoning' | 'assistant' | 'tool' | 'context' | 'summary'
|
export type TraceTargetKind = 'session' | 'turn' | 'step' | 'request' | 'user' | 'reasoning' | 'assistant' | 'tool' | 'context' | 'plan' | 'summary'
|
||||||
|
|
||||||
/** One selectable logical trace object with its complete inspector payload. */
|
/** One selectable logical trace object with its complete inspector payload. */
|
||||||
export interface TraceTarget {
|
export interface TraceTarget {
|
||||||
@@ -32,7 +32,7 @@ export interface TraceTarget {
|
|||||||
|
|
||||||
/** One ordered block in a turn's assistant response. */
|
/** One ordered block in a turn's assistant response. */
|
||||||
export interface ChatActivity {
|
export interface ChatActivity {
|
||||||
readonly kind: 'reasoning' | 'tool' | 'text'
|
readonly kind: 'reasoning' | 'tool' | 'text' | 'plan'
|
||||||
readonly targetId: string
|
readonly targetId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +325,11 @@ export function buildTraceGraph(sessionId: string, events: readonly TraceEvent[]
|
|||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
chatTurn.activities.push({ kind: 'text', targetId: id })
|
chatTurn.activities.push({ kind: 'text', targetId: id })
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'todo/write') {
|
||||||
|
const id = `plan:${event.seq ?? currentGroup.rowTargetIds.length}`
|
||||||
|
const todos = Array.isArray(data.todos) ? data.todos : []
|
||||||
|
addTarget({ id, kind: 'plan', title: 'Plan', subtitle: `Turn ${turn}`, status: 'ok', turn, step, startTime: time, endTime: time, eventSeqs: seqs(event), input: '', output: todos, metadata: event })
|
||||||
|
chatTurn.activities.push({ kind: 'plan', targetId: id })
|
||||||
} else if (event.type === 'context/message' || event.type === 'steering/message') {
|
} else if (event.type === 'context/message' || event.type === 'steering/message') {
|
||||||
const id = `context:${event.seq ?? currentGroup.rowTargetIds.length}`
|
const id = `context:${event.seq ?? currentGroup.rowTargetIds.length}`
|
||||||
addTarget({ id, kind: 'context', title: event.type, subtitle: `Turn ${turn} · Step ${step}`, status: 'ok', turn, step, startTime: time, endTime: time, eventSeqs: seqs(event), input: '', output: data.content ?? '', metadata: event })
|
addTarget({ id, kind: 'context', title: event.type, subtitle: `Turn ${turn} · Step ${step}`, status: 'ok', turn, step, startTime: time, endTime: time, eventSeqs: seqs(event), input: '', output: data.content ?? '', metadata: event })
|
||||||
|
|||||||
@@ -256,6 +256,10 @@ describe('desktop renderer chat lifecycle', () => {
|
|||||||
...turnEvents(3, 'third', 'third answer', 20),
|
...turnEvents(3, 'third', 'third answer', 20),
|
||||||
{ type: 'tool/call', seq: 30, time: 31, data: { turn: 3, step: 1, callId: 'wf-1', name: 'workflow', arguments: '{"name":"audit"}' } },
|
{ type: 'tool/call', seq: 30, time: 31, data: { turn: 3, step: 1, callId: 'wf-1', name: 'workflow', arguments: '{"name":"audit"}' } },
|
||||||
{ type: 'tool/result', seq: 31, time: 32, data: { turn: 3, step: 1, callId: 'wf-1', content: [{ type: 'text', text: 'done' }] } },
|
{ type: 'tool/result', seq: 31, time: 32, data: { turn: 3, step: 1, callId: 'wf-1', content: [{ type: 'text', text: 'done' }] } },
|
||||||
|
{ type: 'todo/write', seq: 32, time: 33, data: { turn: 3, step: 1, todos: [
|
||||||
|
{ content: 'collect findings', status: 'completed' },
|
||||||
|
{ content: 'write the report', status: 'in_progress' },
|
||||||
|
] } },
|
||||||
])
|
])
|
||||||
traceRead = turn1Trace
|
traceRead = turn1Trace
|
||||||
|
|
||||||
@@ -318,8 +322,17 @@ describe('desktop renderer chat lifecycle', () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(document.querySelector('#liveTurn .user-bubble')?.textContent).toBe('third')
|
expect(document.querySelector('#liveTurn .user-bubble')?.textContent).toBe('third')
|
||||||
})
|
})
|
||||||
// The ACP stream carries a richer tool title than the persisted name.
|
// The ACP stream carries a richer tool title, a kind, and a plan snapshot.
|
||||||
update?.({ sessionId: 's-lag', update: { sessionUpdate: 'tool_call', toolCallId: 'wf-1', title: 'workflow: run audit agents', status: 'in_progress' } })
|
update?.({ sessionId: 's-lag', update: { sessionUpdate: 'tool_call', toolCallId: 'wf-1', title: 'workflow: run audit agents', kind: 'execute', status: 'in_progress' } })
|
||||||
|
update?.({ sessionId: 's-lag', update: { sessionUpdate: 'plan', entries: [
|
||||||
|
{ content: 'collect findings', priority: 'medium', status: 'in_progress' },
|
||||||
|
{ content: 'write the report', priority: 'medium', status: 'pending' },
|
||||||
|
] } })
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const livePlan = document.querySelector('[data-live="plan"] .plan-card')
|
||||||
|
expect(livePlan?.textContent).toContain('collect findings')
|
||||||
|
expect(livePlan?.textContent).toContain('0/2')
|
||||||
|
})
|
||||||
|
|
||||||
// Once the persisted log catches up, the view converges with no user action.
|
// Once the persisted log catches up, the view converges with no user action.
|
||||||
prompts[2]!.resolve({ response: {}, trace: turn1Trace })
|
prompts[2]!.resolve({ response: {}, trace: turn1Trace })
|
||||||
@@ -330,5 +343,10 @@ describe('desktop renderer chat lifecycle', () => {
|
|||||||
}, { timeout: 4000 })
|
}, { timeout: 4000 })
|
||||||
// The live workflow presentation survives the switch to the persisted view.
|
// The live workflow presentation survives the switch to the persisted view.
|
||||||
expect(document.querySelector('#conversation')?.textContent).toContain('workflow: run audit agents')
|
expect(document.querySelector('#conversation')?.textContent).toContain('workflow: run audit agents')
|
||||||
|
// The persisted todo/write renders as a checklist card with the streamed kind verb.
|
||||||
|
const planCard = document.querySelector('#conversation .plan-activity .plan-card')
|
||||||
|
expect(planCard?.textContent).toContain('write the report')
|
||||||
|
expect(planCard?.textContent).toContain('1/2')
|
||||||
|
expect(document.querySelector('#conversation .chat-activity.tool-use .activity-select span')?.textContent).toBe('执行')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ describe('desktop trace graph', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('folds todo/write events into plan targets and chat activities', () => {
|
||||||
|
const graph = buildTraceGraph('s-plan', [
|
||||||
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' } } },
|
||||||
|
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'go' }] } },
|
||||||
|
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
|
||||||
|
{ type: 'todo/write', seq: 3, time: 4, data: { turn: 1, step: 1, todos: [
|
||||||
|
{ content: 'read the code', status: 'completed' },
|
||||||
|
{ content: 'fix the bug', status: 'in_progress' },
|
||||||
|
] } },
|
||||||
|
{ type: 'todo/write', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||||
|
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
|
||||||
|
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||||
|
])
|
||||||
|
const plan = graph.targets.get('plan:3')!
|
||||||
|
expect(plan.kind).toBe('plan')
|
||||||
|
expect(plan.output).toEqual([
|
||||||
|
{ content: 'read the code', status: 'completed' },
|
||||||
|
{ content: 'fix the bug', status: 'in_progress' },
|
||||||
|
])
|
||||||
|
expect(graph.targets.get('plan:4')?.output).toEqual([])
|
||||||
|
expect(graph.chatTurns[0]?.activities).toContainEqual({ kind: 'plan', targetId: 'plan:3' })
|
||||||
|
expect(graph.trajectoryRows.filter(row => row.targetId === 'plan:3')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('normalizes incomplete and malformed event tails without inventing duplicate rows', () => {
|
it('normalizes incomplete and malformed event tails without inventing duplicate rows', () => {
|
||||||
expect(buildTraceGraph('empty', []).startTime).toBe(0)
|
expect(buildTraceGraph('empty', []).startTime).toBe(0)
|
||||||
const graph = buildTraceGraph('edge', [
|
const graph = buildTraceGraph('edge', [
|
||||||
|
|||||||
Reference in New Issue
Block a user