Merge branch 'feat/chat-toolcall-group' into feat/chat-codex-ui

This commit is contained in:
2026-09-15 18:56:04 +08:00
9 changed files with 1477 additions and 288 deletions
@@ -63,13 +63,23 @@ import {
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import { ToolCallCard } from './ToolCallCard';
import { ToolCallGroup } from './ToolCallGroup';
/** 与 `App.tsx` 的回合消息 id 同构:`direct-codex:<turnId>:<role>`。 */
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) {
@@ -227,37 +237,47 @@ export function ProjectSupervisorView({
}, [settingsOpen]);
const runBusy =
runtimePanelProps.controlBusy || Boolean(directProcessDetail) || submitting;
// 工具调用卡片按回合分组:契约要求插在**同一回合最后一条 assistant 消息之**。
// 当前正在跑的回合还没有 assistant 消息落盘,锚到窗口里最后一条 assistant 消息
// 历史回合一律锚到自己那条 `direct-codex:<turnId>:assistant`,不回落到别的回合
const liveAssistantMessageId = [...visibleMessages]
.reverse()
.find((message) => message.role === 'assistant')?.messageId;
// 工具调用折叠块按回合分组插在**同一回合 assistant 消息之**(Codex 是「工具在上、答复在下」)
// 历史回合锚到自己那条 `direct-codex:<turnId>:assistant`,不回落到别的回合
// 正在跑的回合还没有 assistant 消息落盘,先落在消息流末尾,等那条消息落盘后回到它之前
const streamTurnId = activeTurnId?.trim() ?? '';
const toolCallsByAnchor = new Map<string, GameCreatorDirectToolCall[]>();
const liveToolCalls: GameCreatorDirectToolCall[] = [];
let liveToolCallTurnId = '';
if (directCodex) {
const streamTurnId = activeTurnId?.trim() ?? '';
for (const call of toolCalls) {
const expected = directCodexTurnMessageId(call.turnId, 'assistant');
const hasPersistedAssistant = visibleMessages.some(
(message) => message.messageId === expected,
);
const anchor =
call.turnId === streamTurnId
? (liveAssistantMessageId ?? null)
: hasPersistedAssistant
? expected
: null;
if (!anchor) {
if (hasPersistedAssistant) {
const bucket = toolCallsByAnchor.get(expected);
if (bucket) {
bucket.push(call);
} else {
toolCallsByAnchor.set(expected, [call]);
}
continue;
}
const bucket = toolCallsByAnchor.get(anchor);
if (bucket) {
bucket.push(call);
} else {
toolCallsByAnchor.set(anchor, [call]);
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 &&
@@ -368,24 +388,36 @@ 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}
<div className={`message message--${message.role}`}>
<ChatMarkdownMessage
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
</div>
{anchoredToolCalls.map((call) => (
<ToolCallCard
key={call.id}
call={call}
className="message-tool-call"
/>
))}
</Fragment>
);
})}
{liveToolCalls.length > 0 ? (
<ToolCallGroup
calls={liveToolCalls}
userSentAt={userMessageUpdatedAtForTurn(liveToolCallTurnId)}
className="message-tool-call"
/>
) : null}
{designReasoning ? (
<details className="design-agent-reasoning">
<summary></summary>
@@ -1,117 +0,0 @@
import {
ChevronDown,
FileText,
Globe,
Minimize2,
Terminal,
Wrench,
} from 'lucide-react';
import { useId, useState } from 'react';
import type { GameCreatorDirectToolCall } from '../../app/types';
/**
* 工具调用卡片(Codex 风格):
* 折叠态一行摘要,展开态看命令与文件明细。契约见
* `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
*
* 无障碍:折叠/展开是一只 `<button aria-expanded>` + `hidden` 控制正文,
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
*/
export function ToolCallCard({
call,
className,
}: {
call: GameCreatorDirectToolCall;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
const bodyId = useId();
const statusLabel =
call.status === 'running'
? '执行中'
: call.status === 'failed'
? '失败'
: '已完成';
const summary = call.summary.trim();
const bodyLabel = `${call.title}${summary || call.id}`;
const changes = call.detail.changes ?? [];
return (
<section
className={className ? `agent-tool-call ${className}` : 'agent-tool-call'}
data-kind={call.kind}
data-status={call.status}
data-testid="agent-tool-call-card"
>
<button
type="button"
className="agent-tool-call-head"
aria-expanded={expanded}
aria-controls={bodyId}
aria-label={bodyLabel}
title={bodyLabel}
onClick={() => setExpanded((current) => !current)}
>
<span className="agent-tool-call-icon" aria-hidden="true">
<ToolCallIcon kind={call.kind} />
</span>
<span className="agent-tool-call-title">{call.title}</span>
{summary ? (
<span className="agent-tool-call-summary">{summary}</span>
) : null}
{call.status === 'running' ? (
<span className="agent-tool-call-running-dot" aria-hidden="true" />
) : null}
<span className="agent-tool-call-status">{statusLabel}</span>
<ChevronDown
className="agent-tool-call-chevron"
size={14}
aria-hidden="true"
/>
</button>
<div className="agent-tool-call-body" id={bodyId} hidden={!expanded}>
{call.detail.command ? (
<pre className="agent-tool-call-command">{call.detail.command}</pre>
) : null}
{changes.length > 0 ? (
<ul className="agent-tool-call-changes">
{changes.map((change, index) => (
<li key={`${change.path}-${index}`}>
{change.path}
<small>{toolCallChangeKindLabel(change.kind)}</small>
</li>
))}
</ul>
) : null}
{call.detail.output ? (
<pre className="agent-tool-call-output">{call.detail.output}</pre>
) : null}
</div>
</section>
);
}
function toolCallChangeKindLabel(kind: string) {
if (kind === 'add') {
return '新增';
}
if (kind === 'delete') {
return '删除';
}
return '修改';
}
function ToolCallIcon({ kind }: { kind: string }) {
switch (kind) {
case 'command':
return <Terminal size={14} />;
case 'file_change':
return <FileText size={14} />;
case 'web_search':
return <Globe size={14} />;
case 'context_compaction':
return <Minimize2 size={14} />;
default:
return <Wrench size={14} />;
}
}
@@ -0,0 +1,234 @@
import {
ChevronDown,
FileText,
Globe,
Minimize2,
Terminal,
Wrench,
} from 'lucide-react';
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`。
*/
/** 一回合的工具调用折叠块;空集合不渲染。 */
export function ToolCallGroup({
calls,
userSentAt = 0,
className,
}: {
calls: GameCreatorDirectToolCall[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
const bodyId = useId();
if (calls.length === 0) {
return null;
}
const orderedCalls = [...calls].sort(
(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')
? 'failed'
: 'completed';
return (
<section
className={
className
? `agent-tool-call-group ${className}`
: 'agent-tool-call-group'
}
data-testid="agent-tool-call-group"
data-status={status}
data-duration-ms={totalDurationMs ?? ''}
>
<button
type="button"
className="agent-tool-call-group-head"
data-testid="agent-tool-call-group-head"
aria-expanded={expanded}
aria-controls={bodyId}
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}
aria-hidden="true"
/>
</button>
<div
className="agent-tool-call-group-body"
id={bodyId}
hidden={!expanded}
>
<ul className="agent-tool-call-group-rows">
{orderedCalls.map((call) => (
<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>
);
}
/** 展开态的一行工具;行可二级展开看命令 / 文件明细 / 输出。 */
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,
durationText ? `耗时 ${durationText}` : '',
]
.filter(Boolean)
.join('');
const changes = call.detail.changes ?? [];
const detailCommand = call.detail.command?.trim() ?? '';
const detailOutput = call.detail.output?.trim() ?? '';
const hasDetail =
Boolean(detailCommand) || Boolean(detailOutput) || changes.length > 0;
return (
<li
className="agent-tool-call-group-row"
data-testid="agent-tool-call-row"
data-kind={call.kind}
data-status={call.status}
data-duration-ms={durationMs ?? ''}
>
<button
type="button"
className="agent-tool-call-row-head"
aria-expanded={expanded}
aria-controls={detailId}
aria-label={rowLabel}
title={rowLabel}
onClick={() => setExpanded((current) => !current)}
>
<span className="agent-tool-call-row-icon" aria-hidden="true">
<ToolCallKindIcon kind={call.kind} />
</span>
<span className="agent-tool-call-row-text">{text}</span>
{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"
size={12}
aria-hidden="true"
/>
) : null}
</button>
<div
className="agent-tool-call-row-detail"
id={detailId}
hidden={!expanded}
>
{detailCommand ? (
<pre className="agent-tool-call-row-command">{detailCommand}</pre>
) : null}
{changes.length > 0 ? (
<ul className="agent-tool-call-row-changes">
{changes.map((change, index) => (
<li key={`${change.path}-${index}`}>
<span>{change.path}</span>
<small>{toolCallChangeKindLabel(change.kind)}</small>
</li>
))}
</ul>
) : null}
{detailOutput ? (
<pre className="agent-tool-call-row-output">{detailOutput}</pre>
) : null}
</div>
</li>
);
}
function toolCallChangeKindLabel(kind: string) {
if (kind === 'add') {
return '新增';
}
if (kind === 'delete') {
return '删除';
}
return '修改';
}
function ToolCallKindIcon({ kind }: { kind: string }) {
switch (kind) {
case 'command':
return <Terminal size={14} />;
case 'file_change':
return <FileText size={14} />;
case 'web_search':
return <Globe size={14} />;
case 'context_compaction':
return <Minimize2 size={14} />;
default:
return <Wrench size={14} />;
}
}
@@ -0,0 +1,217 @@
import type {
GameCreatorDirectToolCall,
GameCreatorDirectToolCallKind,
} from '../../app/types';
/**
* 工具调用折叠块的纯文案计算:汇总 / 行文案。
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
*
* 与组件分文件:`ToolCallGroup.tsx` 只导出组件,纯函数放这里(react-refresh 要求
* 组件文件不导出非组件值,也便于单测直接断言文案规则)。
*/
/** 汇总文案里 kind 的固定顺序:command → file_change → mcp_tool → web_search → context_compaction → other。 */
const TOOL_CALL_KIND_ORDER: GameCreatorDirectToolCallKind[] = [
'command',
'file_change',
'mcp_tool',
'web_search',
'context_compaction',
'other',
];
const TOOL_CALL_KIND_LABELS: Record<GameCreatorDirectToolCallKind, string> = {
command: '命令',
file_change: '文件变更',
mcp_tool: '工具调用',
web_search: '联网搜索',
context_compaction: '上下文整理',
other: '其他操作',
};
/** 行文案动词:`已运行 {summary}` / `已编辑 {summary}` / …,`context_compaction` 不带 summary。 */
const TOOL_CALL_ROW_VERBS: Partial<
Record<GameCreatorDirectToolCallKind, string>
> = {
command: '已运行',
file_change: '已编辑',
mcp_tool: '已调用',
web_search: '已搜索',
other: '已执行',
};
/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */
export function toolCallGroupSummary(calls: GameCreatorDirectToolCall[]) {
const counts = new Map<string, number>();
for (const call of calls) {
counts.set(call.kind, (counts.get(call.kind) ?? 0) + 1);
}
const parts: string[] = [];
const append = (kind: string) => {
const count = counts.get(kind) ?? 0;
if (count <= 0) {
return;
}
counts.delete(kind);
const label =
TOOL_CALL_KIND_LABELS[kind as GameCreatorDirectToolCallKind] ??
'其他操作';
parts.push(`${count}${label}`);
};
for (const kind of TOOL_CALL_KIND_ORDER) {
append(kind);
}
// 契约外的 kind:不丢计数,落到末尾的「其他操作」。
for (const kind of [...counts.keys()]) {
append(kind);
}
return parts.length > 0 ? `已执行 ${parts.join('、')}` : '';
}
/** 一行工具的文案:`已运行 npm run build` / `已整理上下文`。 */
export function toolCallRowText(call: GameCreatorDirectToolCall) {
if (call.kind === 'context_compaction') {
return '已整理上下文';
}
const summary = toolCallRowSummary(call);
const verb = TOOL_CALL_ROW_VERBS[call.kind] ?? '已执行';
return summary ? `${verb} ${summary}` : verb;
}
function toolCallRowSummary(call: GameCreatorDirectToolCall) {
const summary = call.summary.trim();
if (summary) {
return summary;
}
if (call.kind === 'file_change') {
const firstPath = call.detail.changes?.[0]?.path?.trim();
if (firstPath) {
return firstPath;
}
}
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;
}
+139 -48
View File
@@ -11293,59 +11293,64 @@ button.design-workspace-tree__entry:hover,
}
/* ============================================================
工具调用卡片2026-09):Codex 风格可折叠卡片
工具调用折叠块2026-09):一回合一个块,Codex 风格
============================================================
契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
折叠态一行摘要(图标 / 标题 / 摘要 / 状态点 / 状态 / 折角),展开态看命令、文件明细与输出。
块头一行浅底圆角条目(图标 / 汇总 / 结束时间 / 总用时 / 折角),展开态每行一条工具,
行右侧是对齐的耗时,行可二级展开看命令、文件明细与输出。
折叠由 `<button aria-expanded>` + `hidden` 控制(不改消息气泡与列表滚动模型),
配色只用现有 `--platform-*` 变量。 */
/* 卡片不进气泡:它是消息流里跟在 assistant 消息之的一个块,左右与消息对齐。 */
/* 不进气泡:它是消息流里插在该回合 assistant 消息之的一个块,左右与消息对齐。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
> .agent-tool-call {
> .agent-tool-call-group {
display: grid;
width: 100%;
min-width: 0;
margin-top: -4px;
margin-top: 10px;
margin-bottom: 2px;
}
.agent-tool-call {
.agent-tool-call-group {
display: grid;
width: 100%;
min-width: 0;
margin-top: 10px;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 12px;
background: var(--platform-button-secondary-fill);
color: var(--platform-text-base);
}
.agent-tool-call-head {
/* 块头:点整行展开,浅底条目本身是块头。 */
.agent-tool-call-group-head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 8px 10px;
min-height: 32px;
padding: 6px 10px;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease;
}
.agent-tool-call-head:hover,
.agent-tool-call-head:focus-visible {
.agent-tool-call-group-head:hover,
.agent-tool-call-group-head:focus-visible {
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
outline: none;
}
.agent-tool-call-icon {
.agent-tool-call-group-icon {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -11353,68 +11358,139 @@ button.design-workspace-tree__entry:hover,
color: var(--platform-text-soft);
}
.agent-tool-call-title {
flex: 0 0 auto;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 600;
}
/* 摘要压在标题后面:窄面板下靠 ellipsis 收窄,不换行、不把状态挤出可视区。 */
.agent-tool-call-summary {
/* 汇总压在图标后面:窄面板下靠 ellipsis 收窄,不换行、不把时间与用时挤出可视区。 */
.agent-tool-call-group-summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--platform-text-soft);
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tool-call-status {
.agent-tool-call-group-time,
.agent-tool-call-group-duration {
flex: 0 0 auto;
color: var(--platform-text-soft);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.agent-tool-call-running-dot {
width: 6px;
height: 6px;
flex: 0 0 6px;
border-radius: 50%;
background: var(--platform-accent);
}
.agent-tool-call-chevron {
.agent-tool-call-group-chevron {
flex: 0 0 auto;
color: var(--platform-text-soft);
transition: transform 120ms ease;
}
.agent-tool-call-head[aria-expanded='true'] .agent-tool-call-chevron {
.agent-tool-call-group-head[aria-expanded='true']
.agent-tool-call-group-chevron {
transform: rotate(180deg);
}
/* `failed` 用错误色语义变量:标题、状态文案与图标一起变,不新增色值。 */
.agent-tool-call[data-status='failed'] .agent-tool-call-title,
.agent-tool-call[data-status='failed'] .agent-tool-call-status,
.agent-tool-call[data-status='failed'] .agent-tool-call-icon {
color: var(--platform-button-danger-text);
/* `hidden` 必须压过下面的 `display: grid`,否则折叠态只是内容不可见却仍占位。 */
.agent-tool-call-group-body[hidden],
.agent-tool-call-row-detail[hidden] {
display: none;
}
.agent-tool-call[data-status='failed'] {
border-color: var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
.agent-tool-call-group-body {
display: grid;
gap: 2px;
padding: 0 6px 8px;
}
.agent-tool-call-body {
.agent-tool-call-group-rows {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.agent-tool-call-group-row {
min-width: 0;
}
/* 一行一条工具:单行不换行,超长命令靠 ellipsis 收窄,耗时右对齐。 */
.agent-tool-call-row-head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
font-size: 12px;
line-height: 1.45;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease;
}
.agent-tool-call-row-head:hover,
.agent-tool-call-row-head:focus-visible {
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
outline: none;
}
.agent-tool-call-row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--platform-text-soft);
}
.agent-tool-call-row-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tool-call-row-status {
flex: 0 0 auto;
color: var(--platform-text-soft);
font-size: 11px;
}
/* 耗时贴右边缘:`margin-left: auto` 把「执行中 / 失败」与耗时分开,窄面板下也不会互相挤压。 */
.agent-tool-call-row-duration {
flex: 0 0 auto;
margin-left: auto;
color: var(--platform-text-soft);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.agent-tool-call-row-chevron {
flex: 0 0 auto;
color: var(--platform-text-soft);
transition: transform 120ms ease;
}
.agent-tool-call-row-head[aria-expanded='true'] .agent-tool-call-row-chevron {
transform: rotate(180deg);
}
.agent-tool-call-row-detail {
display: grid;
gap: 8px;
padding: 0 10px 10px;
padding: 6px 8px 8px;
}
.agent-tool-call-command,
.agent-tool-call-output {
.agent-tool-call-row-command,
.agent-tool-call-row-output {
margin: 0;
max-height: 220px;
padding: 8px 9px;
@@ -11429,7 +11505,7 @@ button.design-workspace-tree__entry:hover,
overflow-wrap: anywhere;
}
.agent-tool-call-changes {
.agent-tool-call-row-changes {
display: grid;
gap: 4px;
margin: 0;
@@ -11437,7 +11513,7 @@ button.design-workspace-tree__entry:hover,
list-style: none;
}
.agent-tool-call-changes li {
.agent-tool-call-row-changes li {
display: flex;
align-items: baseline;
gap: 6px;
@@ -11447,12 +11523,27 @@ button.design-workspace-tree__entry:hover,
overflow-wrap: anywhere;
}
.agent-tool-call-changes li small {
.agent-tool-call-row-changes li small {
flex: 0 0 auto;
color: var(--platform-text-soft);
font-size: 10px;
}
.agent-tool-call-group-foot {
margin: 0;
padding: 0 8px 2px;
color: var(--platform-text-soft);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
/* `failed` 行用错误色语义变量:行文案、状态与图标一起变,不新增色值。 */
.agent-tool-call-group-row[data-status='failed'] .agent-tool-call-row-text,
.agent-tool-call-group-row[data-status='failed'] .agent-tool-call-row-status,
.agent-tool-call-group-row[data-status='failed'] .agent-tool-call-row-icon {
color: var(--platform-button-danger-text);
}
/* ============================================================
右侧对话面板:输入盒内文字与控件的对齐(2026-09)
============================================================
@@ -35,6 +35,7 @@ import {
registerRuntimeSettingsTests,
} from './appSurface/runtime-settings.suite';
import { registerSupervisorRuntimeTests } from './appSurface/supervisor-runtime.suite';
import { registerToolCallGroupTests } from './appSurface/tool-call-group.suite';
/**
* 原生文件对话框是宿主能力,不能在 jsdom 里真开窗。
@@ -74,4 +75,5 @@ describe('AI 游戏创作 App 界面边界', () => {
registerCanvasAssetTests();
registerPlanGddApprovalTests();
registerDesignAgentSurfaceTests();
registerToolCallGroupTests();
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,357 @@
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';
function toolCall(
overrides: Partial<GameCreatorDirectToolCall> &
Pick<GameCreatorDirectToolCall, 'id' | 'kind'>,
): GameCreatorDirectToolCall {
return {
schemaVersion: 'agc-tool-call.v1',
turnId: 'turn-1',
title: '执行命令',
summary: 'npm run build',
status: 'completed',
detail: {},
startedAt: 0,
updatedAt: 0,
...overrides,
};
}
export function registerToolCallGroupTests() {
it('summarizes tool calls by kind in a fixed order', () => {
// 单 kind。
expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe(
'已执行 1 个命令',
);
// 混合:顺序固定 command → file_change → mcp_tool → web_search →
// context_compaction → other,与传入顺序无关。
expect(
toolCallGroupSummary([
toolCall({ id: 'a', kind: 'other' }),
toolCall({ id: 'b', kind: 'web_search' }),
toolCall({ id: 'c', kind: 'command' }),
toolCall({ id: 'd', kind: 'command' }),
toolCall({ id: 'e', kind: 'file_change' }),
toolCall({ id: 'f', kind: 'context_compaction' }),
toolCall({ id: 'g', kind: 'mcp_tool' }),
]),
).toBe(
'已执行 2 个命令、1 个文件变更、1 个工具调用、1 个联网搜索、1 个上下文整理、1 个其他操作',
);
// 空集合。
expect(toolCallGroupSummary([])).toBe('');
});
it('builds the row text from the tool kind', () => {
expect(
toolCallRowText(
toolCall({ id: 'a', kind: 'command', summary: 'npm run build' }),
),
).toBe('已运行 npm run build');
expect(
toolCallRowText(
toolCall({
id: 'b',
kind: 'file_change',
summary: 'game/src/hero.ts',
}),
),
).toBe('已编辑 game/src/hero.ts');
expect(
toolCallRowText(
toolCall({ id: 'c', kind: 'mcp_tool', summary: 'canvas.sync' }),
),
).toBe('已调用 canvas.sync');
expect(
toolCallRowText(
toolCall({ id: 'd', kind: 'web_search', summary: '弹幕游戏玩法' }),
),
).toBe('已搜索 弹幕游戏玩法');
// 上下文整理不带 summary。
expect(
toolCallRowText(
toolCall({
id: 'e',
kind: 'context_compaction',
summary: 'should be ignored',
}),
),
).toBe('已整理上下文');
expect(
toolCallRowText(
toolCall({ id: 'f', kind: 'other', summary: '未知动作' }),
),
).toBe('已执行 未知动作');
});
it('renders one collapsed block per turn and lists every tool on expand', async () => {
const calls = [
toolCall({ id: 'a', kind: 'command', summary: 'npm run build' }),
toolCall({
id: 'b',
kind: 'file_change',
title: '编辑 1 个文件',
summary: 'game/src/hero.ts',
status: 'running',
detail: { changes: [{ path: 'game/src/hero.ts', kind: 'update' }] },
}),
];
const { container } = render(React.createElement(ToolCallGroup, { calls }));
const group = container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
expect(group).not.toBeNull();
const head = within(group).getByTestId('agent-tool-call-group-head');
// 块头是一行按钮:图标 + 汇总 + 展开箭头,默认折叠,正文由 `hidden` 收起。
expect(head.tagName).toBe('BUTTON');
expect(head.getAttribute('aria-expanded')).toBe('false');
expect(head.getAttribute('aria-label')).toBe(
'已执行 1 个命令、1 个文件变更',
);
expect(
head.querySelector('.agent-tool-call-group-summary')?.textContent,
).toBe('已执行 1 个命令、1 个文件变更');
const body = container.querySelector(
`#${head.getAttribute('aria-controls')}`,
);
expect(body?.hasAttribute('hidden')).toBe(true);
expect(within(group).queryAllByTestId('agent-tool-call-row')).toHaveLength(
2,
);
// 展开:行数 = 工具数,每行一条,按 startedAt 升序。
fireEvent.click(head);
expect(head.getAttribute('aria-expanded')).toBe('true');
expect(body?.hasAttribute('hidden')).toBe(false);
const rows = within(group).queryAllByTestId('agent-tool-call-row');
expect(rows).toHaveLength(2);
expect(rows.map((row) => row.getAttribute('data-kind'))).toEqual([
'command',
'file_change',
]);
expect(
within(rows[0] as HTMLElement).getByText('已运行 npm run build'),
).not.toBeNull();
expect(
within(rows[1] as HTMLElement).getByText('已编辑 game/src/hero.ts'),
).not.toBeNull();
// running 的行在行内标「执行中」。
expect(within(rows[1] as HTMLElement).getByText('执行中')).not.toBeNull();
});
it('expands a row to its own detail and renders nothing for an empty turn', () => {
const { container } = render(
React.createElement(ToolCallGroup, {
calls: [
toolCall({
id: 'a',
kind: 'command',
summary: 'npm run build',
detail: { command: 'npm run build', output: 'build ok' },
}),
toolCall({
id: 'b',
kind: 'file_change',
title: '编辑 1 个文件',
summary: 'game/src/hero.ts',
status: 'failed',
detail: { changes: [{ path: 'game/src/hero.ts', kind: 'add' }] },
}),
],
}),
);
const head = container.querySelector(
'[data-testid="agent-tool-call-group-head"]',
) as HTMLElement;
fireEvent.click(head);
const rows = container.querySelectorAll(
'[data-testid="agent-tool-call-row"]',
);
// 行也是按钮:`aria-expanded` + `aria-controls` 指向自己的明细,默认折叠。
const commandHead = within(rows[0] as HTMLElement).getByRole('button');
expect(commandHead.getAttribute('aria-expanded')).toBe('false');
expect(commandHead.getAttribute('aria-label')).toBe('已运行 npm run build');
const commandDetail = container.querySelector(
`#${commandHead.getAttribute('aria-controls')}`,
);
expect(commandDetail?.hasAttribute('hidden')).toBe(true);
fireEvent.click(commandHead);
expect(commandHead.getAttribute('aria-expanded')).toBe('true');
expect(commandDetail?.hasAttribute('hidden')).toBe(false);
expect(
within(commandDetail as HTMLElement).getByText('build ok'),
).not.toBeNull();
// 文件变更明细:路径 + 变更类型。
const fileHead = within(rows[1] as HTMLElement).getByRole('button');
expect(fileHead.getAttribute('aria-label')).toBe(
'已编辑 game/src/hero.ts,失败',
);
fireEvent.click(fileHead);
const fileDetail = container.querySelector(
`#${fileHead.getAttribute('aria-controls')}`,
);
expect(
within(fileDetail as HTMLElement).getByText('game/src/hero.ts'),
).not.toBeNull();
expect(within(fileDetail as HTMLElement).getByText('新增')).not.toBeNull();
// 空集合不渲染块。
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();
});
}
@@ -48,32 +48,51 @@ toolCalls?: DirectTurnToolCall[] | null;
- 历史文件缺失 → 返回空数组,不报错。
- 单行损坏 → 跳过该行继续,不整体失败(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。
### 4. 前端合并与渲染
### 4. 前端合并与渲染(2026-09 修订:一回合一个折叠块)
- 加载对话时把回读结果按 `turnId` 归并进消息流:工具调用卡插在**同一回合最后一条 assistant 消息之后**,同一回合内按 `startedAt` 升序。
- 实时回合(`directCodexProductRuntime``activeDirectCodexTurnRef` 命中)时,卡片跟着事件增量更新;回合结束后由持久化数据接管(不出现重复卡片,同一 `id` 只渲染一)。
- 卡片 DOM 与交互(对齐 Codex):
- 加载对话时把回读结果按 `turnId` 归并进消息流:**同一回合的工具调用收成一个折叠块**,块插在该回合 **assistant 消息之前**(Codex 是「工具在上、答复在下」),同一回合内按 `startedAt` 升序。
- 实时回合(`directCodexProductRuntime``activeDirectCodexTurnRef` 命中)时,跟着事件增量更新;回合的 assistant 消息还没落盘时,块落在消息流末尾(下一条 assistant 消息一落盘,块就回到它之前),回合结束后由持久化数据接管(不出现重复,同一 `id` 每条工具只渲染一)。
- DOM 与交互(对齐 Codex):
```html
<section class="agent-tool-call" data-kind="command" data-status="running">
<button type="button" class="agent-tool-call-head" aria-expanded="false">
<span class="agent-tool-call-icon" aria-hidden="true"></span>
<span class="agent-tool-call-title">执行命令</span>
<span class="agent-tool-call-summary">npm run build</span>
<span class="agent-tool-call-status">执行中</span>
<svg class="agent-tool-call-chevron" aria-hidden="true"></svg>
<section class="agent-tool-call-group" data-testid="agent-tool-call-group" data-status="completed">
<button type="button" class="agent-tool-call-group-head" aria-expanded="false" aria-controls="…"
aria-label="已执行 2 个命令、1 个文件变更,用时 42秒">
<span class="agent-tool-call-group-icon" aria-hidden="true"></span>
<span class="agent-tool-call-group-summary">已执行 2 个命令、1 个文件变更</span>
<span class="agent-tool-call-group-time">14:20 → 14:21</span>
<span class="agent-tool-call-group-duration">用时 42秒</span>
<svg class="agent-tool-call-group-chevron" aria-hidden="true"></svg>
</button>
<div class="agent-tool-call-body" hidden>
<pre class="agent-tool-call-command">...</pre>
<ul class="agent-tool-call-changes"><li>game/src/x.ts <small>新增</small></li></ul>
<pre class="agent-tool-call-output">...</pre>
<div class="agent-tool-call-group-body" hidden>
<ul class="agent-tool-call-group-rows">
<li class="agent-tool-call-group-row" data-testid="agent-tool-call-row" data-kind="command" data-duration-ms="12300">
<button type="button" class="agent-tool-call-row-head" aria-expanded="false" aria-controls="…"
aria-label="已运行 npm run build,耗时 12.3s">
<span class="agent-tool-call-row-icon" aria-hidden="true"></span>
<span class="agent-tool-call-row-text">已运行 npm run build</span>
<span class="agent-tool-call-row-duration">12.3s</span>
<svg class="agent-tool-call-row-chevron" aria-hidden="true"></svg>
</button>
<div class="agent-tool-call-row-detail" hidden>
<pre class="agent-tool-call-row-command"></pre>
<ul class="agent-tool-call-row-changes"><li><span>game/src/x.ts</span><small>新增</small></li></ul>
<pre class="agent-tool-call-row-output"></pre>
</div>
</li>
</ul>
</div>
</section>
```
- 必须用 `<button aria-expanded>` + `hidden` 控制展开(键盘可达、可读屏),`aria-label` 说明「执行命令:npm run build」。
- 默认折叠;`running` 时标题右侧显示进行中状态点;`failed` 时标题与状态文案用错误色(用现有 `--platform-*` 变量,不新增色值)
- 输入框、消息气泡、消息列表滚动模型**不变**;卡片只是消息流里的一个块
- 文案规则(按 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)`。单条格式:`<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` = 汇总 / 行文案 + 耗时;默认折叠。
- 输入框、消息气泡、消息列表滚动模型**不变**;块只是消息流里的一个块。
## 验收判据(每条都要有可复现证据)