feat(chat): collapse tool calls by run
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-11-05 11:40:51 +08:00
parent 276e2389d6
commit 1bdef36577
8 changed files with 56 additions and 3 deletions
@@ -0,0 +1,10 @@
---
date: 2026-11-05
pr: pending
feature: Collapsible tool call groups
impact: Tool calls in each chat turn and the active run are collapsed by default, with individual details available after expansion. Chat events and persisted messages are unchanged.
---
The chat transcript groups each turn's tool calls in a single disclosure. The
active run's tool list uses a separate disclosure so approval and status
indicators remain visible.
@@ -492,6 +492,10 @@ defineExpose({
</template>
<template #item="{ message: msg }">
<div v-if="msg.systemType === 'run-end'" class="run-end" role="separator">{{ t('chat.runOutputEnded') }}</div>
<details v-else-if="msg.systemType === 'tool-group'" class="tool-group">
<summary>{{ t('chat.toolCallsGroup', { count: msg.toolMessages.length }) }}</summary>
<MessageItem v-for="tool in msg.toolMessages" :key="tool.id" :message="tool" />
</details>
<div v-else-if="msg.systemType === 'fork-divider' && forkLineage" class="fork-divider" role="separator">
<div class="fork-divider-line" aria-hidden="true"></div>
<div class="fork-divider-pill">
@@ -617,6 +621,8 @@ defineExpose({
></span>
</div>
<!-- Tool calls -->
<details v-if="visibleToolCalls.length" class="tool-group live-tool-group">
<summary>{{ t('chat.toolCallsGroup', { count: visibleToolCalls.length }) }}</summary>
<div
v-for="tc in visibleToolCalls"
:key="tc.id"
@@ -688,6 +694,7 @@ defineExpose({
/>
</svg>
</div>
</details>
</div>
</div>
</Transition>
@@ -875,6 +882,21 @@ defineExpose({
<style scoped lang="scss">
@use "@/styles/variables" as *;
.tool-group {
color: var(--color-text-3);
> summary {
cursor: pointer;
padding: 8px 0;
font-size: 13px;
user-select: none;
}
}
.live-tool-group {
width: 100%;
}
.run-end {
margin: 20px auto;
text-align: center;
@@ -20,7 +20,9 @@ export function groupRunMessages(messages: Message[], runActive: boolean): Messa
isStreaming: assistants.some(m => m.isStreaming),
})
}
result.push(...tools)
if (tools.length) {
result.push({ id: `tool-group-${tools[0].id}`, role: 'system', content: '', timestamp: tools[0].timestamp, systemType: 'tool-group', toolMessages: tools })
}
if (completed && assistants.length) {
result.push({ id: `run-end-${assistants[0].id}`, role: 'system', content: '', timestamp: assistants[assistants.length - 1].timestamp, systemType: 'run-end' })
}
+1
View File
@@ -459,6 +459,7 @@ export default {
// Chat
chat: {
toolCallsGroup: 'Tool calls ({count})',
runOutputEnded: 'Run output ended',
messageLoadFailed: 'Could not load this conversation. Please retry.',
contextRemaining: 'remaining',
+1
View File
@@ -459,6 +459,7 @@ export default {
// 对话
chat: {
toolCallsGroup: '工具调用({count} 次)',
runOutputEnded: '本轮输出结束',
messageLoadFailed: '会话加载失败,请重试。',
contextRemaining: '剩余',
+2 -1
View File
@@ -63,7 +63,8 @@ export interface Message {
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
reasoning?: string
queued?: boolean
systemType?: 'command' | 'error' | 'fork-divider' | 'run-end'
systemType?: 'command' | 'error' | 'fork-divider' | 'run-end' | 'tool-group'
toolMessages?: Message[]
commandAction?: string
commandData?: Record<string, unknown>
finishReason?: string | null
+8 -1
View File
@@ -8,7 +8,8 @@ describe('groupRunMessages', () => {
it('groups text across tool calls and marks completion only when the run ends', () => {
const input = [message('u', 'user', 'question'), message('a', 'assistant', 'first'), message('t', 'tool'), message('b', 'assistant', 'second')]
const active = groupRunMessages(input, true)
expect(active.map(m => m.role)).toEqual(['user', 'assistant', 'tool'])
expect(active.map(m => m.role)).toEqual(['user', 'assistant', 'system'])
expect(active[2]).toMatchObject({ systemType: 'tool-group', toolMessages: [input[2]] })
expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' })
const finished = groupRunMessages(input, false)
expect(finished.at(-1)?.systemType).toBe('run-end')
@@ -19,4 +20,10 @@ describe('groupRunMessages', () => {
expect(groupRunMessages(input, false).filter(m => m.role === 'assistant').map(m => m.content)).toEqual(['one', 'two', 'three'])
expect(groupRunMessages(input, false).filter(m => m.systemType === 'run-end')).toHaveLength(3)
})
it('keeps tool calls grouped within their own turn', () => {
const input = [message('u', 'user'), message('a', 'tool'), message('b', 'tool'), message('u2', 'user'), message('c', 'tool')]
const groups = groupRunMessages(input, false).filter(m => m.systemType === 'tool-group')
expect(groups.map(group => group.toolMessages?.map(tool => tool.id))).toEqual([['a', 'b'], ['c']])
})
})
+9
View File
@@ -316,6 +316,9 @@ test('renders tool trace and sends explicit approval decisions over the chat-run
})
}, run.session_id)
await expect(page.locator('.live-tool-group')).not.toHaveAttribute('open', '')
await expect(page.getByText('write_file', { exact: true })).toBeHidden()
await page.locator('.live-tool-group > summary').click()
await expect(page.getByText('write_file', { exact: true })).toBeVisible()
await expect(page.getByText('Writing approved file')).toBeVisible()
await expect(page.locator('.message.tool .tool-line')).toHaveCount(0)
@@ -393,6 +396,8 @@ test('renders tool trace and sends explicit approval decisions over the chat-run
const persistedToolTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'write_file' })
await expect(persistedToolTrace).toHaveCount(1)
await expect(persistedToolTrace).toBeHidden()
await page.locator('.tool-group:not(.live-tool-group) > summary').click()
await persistedToolTrace.click()
const toolDetails = page.locator('.message.tool .tool-details')
await expect(toolDetails).toContainText('/tmp/approved.txt')
@@ -593,6 +598,8 @@ test('keeps completed same-run tool traces hidden until the run finishes', async
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0)
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(0)
await expect(page.locator('.message.tool .tool-error-badge')).toHaveCount(1)
await expect(page.locator('.tool-group:not(.live-tool-group) > summary')).toHaveText('Tool calls (2)')
await page.locator('.tool-group:not(.live-tool-group) > summary').click()
await transcriptTools.filter({ hasText: 'shell_exec' }).click()
await expect(page.locator('.message.tool .tool-details')).toContainText('exit status 1')
await expect(page.getByText('Multi-tool fallback should stay hidden.')).toHaveCount(0)
@@ -849,6 +856,8 @@ test('restores named resumed tool traces from assistant tool calls after session
await expect(page.getByText('Named history answer visible.')).toBeVisible()
const restoredTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'read_file' })
await expect(restoredTrace).toHaveCount(1)
await expect(restoredTrace).toBeHidden()
await page.locator('.tool-group > summary').click()
await restoredTrace.click()
await expect(page.locator('.message.tool .tool-details')).toContainText('/tmp/history.txt')
expect(api.unexpectedRequests).toEqual([])