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 #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' })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -459,6 +459,7 @@ export default {
|
||||
|
||||
// 对话
|
||||
chat: {
|
||||
toolCallsGroup: '工具调用({count} 次)',
|
||||
runOutputEnded: '本轮输出结束',
|
||||
messageLoadFailed: '会话加载失败,请重试。',
|
||||
contextRemaining: '剩余',
|
||||
|
||||
@@ -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,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']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
|
||||
Reference in New Issue
Block a user