feat: Collapse ACP tool calls into a grouped summary after turn completes (#1198)

This commit is contained in:
paisley
2026-07-28 13:13:32 +08:00
committed by GitHub
parent 4318fdc3f1
commit 017749b169
13 changed files with 366 additions and 16 deletions
+3
View File
@@ -54,6 +54,9 @@
"tool": "Tool",
"expandTool": "Expand tool result",
"collapseTool": "Collapse tool result",
"expandToolGroup": "Expand tool calls",
"collapseToolGroup": "Collapse tool calls",
"toolGroupSummary": "{{count}} tool calls",
"permission": "Permission",
"plan": "Plan",
"running": "Running",
+3
View File
@@ -54,6 +54,9 @@
"tool": "ツール",
"expandTool": "ツール結果を展開",
"collapseTool": "ツール結果を折りたたむ",
"expandToolGroup": "ツール呼び出しを展開",
"collapseToolGroup": "ツール呼び出しを折りたたむ",
"toolGroupSummary": "ツール呼び出し {{count}} 件",
"permission": "権限",
"plan": "計画",
"running": "実行中",
+3
View File
@@ -54,6 +54,9 @@
"tool": "Инструмент",
"expandTool": "Развернуть результат инструмента",
"collapseTool": "Свернуть результат инструмента",
"expandToolGroup": "Развернуть вызовы инструментов",
"collapseToolGroup": "Свернуть вызовы инструментов",
"toolGroupSummary": "Вызовов инструментов: {{count}}",
"permission": "Разрешение",
"plan": "План",
"running": "Выполняется",
+3
View File
@@ -54,6 +54,9 @@
"tool": "工具",
"expandTool": "展开工具结果",
"collapseTool": "折叠工具结果",
"expandToolGroup": "展开工具调用",
"collapseToolGroup": "折叠工具调用",
"toolGroupSummary": "{{count}} 个工具调用",
"permission": "权限",
"plan": "计划",
"running": "进行中",
+67 -1
View File
@@ -7,10 +7,64 @@ import { AcpPermissionCard } from './AcpPermissionCard';
import { AcpPlanItem } from './AcpPlanItem';
import { AcpThoughtBlock } from './AcpThoughtBlock';
import { AcpToolCallCard } from './AcpToolCallCard';
import { AcpToolCallsGroup } from './AcpToolCallsGroup';
import type { AcpTurnFileSummary } from '@/lib/acp/openclaw-file-activities';
import { AcpTurnFileActivity } from './AcpTurnFileActivity';
import { AcpAttachmentPart } from './AcpAttachmentPart';
import type { AcpTurnTiming } from '@/lib/acp/turn-timings';
import type { TimelineItem, ToolCallItem } from '@/lib/acp/timeline-types';
type TurnRenderItem =
| TimelineItem
| { kind: 'tool-call-group'; id: string; items: ToolCallItem[] };
function partitionTurnItems(items: TimelineItem[]): TurnRenderItem[] {
const result: TurnRenderItem[] = [];
let toolRun: ToolCallItem[] = [];
const flushTools = () => {
if (toolRun.length === 0) return;
if (toolRun.length === 1) {
result.push(toolRun[0]);
} else {
result.push({ kind: 'tool-call-group', id: `tool-group:${toolRun[0].id}`, items: [...toolRun] });
}
toolRun = [];
};
for (const item of items) {
if (item.kind === 'tool-call') {
toolRun.push(item);
continue;
}
flushTools();
result.push(item);
}
flushTools();
return result;
}
function isToolGroupSettled(
toolItems: ToolCallItem[],
timing: AcpTurnTiming | undefined,
turnItems: TimelineItem[],
): boolean {
if (timing?.status === 'complete') return true;
if (timing?.status === 'running') return false;
if (toolItems.length > 0 && toolItems.every((item) => item.historical)) return true;
const allFinished = toolItems.every((item) => item.status === 'completed' || item.status === 'failed');
if (!allFinished) return false;
const lastToolId = toolItems[toolItems.length - 1]?.id;
const lastToolIndex = turnItems.findIndex((item) => item.id === lastToolId);
const hasAssistantReplyAfter = turnItems.slice(lastToolIndex + 1).some(
(item) => item.kind === 'message-segment' && item.role === 'assistant',
);
if (hasAssistantReplyAfter) return true;
return turnItems.every((item) => item.kind === 'tool-call');
}
function assistantTurnClipboardText(group: AcpAssistantTurnDisplayGroup): string {
const textSegments: string[] = [];
@@ -76,6 +130,7 @@ export function AcpAssistantTurn({
onPermissionSelect?: (requestId: string, optionId: string) => void;
}) {
const clipboardText = useMemo(() => assistantTurnClipboardText(group), [group]);
const renderItems = useMemo(() => partitionTurnItems(group.items), [group.items]);
return (
<div data-testid="acp-assistant-turn" className="group flex w-full justify-start gap-3">
@@ -86,7 +141,18 @@ export function AcpAssistantTurn({
</div>
<div className="flex min-w-0 flex-1 flex-col items-start gap-3">
{group.items.map((item) => {
{renderItems.map((item) => {
if (item.kind === 'tool-call-group') {
return (
<div key={item.id} className="w-full">
<AcpToolCallsGroup
items={item.items}
collapsedByDefault={isToolGroupSettled(item.items, timing, group.items)}
/>
</div>
);
}
if (item.kind === 'message-segment') {
if (item.role === 'user') return <AcpMessageSegment key={item.id} item={item} />;
return (
+14 -7
View File
@@ -39,7 +39,7 @@ function AcpToolOutputPart({ part }: { part: RenderPart }) {
return <AcpRenderPart part={part} tone="process" />;
}
export function AcpToolCallCard({ item }: { item: ToolCallItem }) {
export function AcpToolCallCard({ item, grouped = false }: { item: ToolCallItem; grouped?: boolean }) {
const { t } = useTranslation('chat');
const hasDetails = Boolean(item.error) || item.outputParts.length > 0;
const isFinished = item.status === 'completed' || item.status === 'failed';
@@ -83,7 +83,7 @@ export function AcpToolCallCard({ item }: { item: ToolCallItem }) {
<div
data-testid="acp-tool-call-card"
data-expanded={expanded ? 'true' : 'false'}
className="rounded-lg px-0 py-0.5"
className={cn('rounded-lg px-0', grouped ? 'py-0' : 'py-0.5')}
>
<div className="flex min-w-0 items-center justify-between gap-3">
{hasDetails ? (
@@ -101,16 +101,23 @@ export function AcpToolCallCard({ item }: { item: ToolCallItem }) {
aria-expanded={expanded}
aria-label={toggleLabel}
title={toggleLabel}
className="flex min-w-0 p-1 flex-1 items-center gap-2 rounded-lg text-left transition-colors hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:hover:bg-white/10"
className={cn(
'flex min-w-0 flex-1 items-center gap-2 rounded-lg text-left leading-5 transition-colors hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:hover:bg-white/10',
grouped ? 'px-1 py-1' : 'p-1',
)}
>
{expanded ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" /> : <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t('acp.tool')}</span>
{!grouped && (
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t('acp.tool')}</span>
)}
<span className="min-w-0 truncate text-xs font-medium text-muted-foreground">{item.title}</span>
</button>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2">
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t('acp.tool')}</span>
<div className={cn('flex min-w-0 flex-1 items-center gap-2 leading-5', grouped && 'px-1 py-1')}>
{!grouped && <Wrench className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
{!grouped && (
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t('acp.tool')}</span>
)}
<span className="min-w-0 truncate text-xs font-medium text-muted-foreground">{item.title}</span>
</div>
)}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ToolCallItem } from '@/lib/acp/timeline-types';
import { cn } from '@/lib/utils';
import { AcpToolCallCard } from './AcpToolCallCard';
const TOOL_GROUP_AUTO_COLLAPSE_DELAY_MS = 1_000;
export function AcpToolCallsGroup({
items,
collapsedByDefault = false,
}: {
items: ToolCallItem[];
collapsedByDefault?: boolean;
}) {
const { t } = useTranslation('chat');
const shouldStartCollapsed = collapsedByDefault || items.every((item) => item.historical);
const [expanded, setExpanded] = useState(!shouldStartCollapsed);
const [manualOverride, setManualOverride] = useState(false);
useEffect(() => {
if (manualOverride || !collapsedByDefault || items.every((item) => item.historical)) return;
const timer = window.setTimeout(() => {
setExpanded(false);
}, TOOL_GROUP_AUTO_COLLAPSE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [collapsedByDefault, items, manualOverride]);
const toggleLabel = expanded ? t('acp.collapseToolGroup') : t('acp.expandToolGroup');
const summary = t('acp.toolGroupSummary', { count: items.length });
if (!expanded) {
return (
<button
type="button"
data-testid="acp-tool-calls-group"
data-collapsed="true"
onClick={() => {
setManualOverride(true);
setExpanded(true);
}}
aria-label={toggleLabel}
title={toggleLabel}
className="group flex w-full items-center gap-2 rounded-lg px-1 py-1 text-left text-xs text-muted-foreground transition-colors hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:hover:bg-white/10"
>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 truncate font-medium">
{summary}
</span>
</button>
);
}
return (
<div data-testid="acp-tool-calls-group" data-collapsed="false" className="flex w-full flex-col gap-0">
<button
type="button"
data-testid="acp-tool-calls-group-collapse"
onClick={() => {
setManualOverride(true);
setExpanded(false);
}}
aria-label={toggleLabel}
title={toggleLabel}
className="group flex w-full items-center gap-2 rounded-lg px-1 py-1 text-left text-xs text-muted-foreground transition-colors hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:hover:bg-white/10"
>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 truncate font-medium">
{summary}
</span>
</button>
<div className={cn('grid transition-[grid-template-rows] duration-200 ease-out', 'grid-rows-[1fr]')}>
<div className="min-h-0 overflow-hidden">
<div className="mt-1 flex flex-col gap-0.5">
{items.map((item) => (
<div key={item.id} data-acp-item-id={item.id} className="w-full">
<AcpToolCallCard item={item} grouped />
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import type { ElectronApplication } from '@playwright/test';
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
import { expandAcpToolCallsGroup } from './fixtures/acp-timeline';
const MAIN_SESSION_KEY = 'agent:main:main';
const MAIN_WORKSPACE = '/workspace';
@@ -769,6 +770,68 @@ test.describe('ClawX ACP inline timeline', () => {
}
});
test('collapses multiple replayed tool calls into one group after the turn completes', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
await installAcpLoadReplayMock(app, [
{
sessionUpdate: 'user_message',
messageId: 'history-user',
content: [{ type: 'text', text: 'Check weather' }],
},
{
sessionUpdate: 'tool_call',
toolCallId: 'history-tool-1',
title: 'web_search',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'search results' } }],
locations: [],
},
{
sessionUpdate: 'tool_call',
toolCallId: 'history-tool-2',
title: 'web_fetch',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'fetch results' } }],
locations: [],
},
{
sessionUpdate: 'tool_call',
toolCallId: 'history-tool-3',
title: 'browser',
status: 'failed',
content: [{ type: 'content', content: { type: 'text', text: 'browser failed' } }],
locations: [],
},
{
sessionUpdate: 'agent_message',
messageId: 'history-assistant',
content: [{ type: 'text', text: 'Hangzhou is cloudy today.' }],
},
], [{
normalizedUserText: 'Check weather',
userOccurrenceFromTail: 0,
durationMs: 12_000,
}]);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 });
const group = page.getByTestId('acp-tool-calls-group');
await expect(group).toBeVisible();
await expect(group).toHaveAttribute('data-collapsed', 'true');
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(0);
await expandAcpToolCallsGroup(page);
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(3);
await expect(page.getByTestId('acp-assistant-turn')).toContainText('Hangzhou is cloudy today.');
} finally {
await closeElectronApp(app);
}
});
test('hydrates historical image-generation completions from transcript history when ACP replay omits them', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
+2 -1
View File
@@ -9,6 +9,7 @@ import {
installIpcMocks,
test,
} from './fixtures/electron';
import { expectVisibleToolCallCards } from './fixtures/acp-timeline';
const MAIN_SESSION_KEY = 'agent:main:main';
const OTHER_SESSION_KEY = 'agent:main:other';
@@ -419,7 +420,7 @@ test.describe('ClawX chat file changes', () => {
const page = await openChat(app);
await sendPrompt(page, 'Run non-file activity');
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(2, { timeout: 30_000 });
await expectVisibleToolCallCards(page, 2);
const failedWrite = page.getByTestId('acp-tool-call-card').filter({ hasText: 'Write: failed.ts' });
const unsupportedRead = page.getByTestId('acp-tool-call-card').filter({ hasText: 'Read: unsupported.ts' });
await expect(failedWrite).toContainText('Failed');
+24
View File
@@ -0,0 +1,24 @@
import { expect, type Page } from '@playwright/test';
export async function expandAcpToolCallsGroup(page: Page) {
const group = page.getByTestId('acp-tool-calls-group');
await expect(group).toBeVisible({ timeout: 30_000 });
if (await group.getAttribute('data-collapsed') !== 'false') {
await group.click();
}
await expect(group).toHaveAttribute('data-collapsed', 'false', { timeout: 5_000 });
}
export async function expectVisibleToolCallCards(page: Page, count: number) {
const cards = page.getByTestId('acp-tool-call-card');
if (count <= 1) {
await expect(cards).toHaveCount(count, { timeout: 30_000 });
return;
}
await expandAcpToolCallsGroup(page);
await expect(cards).toHaveCount(count, { timeout: 30_000 });
}
+83
View File
@@ -52,6 +52,9 @@ vi.mock('react-i18next', () => ({
'acp.tool': 'Tool',
'acp.expandTool': 'Expand tool result',
'acp.collapseTool': 'Collapse tool result',
'acp.expandToolGroup': 'Expand tool calls',
'acp.collapseToolGroup': 'Collapse tool calls',
'acp.toolGroupSummary': '{{count}} tool calls',
'acp.permission': 'Permission',
'acp.plan': 'Plan',
'acp.running': 'Running',
@@ -561,6 +564,86 @@ describe('ACP chat timeline components', () => {
expect(screen.getByText('Second assistant segment.')).toBeInTheDocument();
});
it('collapses multiple completed tool calls into one group after the turn settles', () => {
const state = snapshot({
itemOrder: ['tool:exec-1', 'tool:image-1', 'tool:process-1', 'msg-a:0'],
itemsById: {
'tool:exec-1': toolCallItem({ id: 'tool:exec-1', toolCallId: 'exec-1', title: 'exec' }),
'tool:image-1': toolCallItem({ id: 'tool:image-1', toolCallId: 'image-1', title: 'image', outputParts: [] }),
'tool:process-1': toolCallItem({ id: 'tool:process-1', toolCallId: 'process-1', title: 'process', outputParts: [] }),
'msg-a:0': {
kind: 'message-segment',
id: 'msg-a:0',
role: 'assistant',
messageId: 'msg-a',
segmentIndex: 0,
parts: [{ kind: 'markdown', text: 'All done.' }],
},
},
});
render(<AcpTimeline snapshot={state} />);
const group = screen.getByTestId('acp-tool-calls-group');
expect(group).toHaveAttribute('data-collapsed', 'true');
expect(group).toHaveTextContent('3 tool calls');
expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument();
});
it('expands the tool group to show individual tool cards when clicked', () => {
const state = snapshot({
itemOrder: ['tool:exec-1', 'tool:image-1', 'msg-a:0'],
itemsById: {
'tool:exec-1': toolCallItem({ id: 'tool:exec-1', toolCallId: 'exec-1', title: 'exec' }),
'tool:image-1': toolCallItem({ id: 'tool:image-1', toolCallId: 'image-1', title: 'image', outputParts: [] }),
'msg-a:0': {
kind: 'message-segment',
id: 'msg-a:0',
role: 'assistant',
messageId: 'msg-a',
segmentIndex: 0,
parts: [{ kind: 'markdown', text: 'Done.' }],
},
},
});
render(<AcpTimeline snapshot={state} />);
fireEvent.click(screen.getByTestId('acp-tool-calls-group'));
expect(screen.getByTestId('acp-tool-calls-group')).toHaveAttribute('data-collapsed', 'false');
expect(screen.getAllByTestId('acp-tool-call-card')).toHaveLength(2);
expect(screen.getByText('exec')).toBeInTheDocument();
expect(screen.getByText('image')).toBeInTheDocument();
});
it('keeps a running turn tool group expanded until the turn completes', () => {
const state = snapshot({
itemOrder: ['user-a:0', 'tool:exec-1', 'tool:image-1'],
itemsById: {
'user-a:0': {
kind: 'message-segment',
id: 'user-a:0',
role: 'user',
messageId: 'user-a',
segmentIndex: 0,
parts: [{ kind: 'markdown', text: 'Generate assets' }],
},
'tool:exec-1': toolCallItem({ id: 'tool:exec-1', toolCallId: 'exec-1', title: 'exec', status: 'running', outputParts: [] }),
'tool:image-1': toolCallItem({ id: 'tool:image-1', toolCallId: 'image-1', title: 'image', status: 'completed', outputParts: [] }),
},
});
render(<AcpTimeline
snapshot={state}
turnTimingsByUserMessageId={{
'user-a': { source: 'live', status: 'running', startedAtMs: Date.now() - 2_000 },
}}
/>);
expect(screen.getByTestId('acp-tool-calls-group')).toHaveAttribute('data-collapsed', 'false');
expect(screen.getAllByTestId('acp-tool-call-card')).toHaveLength(2);
});
it('keeps completed tool results expanded until the delayed auto-collapse runs', () => {
vi.useFakeTimers();
try {
@@ -93,6 +93,9 @@ vi.mock('react-i18next', () => ({
if (key === 'executionGraph.thinkingLabel') return 'Thinking';
if (key === 'acp.tool') return 'Tool';
if (key === 'acp.completed') return 'Completed';
if (key === 'acp.toolGroupSummary') return `${String(params?.count ?? '')} tool calls`;
if (key === 'acp.expandToolGroup') return 'Expand tool calls';
if (key === 'acp.collapseToolGroup') return 'Collapse tool calls';
if (key === 'welcome.subtitle') return 'What can I do for you?';
if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
return key;
@@ -205,9 +208,8 @@ describe('Chat leading ACP tool calls', () => {
render(<Chat />);
expect(screen.getByTestId('acp-chat-timeline')).toBeInTheDocument();
expect(screen.getAllByTestId('acp-tool-call-card')).toHaveLength(2);
expect(screen.getByText('exec')).toBeInTheDocument();
expect(screen.getByText('image')).toBeInTheDocument();
expect(screen.getByTestId('acp-tool-calls-group')).toHaveAttribute('data-collapsed', 'true');
expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument();
expect(screen.getByText('Continue the task')).toBeInTheDocument();
expect(screen.getByText('Finished.')).toBeInTheDocument();
expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument();
@@ -93,6 +93,9 @@ vi.mock('react-i18next', () => ({
if (key === 'executionGraph.thinkingLabel') return 'Thinking';
if (key === 'acp.tool') return 'Tool';
if (key === 'acp.completed') return 'Completed';
if (key === 'acp.toolGroupSummary') return `${String(params?.count ?? '')} tool calls`;
if (key === 'acp.expandToolGroup') return 'Expand tool calls';
if (key === 'acp.collapseToolGroup') return 'Collapse tool calls';
if (key === 'welcome.subtitle') return 'What can I do for you?';
if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
return key;
@@ -219,10 +222,9 @@ describe('Chat tool card suppression', () => {
render(<Chat />);
expect(screen.getByTestId('acp-chat-timeline')).toBeInTheDocument();
expect(screen.getAllByTestId('acp-tool-call-card')).toHaveLength(3);
expect(screen.getByText('exec')).toBeInTheDocument();
expect(screen.getByText('image')).toBeInTheDocument();
expect(screen.getByText('process')).toBeInTheDocument();
expect(screen.getByTestId('acp-tool-calls-group')).toHaveAttribute('data-collapsed', 'true');
expect(screen.getByTestId('acp-tool-calls-group')).toHaveTextContent('3 tool calls');
expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument();
expect(screen.getByText('All done.')).toBeInTheDocument();
expect(screen.queryByTestId('chat-execution-graph')).not.toBeInTheDocument();
});