From 88544d9c1bb0b8cb0d482b937b1638701c12331e Mon Sep 17 00:00:00 2001 From: NI0317 Date: Sun, 19 Jul 2026 21:50:19 +0800 Subject: [PATCH] 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. --- packages/ui/desktop/src/app.ts | 16 +++++++++++++--- packages/ui/desktop/tests/renderer.spec.ts | 12 +++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/ui/desktop/src/app.ts b/packages/ui/desktop/src/app.ts index db07fa2356..e1ce5a3628 100644 --- a/packages/ui/desktop/src/app.ts +++ b/packages/ui/desktop/src/app.ts @@ -157,6 +157,7 @@ const state = { expandedActivityIds: new Set(), traceLoadRevision: 0, liveTurnCounter: 0, + liveToolTitles: new Map(), traceCatchupTimer: undefined as number | undefined, traceCatchupAttempts: 0, } @@ -488,7 +489,7 @@ function applyTrace(trace: TracePayload, resetExpansion: boolean): void { renderTrajTree() renderWaterfall() renderInspector() - scrollChatToBottom(true) + scrollChatToBottom(resetExpansion) scheduleTraceCatchup(sessionId) } @@ -563,6 +564,7 @@ function handleSessionUpdate(payload: SessionUpdatePayload): void { live.status = 'streaming' } else if (kind === 'tool_call' || kind === 'tool_call_update') { 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 title = String(update.title ?? existing?.title ?? t('chat.toolUse')) 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 { 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 inputHtml = `
${escapeHtml(t('chat.input'))}
${escapeHtml(formatPayload(target.input, 'json'))}
` const outputHtml = target.output === '' ? '' : `
${escapeHtml(failed ? t('chat.errorOutput') : t('chat.output'))}
${escapeHtml(formatPayload(target.output, 'plain'))}
` @@ -964,7 +972,7 @@ function renderChatToolActivity(target: TraceTarget): string { return `
- +
${inputHtml}${outputHtml}${spawnedHtml}
@@ -1091,6 +1099,8 @@ function trajectoryRowTitle(target: TraceTarget): string { if (preview.length > 0) return preview } if (target.kind === 'tool') { + const rich = liveToolTitle(target) + if (rich !== undefined) return rich const preview = toolCallPreview(target.input) return preview.length > 0 ? `${target.title} · ${preview}` : `${target.title} · ${target.subtitle}` } diff --git a/packages/ui/desktop/tests/renderer.spec.ts b/packages/ui/desktop/tests/renderer.spec.ts index 92d9dc580f..097133a0b6 100644 --- a/packages/ui/desktop/tests/renderer.spec.ts +++ b/packages/ui/desktop/tests/renderer.spec.ts @@ -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 () => { const prompts: Deferred[] = [deferred(), deferred(), deferred()] const promptQueue = [...prompts] + let update: ((payload: unknown) => void) | undefined let traceRead: 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' } } }, @@ -253,6 +254,8 @@ describe('desktop renderer chat lifecycle', () => { ...turnEvents(1, 'first', 'first answer', 0), ...turnEvents(2, 'second', 'second answer', 10), ...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 @@ -272,7 +275,10 @@ describe('desktop renderer chat lifecycle', () => { prompt: async () => promptQueue.shift()!.promise, cancel: async () => ({}), reveal: async () => ({}), - onUpdate: () => () => {}, + onUpdate: (callback: (payload: unknown) => void) => { + update = callback + return () => {} + }, }, trace: { read: async () => traceRead }, feedback: { list: async () => [], add: async () => ({}) }, @@ -312,6 +318,8 @@ describe('desktop renderer chat lifecycle', () => { await vi.waitFor(() => { 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. 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('#liveTurn')?.innerHTML).toBe('') }, { timeout: 4000 }) + // The live workflow presentation survives the switch to the persisted view. + expect(document.querySelector('#conversation')?.textContent).toContain('workflow: run audit agents') }) })