fix(ui): keep live tool presentation after turn completion
Turn completion no longer force-scrolls the conversation: only a session switch snaps to the bottom, so finishing a turn does not yank the view while the user reads. ACP streams richer tool titles (for example a workflow run's description) than the persisted tool/call name; remember them per call id and keep rendering them in chat and trajectory after the persisted trace replaces the live turn.
This commit is contained in:
@@ -157,6 +157,7 @@ const state = {
|
|||||||
expandedActivityIds: new Set<string>(),
|
expandedActivityIds: new Set<string>(),
|
||||||
traceLoadRevision: 0,
|
traceLoadRevision: 0,
|
||||||
liveTurnCounter: 0,
|
liveTurnCounter: 0,
|
||||||
|
liveToolTitles: new Map<string, string>(),
|
||||||
traceCatchupTimer: undefined as number | undefined,
|
traceCatchupTimer: undefined as number | undefined,
|
||||||
traceCatchupAttempts: 0,
|
traceCatchupAttempts: 0,
|
||||||
}
|
}
|
||||||
@@ -488,7 +489,7 @@ function applyTrace(trace: TracePayload, resetExpansion: boolean): void {
|
|||||||
renderTrajTree()
|
renderTrajTree()
|
||||||
renderWaterfall()
|
renderWaterfall()
|
||||||
renderInspector()
|
renderInspector()
|
||||||
scrollChatToBottom(true)
|
scrollChatToBottom(resetExpansion)
|
||||||
scheduleTraceCatchup(sessionId)
|
scheduleTraceCatchup(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +564,7 @@ function handleSessionUpdate(payload: SessionUpdatePayload): void {
|
|||||||
live.status = 'streaming'
|
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 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 ?? '')
|
||||||
@@ -953,9 +955,15 @@ function renderConversationActivity(activity: ChatActivity): string {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ACP streams richer tool titles than the persisted name; keep them after the turn. */
|
||||||
|
function liveToolTitle(target: TraceTarget): string | undefined {
|
||||||
|
return state.liveToolTitles.get(target.id.replace(/^tool:/, ''))
|
||||||
|
}
|
||||||
|
|
||||||
function renderChatToolActivity(target: TraceTarget): string {
|
function renderChatToolActivity(target: TraceTarget): string {
|
||||||
const failed = target.status === 'error'
|
const failed = target.status === 'error'
|
||||||
const preview = toolCallPreview(target.input)
|
const richTitle = liveToolTitle(target)
|
||||||
|
const preview = richTitle === undefined ? toolCallPreview(target.input) : ''
|
||||||
const expanded = state.expandedActivityIds.has(target.id)
|
const expanded = state.expandedActivityIds.has(target.id)
|
||||||
const inputHtml = `<div class="activity-section-title">${escapeHtml(t('chat.input'))}</div><pre>${escapeHtml(formatPayload(target.input, 'json'))}</pre>`
|
const inputHtml = `<div class="activity-section-title">${escapeHtml(t('chat.input'))}</div><pre>${escapeHtml(formatPayload(target.input, 'json'))}</pre>`
|
||||||
const outputHtml = target.output === '' ? '' : `<div class="activity-section-title">${escapeHtml(failed ? t('chat.errorOutput') : t('chat.output'))}</div><pre>${escapeHtml(formatPayload(target.output, 'plain'))}</pre>`
|
const outputHtml = target.output === '' ? '' : `<div class="activity-section-title">${escapeHtml(failed ? t('chat.errorOutput') : t('chat.output'))}</div><pre>${escapeHtml(formatPayload(target.output, 'plain'))}</pre>`
|
||||||
@@ -964,7 +972,7 @@ 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(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') : t('chat.toolUse'))}</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}${spawnedHtml}</div>
|
||||||
@@ -1091,6 +1099,8 @@ function trajectoryRowTitle(target: TraceTarget): string {
|
|||||||
if (preview.length > 0) return preview
|
if (preview.length > 0) return preview
|
||||||
}
|
}
|
||||||
if (target.kind === 'tool') {
|
if (target.kind === 'tool') {
|
||||||
|
const rich = liveToolTitle(target)
|
||||||
|
if (rich !== undefined) return rich
|
||||||
const preview = toolCallPreview(target.input)
|
const preview = toolCallPreview(target.input)
|
||||||
return preview.length > 0 ? `${target.title} · ${preview}` : `${target.title} · ${target.subtitle}`
|
return preview.length > 0 ? `${target.title} · ${preview}` : `${target.title} · ${target.subtitle}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ describe('desktop renderer chat lifecycle', () => {
|
|||||||
it('starts a fresh live skeleton per turn and converges without user action when the persisted trace lags', async () => {
|
it('starts a fresh live skeleton per turn and converges without user action when the persisted trace lags', async () => {
|
||||||
const prompts: Deferred<unknown>[] = [deferred(), deferred(), deferred()]
|
const prompts: Deferred<unknown>[] = [deferred(), deferred(), deferred()]
|
||||||
const promptQueue = [...prompts]
|
const promptQueue = [...prompts]
|
||||||
|
let update: ((payload: unknown) => void) | undefined
|
||||||
let traceRead: unknown
|
let traceRead: unknown
|
||||||
const turnEvents = (turn: number, userText: string, answer: string, base: number): unknown[] => [
|
const turnEvents = (turn: number, userText: string, answer: string, base: number): unknown[] => [
|
||||||
{ type: 'turn/start', seq: base, time: base + 1, data: { turn, trigger: { kind: 'message' } } },
|
{ type: 'turn/start', seq: base, time: base + 1, data: { turn, trigger: { kind: 'message' } } },
|
||||||
@@ -253,6 +254,8 @@ describe('desktop renderer chat lifecycle', () => {
|
|||||||
...turnEvents(1, 'first', 'first answer', 0),
|
...turnEvents(1, 'first', 'first answer', 0),
|
||||||
...turnEvents(2, 'second', 'second answer', 10),
|
...turnEvents(2, 'second', 'second answer', 10),
|
||||||
...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/result', seq: 31, time: 32, data: { turn: 3, step: 1, callId: 'wf-1', content: [{ type: 'text', text: 'done' }] } },
|
||||||
])
|
])
|
||||||
traceRead = turn1Trace
|
traceRead = turn1Trace
|
||||||
|
|
||||||
@@ -272,7 +275,10 @@ describe('desktop renderer chat lifecycle', () => {
|
|||||||
prompt: async () => promptQueue.shift()!.promise,
|
prompt: async () => promptQueue.shift()!.promise,
|
||||||
cancel: async () => ({}),
|
cancel: async () => ({}),
|
||||||
reveal: async () => ({}),
|
reveal: async () => ({}),
|
||||||
onUpdate: () => () => {},
|
onUpdate: (callback: (payload: unknown) => void) => {
|
||||||
|
update = callback
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
trace: { read: async () => traceRead },
|
trace: { read: async () => traceRead },
|
||||||
feedback: { list: async () => [], add: async () => ({}) },
|
feedback: { list: async () => [], add: async () => ({}) },
|
||||||
@@ -312,6 +318,8 @@ 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.
|
||||||
|
update?.({ sessionId: 's-lag', update: { sessionUpdate: 'tool_call', toolCallId: 'wf-1', title: 'workflow: run audit agents', status: 'in_progress' } })
|
||||||
|
|
||||||
// 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 })
|
||||||
@@ -320,5 +328,7 @@ describe('desktop renderer chat lifecycle', () => {
|
|||||||
expect(document.querySelector('#conversation')?.textContent).toContain('third answer')
|
expect(document.querySelector('#conversation')?.textContent).toContain('third answer')
|
||||||
expect(document.querySelector('#liveTurn')?.innerHTML).toBe('')
|
expect(document.querySelector('#liveTurn')?.innerHTML).toBe('')
|
||||||
}, { timeout: 4000 })
|
}, { timeout: 4000 })
|
||||||
|
// The live workflow presentation survives the switch to the persisted view.
|
||||||
|
expect(document.querySelector('#conversation')?.textContent).toContain('workflow: run audit agents')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user