feat(chat): collapse tool calls by run
This commit is contained in:
@@ -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>
|
||||||
<template #item="{ message: msg }">
|
<template #item="{ message: msg }">
|
||||||
<div v-if="msg.systemType === 'run-end'" class="run-end" role="separator">{{ t('chat.runOutputEnded') }}</div>
|
<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 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-line" aria-hidden="true"></div>
|
||||||
<div class="fork-divider-pill">
|
<div class="fork-divider-pill">
|
||||||
@@ -617,6 +621,8 @@ defineExpose({
|
|||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tool calls -->
|
<!-- Tool calls -->
|
||||||
|
<details v-if="visibleToolCalls.length" class="tool-group live-tool-group">
|
||||||
|
<summary>{{ t('chat.toolCallsGroup', { count: visibleToolCalls.length }) }}</summary>
|
||||||
<div
|
<div
|
||||||
v-for="tc in visibleToolCalls"
|
v-for="tc in visibleToolCalls"
|
||||||
:key="tc.id"
|
:key="tc.id"
|
||||||
@@ -688,6 +694,7 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -875,6 +882,21 @@ defineExpose({
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/styles/variables" as *;
|
@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 {
|
.run-end {
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export function groupRunMessages(messages: Message[], runActive: boolean): Messa
|
|||||||
isStreaming: assistants.some(m => m.isStreaming),
|
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) {
|
if (completed && assistants.length) {
|
||||||
result.push({ id: `run-end-${assistants[0].id}`, role: 'system', content: '', timestamp: assistants[assistants.length - 1].timestamp, systemType: 'run-end' })
|
result.push({ id: `run-end-${assistants[0].id}`, role: 'system', content: '', timestamp: assistants[assistants.length - 1].timestamp, systemType: 'run-end' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ export default {
|
|||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
chat: {
|
chat: {
|
||||||
|
toolCallsGroup: 'Tool calls ({count})',
|
||||||
runOutputEnded: 'Run output ended',
|
runOutputEnded: 'Run output ended',
|
||||||
messageLoadFailed: 'Could not load this conversation. Please retry.',
|
messageLoadFailed: 'Could not load this conversation. Please retry.',
|
||||||
contextRemaining: 'remaining',
|
contextRemaining: 'remaining',
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ export default {
|
|||||||
|
|
||||||
// 对话
|
// 对话
|
||||||
chat: {
|
chat: {
|
||||||
|
toolCallsGroup: '工具调用({count} 次)',
|
||||||
runOutputEnded: '本轮输出结束',
|
runOutputEnded: '本轮输出结束',
|
||||||
messageLoadFailed: '会话加载失败,请重试。',
|
messageLoadFailed: '会话加载失败,请重试。',
|
||||||
contextRemaining: '剩余',
|
contextRemaining: '剩余',
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ export interface Message {
|
|||||||
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
|
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
|
||||||
reasoning?: string
|
reasoning?: string
|
||||||
queued?: boolean
|
queued?: boolean
|
||||||
systemType?: 'command' | 'error' | 'fork-divider' | 'run-end'
|
systemType?: 'command' | 'error' | 'fork-divider' | 'run-end' | 'tool-group'
|
||||||
|
toolMessages?: Message[]
|
||||||
commandAction?: string
|
commandAction?: string
|
||||||
commandData?: Record<string, unknown>
|
commandData?: Record<string, unknown>
|
||||||
finishReason?: string | null
|
finishReason?: string | null
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ describe('groupRunMessages', () => {
|
|||||||
it('groups text across tool calls and marks completion only when the run ends', () => {
|
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 input = [message('u', 'user', 'question'), message('a', 'assistant', 'first'), message('t', 'tool'), message('b', 'assistant', 'second')]
|
||||||
const active = groupRunMessages(input, true)
|
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' })
|
expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' })
|
||||||
const finished = groupRunMessages(input, false)
|
const finished = groupRunMessages(input, false)
|
||||||
expect(finished.at(-1)?.systemType).toBe('run-end')
|
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.role === 'assistant').map(m => m.content)).toEqual(['one', 'two', 'three'])
|
||||||
expect(groupRunMessages(input, false).filter(m => m.systemType === 'run-end')).toHaveLength(3)
|
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']])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -316,6 +316,9 @@ test('renders tool trace and sends explicit approval decisions over the chat-run
|
|||||||
})
|
})
|
||||||
}, run.session_id)
|
}, 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('write_file', { exact: true })).toBeVisible()
|
||||||
await expect(page.getByText('Writing approved file')).toBeVisible()
|
await expect(page.getByText('Writing approved file')).toBeVisible()
|
||||||
await expect(page.locator('.message.tool .tool-line')).toHaveCount(0)
|
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' })
|
const persistedToolTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'write_file' })
|
||||||
await expect(persistedToolTrace).toHaveCount(1)
|
await expect(persistedToolTrace).toHaveCount(1)
|
||||||
|
await expect(persistedToolTrace).toBeHidden()
|
||||||
|
await page.locator('.tool-group:not(.live-tool-group) > summary').click()
|
||||||
await persistedToolTrace.click()
|
await persistedToolTrace.click()
|
||||||
const toolDetails = page.locator('.message.tool .tool-details')
|
const toolDetails = page.locator('.message.tool .tool-details')
|
||||||
await expect(toolDetails).toContainText('/tmp/approved.txt')
|
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: 'read_file' })).toHaveCount(0)
|
||||||
await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).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('.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 transcriptTools.filter({ hasText: 'shell_exec' }).click()
|
||||||
await expect(page.locator('.message.tool .tool-details')).toContainText('exit status 1')
|
await expect(page.locator('.message.tool .tool-details')).toContainText('exit status 1')
|
||||||
await expect(page.getByText('Multi-tool fallback should stay hidden.')).toHaveCount(0)
|
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()
|
await expect(page.getByText('Named history answer visible.')).toBeVisible()
|
||||||
const restoredTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'read_file' })
|
const restoredTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'read_file' })
|
||||||
await expect(restoredTrace).toHaveCount(1)
|
await expect(restoredTrace).toHaveCount(1)
|
||||||
|
await expect(restoredTrace).toBeHidden()
|
||||||
|
await page.locator('.tool-group > summary').click()
|
||||||
await restoredTrace.click()
|
await restoredTrace.click()
|
||||||
await expect(page.locator('.message.tool .tool-details')).toContainText('/tmp/history.txt')
|
await expect(page.locator('.message.tool .tool-details')).toContainText('/tmp/history.txt')
|
||||||
expect(api.unexpectedRequests).toEqual([])
|
expect(api.unexpectedRequests).toEqual([])
|
||||||
|
|||||||
Reference in New Issue
Block a user