工具调用折叠块补齐耗时与时间

- 新增 toolCallGroupPresentation.ts 的耗时 / 时间纯函数:toolCallDurationMs、formatToolCallDuration(0.4s / 12.3s / 2m 5s)、turnToolCallDurationMs、formatTurnDuration(42秒 / 4分钟 / 5分钟 45秒)、formatClockTime、turnToolCallTimeLabel
- ToolCallGroup:块头右侧显示总用时(min(startedAt) → max(updatedAt))与该回合结束时间(拿得到同回合用户消息时间时显示 发送 → 结束)
- ToolCallGroup:展开态每行右侧显示该工具自己的耗时;startedAt 为 0 或时间倒序时不显示耗时(不出现 0s / 负数),running 行显示 执行中
- ToolCallGroup:块与行都带 data-duration-ms(无法计算时为空串),块尾补一行 结束于 HH:mm
- ProjectSupervisorView:按回合把用户消息的 updatedAt 传给块,用于 发送 → 结束 时间显示
- 用例:耗时格式化边界(0 / <1s / 整秒 / ≥60s / 时间倒序)、总用时、每行耗时、无时间戳不渲染耗时
- 技术方案文档补耗时 / 时间 / data-* 契约
This commit is contained in:
2026-09-15 17:37:12 +08:00
parent 35a46e9a44
commit 9f7aed0b2d
6 changed files with 381 additions and 8 deletions
@@ -70,6 +70,16 @@ function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') {
return `direct-codex:${turnId}:${role}`;
}
/** 从 `direct-codex:<turnId>:assistant` 反解回合 id;不是这个形状返回 `null`。 */
function directCodexTurnIdFromAssistantMessageId(messageId: string) {
const prefix = 'direct-codex:';
const suffix = ':assistant';
if (!messageId.startsWith(prefix) || !messageId.endsWith(suffix)) {
return null;
}
return messageId.slice(prefix.length, -suffix.length);
}
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
function directStatusTitle(status: string | null | undefined) {
@@ -233,6 +243,7 @@ export function ProjectSupervisorView({
const streamTurnId = activeTurnId?.trim() ?? '';
const toolCallsByAnchor = new Map<string, GameCreatorDirectToolCall[]>();
const liveToolCalls: GameCreatorDirectToolCall[] = [];
let liveToolCallTurnId = '';
if (directCodex) {
for (const call of toolCalls) {
const expected = directCodexTurnMessageId(call.turnId, 'assistant');
@@ -249,10 +260,24 @@ export function ProjectSupervisorView({
continue;
}
if (streamTurnId && call.turnId === streamTurnId) {
if (!liveToolCallTurnId) {
liveToolCallTurnId = call.turnId;
}
liveToolCalls.push(call);
}
}
}
// 该回合用户消息的 `updatedAt`:拿得到就在块头显示「发送 → 结束」,拿不到只显示结束时间。
const userMessageUpdatedAtForTurn = (turnId: string) => {
if (!turnId) {
return 0;
}
const userId = directCodexTurnMessageId(turnId, 'user');
return (
visibleMessages.find((message) => message.messageId === userId)
?.updatedAt ?? 0
);
};
const emptyState =
directCodex &&
visibleMessages.length === 0 &&
@@ -363,11 +388,17 @@ export function ProjectSupervisorView({
const anchoredToolCalls = message.messageId
? (toolCallsByAnchor.get(message.messageId) ?? [])
: [];
const anchoredTurnId = message.messageId
? directCodexTurnIdFromAssistantMessageId(message.messageId)
: null;
return (
<Fragment key={message.messageId ?? `${message.role}-${index}`}>
{anchoredToolCalls.length > 0 ? (
<ToolCallGroup
calls={anchoredToolCalls}
userSentAt={userMessageUpdatedAtForTurn(
anchoredTurnId ?? '',
)}
className="message-tool-call"
/>
) : null}
@@ -383,6 +414,7 @@ export function ProjectSupervisorView({
{liveToolCalls.length > 0 ? (
<ToolCallGroup
calls={liveToolCalls}
userSentAt={userMessageUpdatedAtForTurn(liveToolCallTurnId)}
className="message-tool-call"
/>
) : null}
@@ -10,26 +10,36 @@ import { useId, useState } from 'react';
import type { GameCreatorDirectToolCall } from '../../app/types';
import {
formatClockTime,
formatToolCallDuration,
formatTurnDuration,
toolCallDurationMs,
toolCallGroupSummary,
toolCallRowText,
turnToolCallDurationMs,
turnToolCallEndedAt,
turnToolCallTimeLabel,
} from './toolCallGroupPresentation';
/**
* 一回合的工具调用折叠块(Codex 风格):
* 块头一行汇总,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
* 块头一行汇总 + 该回合总用时 + 结束时间,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
*
* 无障碍:块头与每一行都是 `<button aria-expanded>` + `hidden` 控制正文,
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
* 文案规则(纯函数)在 `toolCallGroupPresentation.ts`。
* 文案与耗时规则(纯函数)在 `toolCallGroupPresentation.ts`。
*/
/** 一回合的工具调用折叠块;空集合不渲染。 */
export function ToolCallGroup({
calls,
userSentAt = 0,
className,
}: {
calls: GameCreatorDirectToolCall[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
@@ -41,6 +51,10 @@ export function ToolCallGroup({
(left, right) => left.startedAt - right.startedAt,
);
const summary = toolCallGroupSummary(orderedCalls);
const totalDurationMs = turnToolCallDurationMs(orderedCalls);
const durationText = formatTurnDuration(totalDurationMs);
const timeLabel = turnToolCallTimeLabel(orderedCalls, userSentAt);
const headLabel = durationText ? `${summary},用时 ${durationText}` : summary;
const status = orderedCalls.some((call) => call.status === 'running')
? 'running'
: orderedCalls.some((call) => call.status === 'failed')
@@ -55,6 +69,7 @@ export function ToolCallGroup({
}
data-testid="agent-tool-call-group"
data-status={status}
data-duration-ms={totalDurationMs ?? ''}
>
<button
type="button"
@@ -62,14 +77,22 @@ export function ToolCallGroup({
data-testid="agent-tool-call-group-head"
aria-expanded={expanded}
aria-controls={bodyId}
aria-label={summary}
title={summary}
aria-label={headLabel}
title={headLabel}
onClick={() => setExpanded((current) => !current)}
>
<span className="agent-tool-call-group-icon" aria-hidden="true">
<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />
</span>
<span className="agent-tool-call-group-summary">{summary}</span>
{timeLabel ? (
<span className="agent-tool-call-group-time">{timeLabel}</span>
) : null}
{durationText ? (
<span className="agent-tool-call-group-duration">
{`用时 ${durationText}`}
</span>
) : null}
<ChevronDown
className="agent-tool-call-group-chevron"
size={14}
@@ -86,6 +109,14 @@ export function ToolCallGroup({
<ToolCallRow key={call.id} call={call} />
))}
</ul>
{timeLabel ? (
<p
className="agent-tool-call-group-foot"
data-testid="agent-tool-call-group-end-time"
>
{`结束于 ${formatClockTime(turnToolCallEndedAt(orderedCalls))}`}
</p>
) : null}
</div>
</section>
);
@@ -96,13 +127,21 @@ function ToolCallRow({ call }: { call: GameCreatorDirectToolCall }) {
const [expanded, setExpanded] = useState(false);
const detailId = useId();
const text = toolCallRowText(call);
const durationMs = toolCallDurationMs(call);
const durationText = formatToolCallDuration(durationMs);
const statusText =
call.status === 'running'
? '执行中'
: call.status === 'failed'
? '失败'
: '';
const rowLabel = [text, statusText].filter(Boolean).join('');
const rowLabel = [
text,
statusText,
durationText ? `耗时 ${durationText}` : '',
]
.filter(Boolean)
.join('');
const changes = call.detail.changes ?? [];
const detailCommand = call.detail.command?.trim() ?? '';
const detailOutput = call.detail.output?.trim() ?? '';
@@ -114,6 +153,7 @@ function ToolCallRow({ call }: { call: GameCreatorDirectToolCall }) {
data-testid="agent-tool-call-row"
data-kind={call.kind}
data-status={call.status}
data-duration-ms={durationMs ?? ''}
>
<button
type="button"
@@ -131,6 +171,9 @@ function ToolCallRow({ call }: { call: GameCreatorDirectToolCall }) {
{statusText ? (
<span className="agent-tool-call-row-status">{statusText}</span>
) : null}
{durationText ? (
<span className="agent-tool-call-row-duration">{durationText}</span>
) : null}
{hasDetail ? (
<ChevronDown
className="agent-tool-call-row-chevron"
@@ -92,3 +92,126 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) {
}
return call.title.trim();
}
/**
* 单条工具的耗时(毫秒)。
* `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`
* 这两种情况不显示耗时,不显示 `0s` / 负数。
*/
export function toolCallDurationMs(
call: Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>,
): number | null {
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
if (startedAt <= 0 || updatedAt < startedAt) {
return null;
}
return updatedAt - startedAt;
}
/**
* 单条工具的耗时文案:`0.4s`<1s/ `12.3s`<60s,整秒省略小数)/ `2m 5s`(≥60s)。
* 无法计算的耗时(`null` / 0 / 负数)返回 `null`。
*/
export function formatToolCallDuration(ms: number | null | undefined) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
return null;
}
if (ms < 60000) {
const tenths = Math.max(1, Math.round(ms / 100));
if (tenths < 600) {
const value = tenths / 10;
return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`;
}
}
const totalSeconds = Math.max(60, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const restSeconds = totalSeconds % 60;
return restSeconds === 0 ? `${minutes}m` : `${minutes}m ${restSeconds}s`;
}
/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */
export function turnToolCallDurationMs(
calls: Array<Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>>,
): number | null {
let minStartedAt = Number.POSITIVE_INFINITY;
let maxUpdatedAt = Number.NEGATIVE_INFINITY;
for (const call of calls) {
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
if (startedAt > 0) {
minStartedAt = Math.min(minStartedAt, startedAt);
}
if (updatedAt > 0) {
maxUpdatedAt = Math.max(maxUpdatedAt, updatedAt);
}
}
if (!Number.isFinite(minStartedAt) || !Number.isFinite(maxUpdatedAt)) {
return null;
}
if (maxUpdatedAt < minStartedAt) {
return null;
}
return maxUpdatedAt - minStartedAt;
}
/** 块头总用时文案:`42秒` / `4分钟` / `5分钟 45秒`;无法计算的耗时返回 `null`。 */
export function formatTurnDuration(ms: number | null | undefined) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
return null;
}
const seconds = Math.max(1, Math.round(ms / 1000));
if (seconds < 60) {
return `${seconds}`;
}
const minutes = Math.floor(seconds / 60);
const restSeconds = seconds % 60;
return restSeconds === 0
? `${minutes}分钟`
: `${minutes}分钟 ${restSeconds}`;
}
/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */
export function turnToolCallEndedAt(
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
) {
let maxUpdatedAt = 0;
for (const call of calls) {
if (Number.isFinite(call.updatedAt) && call.updatedAt > maxUpdatedAt) {
maxUpdatedAt = call.updatedAt;
}
}
return maxUpdatedAt;
}
/** 本地 `HH:mm`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */
export function formatClockTime(timestamp: number | null | undefined) {
if (
timestamp === null ||
timestamp === undefined ||
!Number.isFinite(timestamp) ||
timestamp <= 0
) {
return null;
}
const date = new Date(timestamp);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm`);
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
*/
export function turnToolCallTimeLabel(
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
userSentAt: number | null | undefined,
) {
const endLabel = formatClockTime(turnToolCallEndedAt(calls));
if (!endLabel) {
return null;
}
const sentLabel = formatClockTime(userSentAt ?? 0);
return sentLabel ? `${sentLabel}${endLabel}` : endLabel;
}
@@ -8260,6 +8260,13 @@ export function registerProjectSupervisorSurfaceTests() {
expect(group.getAttribute('data-status')).toBe('failed');
const groupHead = within(group).getByTestId('agent-tool-call-group-head');
expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更');
// 块头右侧是总用时(min(startedAt) → max(updatedAt)),并在 data-* 上暴露原始毫秒。
expect(group.getAttribute('data-duration-ms')).toBe('100');
expect(groupHead.textContent).toContain('用时 1秒');
// 同一回合的用户消息时间(App 提交时写入)→ 显示「发送 → 结束」。
expect(
groupHead.querySelector('.agent-tool-call-group-time')?.textContent,
).toMatch(/^\d{2}:\d{2} → \d{2}:\d{2}$/);
// 实时回合的块落在消息流末尾:该回合 assistant 消息还没落盘,
// 所以它在上一条 assistant 消息之后,而不是被锚到别人头上。
const liveChildren = Array.from((messageList as HTMLElement).children);
@@ -8296,6 +8303,11 @@ export function registerProjectSupervisorSurfaceTests() {
expect(within(commandRow).getByText('已运行 npm run build')).not.toBeNull();
expect(within(fileRow).getByText('已编辑 game/src/hero.ts')).not.toBeNull();
expect(within(fileRow).getByText('失败')).not.toBeNull();
// 每行右侧显示该工具自己的耗时(`startedAt` → `updatedAt`)。
expect(commandRow.getAttribute('data-duration-ms')).toBe('100');
expect(within(commandRow).getByText('0.1s')).not.toBeNull();
expect(fileRow.getAttribute('data-duration-ms')).toBe('50');
expect(within(fileRow).getByText('0.1s')).not.toBeNull();
// 行可二级展开:默认折叠,展开后看到命令 / 路径 + 变更类型 / 输出。
const commandRowHead = within(commandRow).getByRole('button');
@@ -8514,6 +8526,12 @@ export function registerProjectSupervisorSurfaceTests() {
expect(persistedHead.textContent).toContain(
'已执行 1 个命令、1 个文件变更',
);
// 回读回来的时间戳一样能算总用时与「发送 → 结束」。
expect(persistedGroup.getAttribute('data-duration-ms')).toBe('1000');
expect(persistedHead.textContent).toContain('用时 1秒');
expect(
persistedHead.querySelector('.agent-tool-call-group-time')?.textContent,
).toMatch(/^\d{2}:\d{2} → \d{2}:\d{2}$/);
const messageList = supervisorSurface.querySelector(
'.project-supervisor-message-list',
) as HTMLElement;
@@ -8525,9 +8543,15 @@ export function registerProjectSupervisorSurfaceTests() {
expect(assistantIndex).toBeGreaterThanOrEqual(0);
expect(groupIndex).toBe(assistantIndex - 1);
fireEvent.click(persistedHead);
const persistedRows = within(persistedGroup).getAllByTestId(
'agent-tool-call-row',
);
expect(persistedRows).toHaveLength(2);
// 每行右侧是该工具自己的耗时(0.5s / 0.5s)。
expect(persistedRows[0]?.getAttribute('data-duration-ms')).toBe('500');
expect(
within(persistedGroup).getAllByTestId('agent-tool-call-row'),
).toHaveLength(2);
within(persistedRows[0] as HTMLElement).getByText('0.5s'),
).not.toBeNull();
});
it('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => {
@@ -1,8 +1,13 @@
import type { GameCreatorDirectToolCall } from '../../src/app/types';
import { ToolCallGroup } from '../../src/features/project-workspace/ToolCallGroup';
import {
formatToolCallDuration,
formatTurnDuration,
toolCallDurationMs,
toolCallGroupSummary,
toolCallRowText,
turnToolCallDurationMs,
turnToolCallTimeLabel,
} from '../../src/features/project-workspace/toolCallGroupPresentation';
import { expect, fireEvent, it, React, render, within } from './harness';
@@ -205,4 +210,148 @@ export function registerToolCallGroupTests() {
const empty = render(React.createElement(ToolCallGroup, { calls: [] }));
expect(empty.container.firstChild).toBeNull();
});
it('formats durations and turn totals across the documented boundaries', () => {
// 单条工具耗时:`startedAt` 缺失(0)/ 0 / 时间倒序 → 不显示耗时。
expect(
toolCallDurationMs(toolCall({ id: 'a', kind: 'command' })),
).toBeNull();
expect(formatToolCallDuration(null)).toBeNull();
expect(formatToolCallDuration(0)).toBeNull();
expect(
toolCallDurationMs(
toolCall({
id: 'b',
kind: 'command',
startedAt: 2000,
updatedAt: 1000,
}),
),
).toBeNull();
// <1s 一位小数;<60s 整秒省略小数;≥60s 用 `Xm Ys`。
expect(formatToolCallDuration(400)).toBe('0.4s');
expect(formatToolCallDuration(950)).toBe('1s');
expect(formatToolCallDuration(12300)).toBe('12.3s');
expect(formatToolCallDuration(12000)).toBe('12s');
expect(formatToolCallDuration(125000)).toBe('2m 5s');
expect(formatToolCallDuration(120000)).toBe('2m');
// 一回合总用时 = min(startedAt) → max(updatedAt);缺时间戳的工具被跳过。
const calls = [
toolCall({ id: 'a', kind: 'command', startedAt: 5000, updatedAt: 6000 }),
toolCall({
id: 'b',
kind: 'file_change',
startedAt: 1000,
updatedAt: 9000,
}),
toolCall({ id: 'c', kind: 'web_search' }),
];
expect(turnToolCallDurationMs(calls)).toBe(8000);
expect(formatTurnDuration(8000)).toBe('8秒');
expect(formatTurnDuration(42000)).toBe('42秒');
expect(formatTurnDuration(240000)).toBe('4分钟');
expect(formatTurnDuration(345000)).toBe('5分钟 45秒');
expect(formatTurnDuration(null)).toBeNull();
expect(formatTurnDuration(0)).toBeNull();
// 全部没有时间戳时算不出总用时。
expect(
turnToolCallDurationMs([toolCall({ id: 'd', kind: 'command' })]),
).toBe(null);
// 块头时间:取得到用户消息时间就是「发送 → 结束」,取不到只显示结束时间,都取不到就不显示。
expect(turnToolCallTimeLabel(calls, 1000)).toMatch(
/^\d{2}:\d{2} → \d{2}:\d{2}$/,
);
expect(turnToolCallTimeLabel(calls, 0)).toMatch(/^\d{2}:\d{2}$/);
expect(
turnToolCallTimeLabel([toolCall({ id: 'e', kind: 'command' })], 0),
).toBe(null);
});
it('renders per-row durations plus the turn total, and nothing when timestamps are missing', () => {
const { container } = render(
React.createElement(ToolCallGroup, {
calls: [
toolCall({
id: 'a',
kind: 'command',
summary: 'npm run build',
startedAt: 1000,
updatedAt: 1400,
}),
toolCall({
id: 'b',
kind: 'web_search',
summary: '玩法调研',
startedAt: 1400,
updatedAt: 17900,
}),
toolCall({
id: 'c',
kind: 'file_change',
title: '编辑 1 个文件',
summary: 'game/src/hero.ts',
startedAt: 17900,
updatedAt: 17900,
}),
],
userSentAt: 1000,
}),
);
const group = container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
// 总用时:1000 → 17900,块头显示「用时 17秒」,`data-duration-ms` 暴露原始毫秒。
expect(group.getAttribute('data-duration-ms')).toBe('16900');
const head = within(group).getByTestId('agent-tool-call-group-head');
expect(head.textContent).toContain('用时 17秒');
expect(head.getAttribute('aria-label')).toBe(
'已执行 1 个命令、1 个文件变更、1 个联网搜索,用时 17秒',
);
// 时间戳不写死时区:`HH:mm → HH:mm`(发送 → 结束)。
expect(
head.querySelector('.agent-tool-call-group-time')?.textContent,
).toMatch(/^\d{2}:\d{2} → \d{2}:\d{2}$/);
fireEvent.click(head);
const rows = within(group).queryAllByTestId('agent-tool-call-row');
expect(rows[0]?.getAttribute('data-duration-ms')).toBe('400');
expect(within(rows[0] as HTMLElement).getByText('0.4s')).not.toBeNull();
expect(rows[1]?.getAttribute('data-duration-ms')).toBe('16500');
expect(within(rows[1] as HTMLElement).getByText('16.5s')).not.toBeNull();
// startedAt === updatedAt:耗时为 0 —— `data-duration-ms` 如实暴露 0,但行上不显示 `0s`。
expect(rows[2]?.getAttribute('data-duration-ms')).toBe('0');
expect(within(rows[2] as HTMLElement).queryByText('0s')).toBeNull();
// 块尾显示该回合结束时间。
expect(
within(group).getByTestId('agent-tool-call-group-end-time').textContent,
).toMatch(/^结束于 \d{2}:\d{2}$/);
// 时间戳缺失(startedAt 为 0):块头与行都不显示耗时。
const missing = render(
React.createElement(ToolCallGroup, {
calls: [
toolCall({ id: 'z', kind: 'command', summary: 'npm run build' }),
],
}),
);
const missingGroup = missing.container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
expect(missingGroup.getAttribute('data-duration-ms')).toBe('');
expect(
within(missingGroup).getByTestId('agent-tool-call-group-head')
.textContent,
).toBe('已执行 1 个命令');
fireEvent.click(
within(missingGroup).getByTestId('agent-tool-call-group-head'),
);
const missingRow = within(missingGroup).getByTestId('agent-tool-call-row');
expect(missingRow.getAttribute('data-duration-ms')).toBe('');
expect(within(missingRow).queryByText('0s')).toBeNull();
expect(
missingGroup.querySelector('.agent-tool-call-group-end-time'),
).toBeNull();
});
}
@@ -88,7 +88,9 @@ toolCalls?: DirectTurnToolCall[] | null;
- 文案规则(按 kind,不允许自由发挥):
- 块头汇总按 kind 计数、顺序固定 `command → file_change → mcp_tool → web_search → context_compaction → other`,标签 `命令`/`文件变更`/`工具调用`/`联网搜索`/`上下文整理`/`其他操作`,形如 `已执行 5 个命令、2 个文件变更`;空集合不渲染块。
- 行文案:`command``已运行 {summary}``file_change``已编辑 {summary}``mcp_tool``已调用 {summary}``web_search``已搜索 {summary}``context_compaction``已整理上下文``other``已执行 {summary}``failed` 行加 `失败` 并用现有 `--platform-*` 错误色。
- 耗时:单条工具 `startedAt``updatedAt`,块头 = 该回合所有工具的 `min(startedAt)``max(updatedAt)``startedAt` 为 0 或 `updatedAt < startedAt` 时不显示耗时(不显示 `0s` / 负数)。
- 耗时:单条工具 = `startedAt``updatedAt`,块头总用时 = 该回合所有工具的 `min(startedAt)``max(updatedAt)`单条格式:`<1s``0.4s``<60s``12.3s`(整秒省略小数)、`≥60s``2m 5s`;块头格式:`42秒` / `4分钟` / `5分钟 45秒``startedAt` 为 0 或 `updatedAt < startedAt` 时不显示耗时(不显示 `0s` / 负数),耗时为 0 时同样不显示 `0s`
- 时间:块头显示该回合结束时间(`max(updatedAt)` 的本地 `HH:mm`);同一回合能拿到用户消息时间(`updatedAt > 0`)时显示 `HH:mm → HH:mm`(发送 → 结束),取不到就只显示结束时间,不编造。展开态块尾再写一行 `结束于 HH:mm`
- 调试属性:块与行都带 `data-duration-ms`(原始毫秒,无法计算时为空串)与稳定 `data-testid`(块 `agent-tool-call-group`、行 `agent-tool-call-row`)。
- 必须用 `<button aria-expanded>` + `hidden` 控制展开(键盘可达、可读屏),块头与行都是按钮:`aria-label` = 汇总 / 行文案 + 耗时;默认折叠。
- 输入框、消息气泡、消息列表滚动模型**不变**;块只是消息流里的一个块。