mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
feat: Collapse ACP tool calls into a grouped summary after turn completes (#1198)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
"tool": "ツール",
|
||||
"expandTool": "ツール結果を展開",
|
||||
"collapseTool": "ツール結果を折りたたむ",
|
||||
"expandToolGroup": "ツール呼び出しを展開",
|
||||
"collapseToolGroup": "ツール呼び出しを折りたたむ",
|
||||
"toolGroupSummary": "ツール呼び出し {{count}} 件",
|
||||
"permission": "権限",
|
||||
"plan": "計画",
|
||||
"running": "実行中",
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
"tool": "Инструмент",
|
||||
"expandTool": "Развернуть результат инструмента",
|
||||
"collapseTool": "Свернуть результат инструмента",
|
||||
"expandToolGroup": "Развернуть вызовы инструментов",
|
||||
"collapseToolGroup": "Свернуть вызовы инструментов",
|
||||
"toolGroupSummary": "Вызовов инструментов: {{count}}",
|
||||
"permission": "Разрешение",
|
||||
"plan": "План",
|
||||
"running": "Выполняется",
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
"tool": "工具",
|
||||
"expandTool": "展开工具结果",
|
||||
"collapseTool": "折叠工具结果",
|
||||
"expandToolGroup": "展开工具调用",
|
||||
"collapseToolGroup": "折叠工具调用",
|
||||
"toolGroupSummary": "{{count}} 个工具调用",
|
||||
"permission": "权限",
|
||||
"plan": "计划",
|
||||
"running": "进行中",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user