统一思考与执行过程折叠样式及中文耗时

思考展开复用安全Markdown渲染,折叠显示浅色单行纯文本预览与右侧箭头。
共享过程摘要组件,执行过程按真实操作数显示统计并保留各组独立计时。
工具、整轮、生成任务和策划耗时统一为中文时分秒格式。
补齐格式与Markdown回归、更新已有界面断言并验证窄屏键盘操作。
This commit is contained in:
2026-09-18 14:34:10 +08:00
parent 21b059bd41
commit a0f6b7c67d
21 changed files with 604 additions and 177 deletions
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
PlanGddDecisionAction,
@@ -171,7 +172,7 @@ export function PlanGddStageProgress({
: `当前版本:v${latestVersion}`}
</span>
{processingSeconds > 0 ? (
<span>{`处理耗时:${processingSeconds.toFixed(1)}`}</span>
<span>{`处理耗时:${formatElapsedDuration(processingSeconds * 1000) ?? '—'}`}</span>
) : null}
</div>
{deliveredGdd ? (
@@ -1,4 +1,12 @@
import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react';
import {
ArrowUp,
AtSign,
Brain,
ChevronDown,
Loader2,
Settings,
Wrench,
} from 'lucide-react';
import type {
ComponentProps,
FormEventHandler,
@@ -12,6 +20,7 @@ import {
AgentMessageContent,
type AgentMessageTone,
} from '../../../../../packages/shared/src/components/AgentMessageContent';
import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary';
import type {
AgentStatusCard,
ChatMessage,
@@ -41,6 +50,7 @@ import {
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
import { agentProcessPreview } from './agentProcessPreview';
import type { QueuedChatTurn } from './chatComposerQueue';
import {
ComposerPendingAttachments,
@@ -81,6 +91,7 @@ import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup';
import {
formatClockTime,
resolveToolGroupTiming,
resolveTurnTiming,
} from './toolCallGroupPresentation';
import { useLiveNow } from './useLiveNow';
@@ -114,6 +125,8 @@ function AgentReasoning({
label?: string;
testId?: string;
}) {
// 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。
const preview = agentProcessPreview(text);
return (
<AgentMessageContent
as="details"
@@ -122,8 +135,15 @@ function AgentReasoning({
aria-label={label}
data-testid={testId}
>
<summary></summary>
<pre>{text}</pre>
<summary>
<AgentProcessSummary
icon={<Brain size={12} aria-hidden="true" />}
preview={preview || '思考过程'}
chevron={<ChevronDown size={12} aria-hidden="true" />}
/>
</summary>
{/* 展开态复用助手正文的安全 Markdown 链路(内部 skipHtml,不用 innerHTML)。 */}
<ChatMarkdownMessage role="assistant" text={text} />
</AgentMessageContent>
);
}
@@ -486,6 +506,15 @@ export function ProjectSupervisorView({
.reverse()
.find((block) => block.kind === 'assistant')?.key ?? null)
: null;
// 外层"执行过程"汇总**这一轮全部工具调用**(不是过程段落数,也不含思考段):
// 计数 = 所有组的 calls 之和;耗时 = 首工具开始 → 末工具完成(各组的并集跨度,
// 不是整轮总耗时,也不与整轮那处重复);边界不完整时隐藏。
const turnToolCalls = turn.process.flatMap((block) =>
block.kind === 'tools' ? block.calls : [],
);
const turnToolSpan = resolveToolGroupTiming(turnToolCalls, {
running: false,
});
const renderBlock = (
block: DirectChatBlock,
tone: AgentMessageTone = 'body',
@@ -553,7 +582,24 @@ export function ProjectSupervisorView({
className="message-turn-process"
data-testid="turn-process"
>
<summary></summary>
<summary>
<AgentProcessSummary
icon={<Wrench size={12} aria-hidden="true" />}
preview={
turnToolCalls.length > 0
? `执行了 ${turnToolCalls.length} 个操作`
: '执行过程'
}
meta={
turnToolSpan.durationText
? `,耗时 ${turnToolSpan.durationText}`
: ''
}
chevron={
<ChevronDown size={12} aria-hidden="true" />
}
/>
</summary>
<div className="message-turn-process-body">
{turn.process.map((block) =>
renderBlock(block, 'process'),
@@ -9,6 +9,7 @@ import {
import { useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary';
import type { DirectChatToolCard } from './directThreadChat';
import {
formatToolCallDuration,
@@ -78,13 +79,16 @@ export function ToolCallGroup({
: orderedCalls.some((call) => call.status === 'failed')
? 'failed'
: 'completed';
const headLabel = [
summary,
// 用户口径:`执行了 X 个操作,耗时 XXX`。耗时属于**这一组**(不是整轮),可见文本里用
// 中文逗号连起来,所以分隔符跟着 meta 一起进 DOM。
// 顺序按用户口径:`执行了 X 个操作,耗时 XXX`,运行状态挂到最后,不插在中间。
const headMeta = [
timing.durationText ? `耗时 ${timing.durationText}` : '',
toolRunning ? '进行中' : '',
timing.durationText ? `本组用时 ${timing.durationText}` : '',
]
.filter(Boolean)
.join('');
const headLabel = [summary, headMeta].filter(Boolean).join('');
return (
<AgentMessageContent
as="section"
@@ -109,24 +113,17 @@ export function ToolCallGroup({
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>
<span className="agent-tool-call-group-meta">
{toolRunning ? (
<span className="agent-tool-call-group-running"></span>
) : null}
{timing.durationText ? (
<span className="agent-tool-call-group-duration">
{`本组用时 ${timing.durationText}`}
</span>
) : null}
</span>
<ChevronDown
className="agent-tool-call-group-chevron"
size={14}
aria-hidden="true"
<AgentProcessSummary
icon={<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />}
preview={summary}
meta={headMeta ? `${headMeta}` : ''}
chevron={
<ChevronDown
className="agent-tool-call-group-chevron"
size={14}
aria-hidden="true"
/>
}
/>
</button>
<div
@@ -0,0 +1,73 @@
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
/** 折叠态单行预览的字数上限。 */
export const AGENT_PROCESS_PREVIEW_MAX_CHARS = 80;
type PreviewNode = {
type?: string;
value?: unknown;
alt?: unknown;
children?: unknown;
};
/**
* 只从 AST 里取**可见文字**`html` 节点整棵跳过(`<script>` / `<img>` 这类原文不进预览),
* 图片只取 alt,代码取源码值,其它节点递归子节点。链接因此只留字面文字、不带 URL。
*/
function previewText(node: unknown): string {
if (!node || typeof node !== 'object') return '';
const typed = node as PreviewNode;
if (typed.type === 'html') return '';
if (
typed.type === 'text' ||
typed.type === 'inlineCode' ||
typed.type === 'code'
) {
return typeof typed.value === 'string' ? typed.value : '';
}
if (typed.type === 'image' || typed.type === 'imageReference') {
return typeof typed.alt === 'string' ? typed.alt : '';
}
if (!Array.isArray(typed.children)) return '';
const separator = [
'root',
'list',
'listItem',
'blockquote',
'table',
'tableRow',
].includes(typed.type ?? '')
? ' '
: '';
return typed.children.map((child) => previewText(child)).join(separator);
}
/**
* Markdown → 折叠态单行预览(纯函数、纯文本)。
*
* 解析链路与正文同源(`remark-parse` + `remark-gfm`,与 `ChatMarkdownMessage` 的插件一致),
* 但预览只取文字节点:链接只留字面文字(不含 URL / 标题)、强调与 GFM 删除线符号不进预览、
* HTML 原文(`<script>` / `<img>` 等)整棵跳过,因此不需要任何 `dangerouslySetInnerHTML`。
* 空白压成单空格并按上限省略;解析失败退回空预览(展开态仍有完整 Markdown)。
*/
export function agentProcessPreview(
text: string,
maxChars = AGENT_PROCESS_PREVIEW_MAX_CHARS,
): string {
if (!text.trim()) return '';
let preview = '';
try {
const root = unified().use(remarkParse).use(remarkGfm).parse(text);
preview = (root.children ?? [])
.map((node) => previewText(node))
.filter((part) => part.trim().length > 0)
.join(' ');
} catch {
return '';
}
const single = preview.replace(/\s+/g, ' ').trim();
if (single.length <= maxChars) return single;
return `${single.slice(0, maxChars).trimEnd()}`;
}
@@ -1,3 +1,4 @@
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
import type { GameCreatorDirectToolCallKind } from '../../app/types';
import type { DirectChatToolCard } from './directThreadChat';
@@ -9,25 +10,6 @@ import type { DirectChatToolCard } from './directThreadChat';
* 组件文件不导出非组件值,也便于单测直接断言文案规则)。
*/
/** 汇总文案里 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。 */
export const TOOL_CALL_ROW_VERBS: Partial<
Record<GameCreatorDirectToolCallKind, string>
@@ -39,39 +21,17 @@ export const TOOL_CALL_ROW_VERBS: Partial<
other: '已执行',
};
/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */
/**
* 汇总文案:`执行了 N 个操作`(N = 这一组自己的调用数,不按段落数、不拆 kind)。
*
* 与耗时一起构成用户口径的 `执行了 X 个操作,耗时 XXX`:耗时由调用方作为 meta 追加,
* 这里只出前半句。
*/
export function toolCallGroupSummary(
calls: DirectChatToolCard[],
running = false,
) {
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
? running
? `已执行 ${parts.join('、')}进行中`
: `已执行 ${parts.join('、')}`
: '';
calls: readonly DirectChatToolCard[],
): string {
if (calls.length === 0) return '';
return `执行了 ${calls.length} 个操作`;
}
/** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */
@@ -201,35 +161,18 @@ export function toolCallDurationMs(
}
/**
* 单条工具的耗时文案:始终一位小数,`0.0s` / `5.0s` / `1m 2.3s`
* 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0s`。
* 单条工具与整轮、生成任务共用中文时分秒格式;未知耗时隐藏,合法 0 显示 0.0秒
*/
export function formatToolCallDuration(ms: number | null | undefined) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) {
return null;
}
const tenths = Math.round(ms / 100);
if (tenths < 600) {
return `${(tenths / 10).toFixed(1)}s`;
}
const minutes = Math.floor(tenths / 600);
return `${minutes}m ${((tenths - minutes * 600) / 10).toFixed(1)}s`;
return formatElapsedDuration(ms);
}
/**
* 整轮总耗时文案:始终一位小数,`0.0秒` / `5.0秒` / `1分钟 2.3秒`
* 整轮总耗时文案:0.0秒 / 1分02.3秒 / 1时02分05.2秒
* 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0秒`。
*/
export function formatTurnDuration(ms: number | null | undefined) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) {
return null;
}
const tenths = Math.round(ms / 100);
if (tenths < 600) {
return `${(tenths / 10).toFixed(1)}`;
}
const minutes = Math.floor(tenths / 600);
return `${minutes}分钟 ${((tenths - minutes * 600) / 10).toFixed(1)}`;
return formatElapsedDuration(ms);
}
/**
@@ -1,4 +1,5 @@
import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
import {
resolveResourceCanvasBottomTools,
type ResourceCanvasAssetToolAction,
@@ -359,10 +360,7 @@ export function sortResourceCanvasAssetGenerationTasks(
export function resourceCanvasAssetGenerationElapsedLabel(
elapsedMillis: number,
): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMillis / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}${seconds}` : `${seconds}`;
return formatElapsedDuration(elapsedMillis) ?? '—';
}
export function resourceCanvasAssetGenerationTaskElapsedMillis(
+121
View File
@@ -12702,3 +12702,124 @@ button.design-workspace-tree__entry:hover,
.message li {
line-height: 1.5 !important;
}
/* 紧凑过程入口2026-09-18思考过程 / 外层执行过程 / 工具组共用一行摘要
放在文件末尾避免后段规则再把它们恢复成两行或加大底色 */
.design-agent-reasoning > summary,
.message-turn-process > summary,
.agent-tool-call-group-head {
display: block;
padding: 2px 0;
background: none;
border: 0;
font-size: 12px;
line-height: 1.4;
color: var(--platform-text-soft, #988476);
list-style: none;
cursor: pointer;
}
.design-agent-reasoning > summary::-webkit-details-marker,
.message-turn-process > summary::-webkit-details-marker {
display: none;
}
.design-agent-reasoning > summary:hover,
.message-turn-process > summary:hover,
.agent-tool-call-group-head:hover,
.agent-tool-call-group-head:focus-visible {
background: none;
color: var(--platform-text-strong, #3d1f10);
}
.design-agent-reasoning > summary {
margin: 0;
}
.message-turn-process > summary {
margin: 0;
}
.agent-tool-call-group-head {
width: 100%;
text-align: left;
}
/* 展开态正文保持过程色调,不再额外加卡底。 */
.design-agent-reasoning .agent-message-content,
.design-agent-reasoning > .agent-message-content {
margin-top: 4px;
}
/* ===== 紧凑过程入口收口(2026-09-18):压过上面 0,3,0 的两行 grid / 大底色规则 ===== */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
> .agent-tool-call-group,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group {
display: block;
padding: 0;
border-radius: 0;
background: transparent;
overflow: visible;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-head {
display: flex;
grid-template-columns: none;
align-items: center;
gap: 6px;
min-height: 20px;
padding: 2px 0;
border-radius: 0;
background: transparent;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-head:hover,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-head:focus-visible {
background: transparent;
}
/* 单个子元素占满整行:预览 flex 增长,chevron 由 margin-left:auto 贴到最右。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-head
> .agent-process-summary {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-head
.agent-process-summary-chevron {
grid-column: auto;
grid-row: auto;
margin-left: auto;
}
/* chevron 旋转只有一处来源:折叠态不旋转,展开态 rotate(180deg)(120ms 过渡内截图会看到中间角度)。 */
.message-turn-process > summary .agent-process-summary .agent-process-summary-chevron,
.design-agent-reasoning > summary .agent-process-summary .agent-process-summary-chevron {
transform: none;
}
details.message-turn-process[open]
> summary
.agent-process-summary
.agent-process-summary-chevron,
details.design-agent-reasoning[open]
> summary
.agent-process-summary
.agent-process-summary-chevron {
transform: rotate(180deg);
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
AGENT_PROCESS_PREVIEW_MAX_CHARS,
agentProcessPreview,
} from '../src/features/project-workspace/agentProcessPreview';
describe('思考过程单行预览模型', () => {
it('去掉 Markdown 符号与链接目标,只留字面文字', () => {
expect(agentProcessPreview('**Planning** 一下')).toBe('Planning 一下');
expect(
agentProcessPreview(
'先看 [设计文档](https://internal.example/secret) 再动手',
),
).toBe('先看 设计文档 再动手');
expect(agentProcessPreview('用 `npm run build` 验证')).toBe(
'用 npm run build 验证',
);
});
it('原始 HTML 不进预览(不渲染、也不当文字)', () => {
expect(agentProcessPreview('<script>alert(1)</script>')).toBe('');
expect(agentProcessPreview('<img src="x.png" alt="图">')).toBe('');
expect(
agentProcessPreview('先检查\n\n<script>alert(1)</script>\n\n再继续'),
).toBe('先检查 再继续');
// 行内 HTML 与其文字内容都不泄漏。
expect(agentProcessPreview('结果 <b>加粗</b> 完成')).toBe('结果 加粗 完成');
});
it('GFM 删除线按同源插件解析,波浪号不进预览', () => {
// 与正文同源:删除线保留其文字(正文里也是这段字),但 `~~` 控制符不进预览。
expect(agentProcessPreview('~~删除这段~~ 保留这段')).toBe(
'删除这段 保留这段',
);
expect(agentProcessPreview('~~删除~~')).toBe('删除');
});
it('压成单行并按上限省略', () => {
expect(agentProcessPreview('第一行\n\n第二行')).toBe('第一行 第二行');
expect(agentProcessPreview('- 第一项\n- 第二项')).toBe('第一项 第二项');
const long = 'x'.repeat(AGENT_PROCESS_PREVIEW_MAX_CHARS + 20);
const preview = agentProcessPreview(long);
expect(preview.endsWith('…')).toBe(true);
expect(preview.length).toBeLessThanOrEqual(
AGENT_PROCESS_PREVIEW_MAX_CHARS + 1,
);
expect(agentProcessPreview('')).toBe('');
expect(agentProcessPreview(' ')).toBe('');
});
});
@@ -222,16 +222,29 @@ export function registerDesignAgentSurfaceTests() {
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
reasoningText: '先分析需求,再组织方案。',
reasoningText:
'## 结论\n\n- 先分析需求\n- 再组织方案\n\n用 `npm run build` 验证',
});
const summary = await screen.findByText('思考过程');
const details = summary.closest('details') as HTMLDetailsElement;
// 折叠入口按 aria-label 定位(标题文案不再写死,折叠态显示的是单行预览)。
const details = (await screen.findByLabelText(
/思考过程/,
)) as HTMLDetailsElement;
const summary = details.querySelector('summary') as HTMLElement;
expect(details.getAttribute('data-agent-content')).toBe('process');
expect(details.open).toBe(false);
// 折叠态是纯文本单行预览:Markdown 符号不出现在入口文字里。
expect(summary.textContent).toContain('结论');
expect(summary.textContent).not.toContain('##');
expect(summary.textContent).not.toContain('`');
fireEvent.click(summary);
expect(details.open).toBe(true);
expect(screen.getByText('先分析需求,再组织方案。')).not.toBeNull();
// 展开态复用助手正文的 Markdown 安全链路:标题 / 列表 / 行内代码都成为真实语义元素。
await waitFor(() => {
expect(details.querySelector('h2')?.textContent).toBe('结论');
});
expect(details.querySelectorAll('li')).toHaveLength(2);
expect(details.querySelector('code')?.textContent).toBe('npm run build');
});
it('renders historical reasoning as independent collapsed sections', async () => {
@@ -252,10 +265,12 @@ export function registerDesignAgentSurfaceTests() {
}),
);
const summaries = await screen.findAllByText('思考过程');
expect(summaries).toHaveLength(2);
const details = summaries.map(
(summary) => summary.closest('details') as HTMLDetailsElement,
const details = (await screen.findAllByLabelText(
/思考过程/,
)) as HTMLDetailsElement[];
expect(details).toHaveLength(2);
const summaries = details.map(
(element) => element.querySelector('summary') as HTMLElement,
);
expect(details.every((element) => !element.open)).toBe(true);
expect(
@@ -8111,7 +8111,7 @@ export function registerProjectSupervisorSurfaceTests() {
);
expect(runningBody).not.toBeNull();
expect(runningBody?.hasAttribute('hidden')).toBe(true);
expect(runningHead.textContent).toContain('1 个命令');
expect(runningHead.textContent).toContain('执行了 1 个操作');
// 命令完成 + 文件变更:同一回合两块合成一个块,顺序按 startedAt。
await act(async () => {
@@ -8166,12 +8166,12 @@ export function registerProjectSupervisorSurfaceTests() {
'agent-tool-call-group',
)[0] as HTMLElement;
const groupHead = within(group).getByTestId('agent-tool-call-group-head');
expect(groupHead.textContent).toContain('执行 1 个命令、1 个文件变更');
expect(groupHead.textContent).toContain('执行了 2 个操作');
// 组头右侧是**本组**用时(本组工具 min(开始) → max(完成) = 100ms),与整轮总耗时无关:
// 组内工具都结束了,即使回合还在跑,也不标"进行中"、也不再滚动。
expect(group.getAttribute('data-status')).toBe('completed');
expect(group.getAttribute('data-duration-ms')).toBe('100');
expect(groupHead.textContent).toContain('本组用时 0.1秒');
expect(groupHead.textContent).toContain('时 0.1秒');
expect(groupHead.textContent).not.toContain('进行中');
expect(groupHead.textContent).not.toContain('总耗时');
// 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。
@@ -8261,6 +8261,13 @@ export function registerProjectSupervisorSurfaceTests() {
const processSection =
within(supervisorSurface).getByTestId('turn-process');
expect(processSection.contains(settledGroups[0] ?? null)).toBe(true);
// 外层"执行过程"汇总这一轮**全部工具调用**(1 组 2 条 = 2 个操作),
// 耗时是首工具 → 末工具的执行跨度(不是整轮总耗时)。
const turnProcessSummary = within(processSection).getByTestId(
'agent-tool-call-group-head',
);
expect(processSection.textContent).toContain('执行了 2 个操作');
expect(turnProcessSummary.textContent).toContain('耗时 0.1秒');
expect(processSection.textContent).not.toContain(
'DIRECT_REPLY:做一个跑酷游戏',
);
@@ -8275,7 +8282,7 @@ export function registerProjectSupervisorSurfaceTests() {
// 回合收口不改变组头:组头始终是**本组**用时,不随回合变长。
expect(settledGroup.getAttribute('data-status')).toBe('completed');
expect(settledGroup.getAttribute('data-duration-ms')).toBe('100');
expect(settledHead.textContent).toContain('本组用时 0.1秒');
expect(settledHead.textContent).toContain('时 0.1秒');
expect(settledHead.textContent).not.toContain('进行中');
expect(settledHead.textContent).not.toContain('总耗时');
// 整轮总耗时在界面上只有一处:回合小结;任何工具组都不再显示它。
@@ -8406,9 +8413,7 @@ export function registerProjectSupervisorSurfaceTests() {
'agent-tool-call-group-head',
);
expect(persistedHead.getAttribute('aria-expanded')).toBe('false');
expect(persistedHead.textContent).toContain(
'已执行 1 个命令、1 个文件变更',
);
expect(persistedHead.textContent).toContain('执行了 2 个操作');
const processSection =
within(supervisorSurface).getByTestId('turn-process');
expect(processSection.contains(persistedGroup)).toBe(true);
@@ -8932,11 +8937,11 @@ export function registerProjectSupervisorSurfaceTests() {
'agent-tool-call-group-head',
);
expect(runningHead.getAttribute('aria-expanded')).toBe('false');
expect(runningHead.textContent).toContain('1 个命令');
expect(runningHead.textContent).toContain('执行了 1 个操作');
// 组内还有工具在跑:组头按"本组起点 → 现在"增长,一位小数、显示进行中。
// 整轮总耗时不在组头(它只在回合小结 / 底部耗时行出现)。
expect(runningHead.textContent).toContain('进行中');
expect(runningHead.textContent).toMatch(/本组用时 \d+\.\d+秒/);
expect(runningHead.textContent).toMatch(/时 \d+\.\d+秒/);
expect(runningHead.textContent).not.toContain('总耗时');
const runningChildren = Array.from((messageList as HTMLElement).children);
expect(
@@ -8980,7 +8985,7 @@ export function registerProjectSupervisorSurfaceTests() {
const settledRunningHead = within(settledRunningGroup).getByTestId(
'agent-tool-call-group-head',
);
expect(settledRunningHead.textContent).toContain('本组用时 0.4秒');
expect(settledRunningHead.textContent).toContain('时 0.4秒');
expect(settledRunningHead.textContent).not.toContain('进行中');
expect(settledRunningHead.textContent).not.toContain('总耗时');
fireEvent.click(settledRunningHead);
@@ -8990,7 +8995,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(settledRunningRows).toHaveLength(1);
expect(settledRunningRows[0]?.getAttribute('data-duration-ms')).toBe('400');
expect(
within(settledRunningRows[0] as HTMLElement).getByText('0.4s'),
within(settledRunningRows[0] as HTMLElement).getByText('0.4'),
).not.toBeNull();
// 回合结束:assistant 正文进历史,工具块收进「执行过程」折叠区,且**只有一份**。
@@ -9022,7 +9027,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(
within(settledGroup).getByTestId('agent-tool-call-group-head')
.textContent,
).toContain('本组用时 0.4秒');
).toContain('时 0.4秒');
// 整轮总耗时冻结在用户发送 → turn.completed.at,且只在回合小结里出现一处。
expect(
within(supervisorSurface).getByTestId('turn-usage').textContent,
@@ -34,13 +34,11 @@ function toolCall(
}
export function registerToolCallGroupTests() {
it('summarizes tool calls by kind in a fixed order', () => {
// 单 kind
it('summarizes the group as its own operation count', () => {
// 用户口径:`执行了 X 个操作`,X = 这一组自己的调用数(不拆 kind、不看段落数)
expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe(
'执行 1 个命令',
'执行 1 个操作',
);
// 混合:顺序固定 command → file_change → mcp_tool → web_search →
// context_compaction → other,与传入顺序无关。
expect(
toolCallGroupSummary([
toolCall({ id: 'a', kind: 'other' }),
@@ -51,10 +49,8 @@ export function registerToolCallGroupTests() {
toolCall({ id: 'f', kind: 'context_compaction' }),
toolCall({ id: 'g', kind: 'mcp_tool' }),
]),
).toBe(
'已执行 2 个命令、1 个文件变更、1 个工具调用、1 个联网搜索、1 个上下文整理、1 个其他操作',
);
// 空集合。
).toBe('执行了 7 个操作');
// 空集合不渲染汇总。
expect(toolCallGroupSummary([])).toBe('');
});
@@ -122,12 +118,10 @@ export function registerToolCallGroupTests() {
// 块头是一行按钮:图标 + 汇总 + 展开箭头,默认折叠,正文由 `hidden` 收起。
expect(head.tagName).toBe('BUTTON');
expect(head.getAttribute('aria-expanded')).toBe('false');
expect(head.getAttribute('aria-label')).toBe(
'已执行 1 个命令、1 个文件变更',
);
expect(head.getAttribute('aria-label')).toBe('执行了 2 个操作');
expect(
head.querySelector('.agent-tool-call-group-summary')?.textContent,
).toBe('执行 1 个命令、1 个文件变更');
head.querySelector('.agent-process-summary-preview')?.textContent,
).toBe('执行了 2 个操作');
const body = container.querySelector(
`#${head.getAttribute('aria-controls')}`,
);
@@ -240,14 +234,14 @@ export function registerToolCallGroupTests() {
),
).toBeNull();
// 合法 0 显示 `0.0s`;两种耗时都始终一位小数。
expect(formatToolCallDuration(0)).toBe('0.0s');
expect(formatToolCallDuration(400)).toBe('0.4s');
expect(formatToolCallDuration(950)).toBe('1.0s');
expect(formatToolCallDuration(12300)).toBe('12.3s');
expect(formatToolCallDuration(12000)).toBe('12.0s');
expect(formatToolCallDuration(59900)).toBe('59.9s');
expect(formatToolCallDuration(60000)).toBe('1m 0.0s');
expect(formatToolCallDuration(125000)).toBe('2m 5.0s');
expect(formatToolCallDuration(0)).toBe('0.0');
expect(formatToolCallDuration(400)).toBe('0.4');
expect(formatToolCallDuration(950)).toBe('1.0');
expect(formatToolCallDuration(12300)).toBe('12.3');
expect(formatToolCallDuration(12000)).toBe('12.0');
expect(formatToolCallDuration(59900)).toBe('59.9');
expect(formatToolCallDuration(60000)).toBe('1分00.0');
expect(formatToolCallDuration(125000)).toBe('2分05.0');
// 整轮总耗时 = 本轮起点 → 本轮终态(不是块内工具的时间跨度)。
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 9000 })).toBe(8000);
@@ -265,9 +259,9 @@ export function registerToolCallGroupTests() {
expect(formatTurnDuration(8000)).toBe('8.0秒');
expect(formatTurnDuration(42000)).toBe('42.0秒');
expect(formatTurnDuration(59900)).toBe('59.9秒');
expect(formatTurnDuration(60000)).toBe('1分0.0秒');
expect(formatTurnDuration(240000)).toBe('4分0.0秒');
expect(formatTurnDuration(345000)).toBe('5分45.0秒');
expect(formatTurnDuration(60000)).toBe('1分00.0秒');
expect(formatTurnDuration(240000)).toBe('4分00.0秒');
expect(formatTurnDuration(345000)).toBe('5分45.0秒');
expect(formatTurnDuration(null)).toBeNull();
expect(formatTurnDuration(undefined)).toBeNull();
@@ -324,27 +318,27 @@ export function registerToolCallGroupTests() {
const group = container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
// 本组时读**这一组**的边界:1000 → 17900,块头显示「本组用时 16.9秒」,
// 本组时读**这一组**的边界:1000 → 17900,块头显示「时 16.9秒」,
// `data-duration-ms` 暴露取整到 100ms 网格后的展示耗时。
expect(group.getAttribute('data-duration-ms')).toBe('16900');
const head = within(group).getByTestId('agent-tool-call-group-head');
expect(head.textContent).toContain('本组用时 16.9秒');
expect(head.textContent).toContain('时 16.9秒');
// 整轮总耗时只在对话底部渲染一处,组头不再出现"总耗时",也不再显示时间范围。
expect(head.textContent).not.toContain('总耗时');
expect(head.querySelector('.agent-tool-call-group-time')).toBeNull();
expect(head.getAttribute('aria-label')).toMatch(
/^执行 1 个命令、1 个文件变更、1 个联网搜索,本组用时 16\.9秒$/,
/^执行了 3 个操作,耗时 16\.9秒$/,
);
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(within(rows[0] as HTMLElement).getByText('0.4')).not.toBeNull();
expect(rows[1]?.getAttribute('data-duration-ms')).toBe('16500');
expect(within(rows[1] as HTMLElement).getByText('16.5s')).not.toBeNull();
expect(within(rows[1] as HTMLElement).getByText('16.5')).not.toBeNull();
// startedAt === updatedAt:合法 0,按契约显示 `0.0s`。
expect(rows[2]?.getAttribute('data-duration-ms')).toBe('0');
expect(within(rows[2] as HTMLElement).getByText('0.0s')).not.toBeNull();
expect(within(rows[2] as HTMLElement).getByText('0.0')).not.toBeNull();
// 时间只显示在块头,展开后不重复追加块尾时间。
expect(
within(group).queryByTestId('agent-tool-call-group-end-time'),
@@ -365,13 +359,13 @@ export function registerToolCallGroupTests() {
expect(
within(missingGroup).getByTestId('agent-tool-call-group-head')
.textContent,
).toBe('执行 1 个命令');
).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(within(missingRow).queryByText('0')).toBeNull();
expect(
missingGroup.querySelector('.agent-tool-call-group-end-time'),
).toBeNull();
@@ -412,9 +406,9 @@ export function registerToolCallGroupTests() {
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
const head = within(group).getByTestId('agent-tool-call-group-head');
// 只有本组还有工具在跑时才标"进行中",并且组头显示的是本组时。
// 只有本组还有工具在跑时才标"进行中",并且组头显示的是本组时。
expect(head.textContent).toContain('进行中');
expect(head.textContent).toContain('本组用时 0.4秒');
expect(head.textContent).toContain('时 0.4秒');
expect(group.getAttribute('data-duration-ms')).toBe('400');
// 没有新事件,时间推进组用时也增长;5000ms 后是 `5.4秒`。
@@ -422,12 +416,12 @@ export function registerToolCallGroupTests() {
vi.advanceTimersByTime(5000);
});
expect(group.getAttribute('data-duration-ms')).toBe('5400');
expect(head.textContent).toContain('本组用时 5.4秒');
// 分钟进位:65400ms → `1分5.4秒`。
expect(head.textContent).toContain('时 5.4秒');
// 分钟进位:65400ms → `1分05.4秒`。
act(() => {
vi.advanceTimersByTime(60000);
});
expect(head.textContent).toContain('本组用时 1分5.4秒');
expect(head.textContent).toContain('时 1分05.4秒');
// 组内工具全部拿到终态:组用时冻结在**工具终点**上(不是当前时钟),
// 继续推进时钟不再变化,也不等回合收口。
@@ -450,7 +444,7 @@ export function registerToolCallGroupTests() {
vi.advanceTimersByTime(30000);
});
expect(group.getAttribute('data-duration-ms')).toBe('65000');
expect(head.textContent).toContain('本组用时 1分5.0秒');
expect(head.textContent).toContain('时 1分05.0秒');
expect(head.textContent).not.toContain('进行中');
view.unmount();
vi.useRealTimers();
@@ -502,14 +496,14 @@ export function registerToolCallGroupTests() {
'agent-tool-call-group-head',
);
expect(first.getAttribute('data-duration-ms')).toBe('2000');
expect(finishedHead.textContent).toContain('本组用时 2.0秒');
expect(finishedHead.textContent).toContain('时 2.0秒');
expect(finishedHead.textContent).not.toContain('进行中');
// 后开始的那一组:只算自己的 5 秒起点 → 当前时钟,仍标"进行中"。
const runningHead = within(second).getByTestId(
'agent-tool-call-group-head',
);
expect(second.getAttribute('data-duration-ms')).toBe('5000');
expect(runningHead.textContent).toContain('本组用时 5.0秒');
expect(runningHead.textContent).toContain('时 5.0秒');
expect(runningHead.textContent).toContain('进行中');
// 行级:已完成的工具冻结在 2.0 秒,运行中的工具按当前时钟增长。
fireEvent.click(finishedHead);
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { formatElapsedDuration } from '../../../packages/shared/src/lib/formatElapsedDuration';
import {
formatToolCallDuration,
formatTurnDuration,
} from '../src/features/project-workspace/toolCallGroupPresentation';
import { resourceCanvasAssetGenerationElapsedLabel } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
describe('统一中文耗时', () => {
it.each([
[0, '0.0秒'],
[5200, '5.2秒'],
[59_949, '59.9秒'],
[59_950, '1分00.0秒'],
[125_200, '2分05.2秒'],
[3_599_950, '1时00分00.0秒'],
[3_725_200, '1时02分05.2秒'],
[90_061_200, '25时01分01.2秒'],
])('%s ms → %s,所有入口一致', (ms, expected) => {
expect(formatElapsedDuration(ms)).toBe(expected);
expect(formatToolCallDuration(ms)).toBe(expected);
expect(formatTurnDuration(ms)).toBe(expected);
expect(resourceCanvasAssetGenerationElapsedLabel(ms)).toBe(expected);
});
it.each([null, undefined, NaN, Infinity, -1])(
'未知时间不伪造零:%s',
(ms) => {
expect(formatElapsedDuration(ms)).toBeNull();
},
);
});
@@ -208,11 +208,9 @@ describe('生成任务模型', () => {
});
test('已耗时文案按分秒呈现', () => {
expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒');
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe(
'1 分 12 秒',
);
expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒');
expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12.0秒');
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1分12.0秒');
expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('—');
});
});
@@ -124,7 +124,7 @@ describe('「生成任务」侧栏', () => {
expect(within(doneSection).getByText('生成已完成。')).not.toBeNull();
expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝');
expect(
screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length,
screen.getAllByText(/^已耗时 (?:\d+时)?(?:\d+分)?\d+\.\d秒$/).length,
).toBeGreaterThan(0);
});
@@ -16,6 +16,8 @@
## 开发中
- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 只在展开正文走既有安全渲染,折叠预览只取纯文本,不在 summary 嵌套链接或按钮。耗时统一复用中文时分秒格式(秒一位小数),格式化与各层计时边界分离,不因统一文案改变状态来源。
- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
@@ -15,11 +15,20 @@
## 总耗时与动态工具计时
### 紧凑过程入口与 Markdown
- 思考过程、执行过程和工具组统一为无大块底色的单行折叠入口:左侧小图标/简短预览,最右侧展开箭头;长文本省略,不把箭头挤出窄聊天列。沿用共享过程色和 12px 层级,键盘可展开、有可见焦点。
- 思考折叠态直接显示浅色的内容预览,而不是只有“思考过程”标题;预览不显示 Markdown 控制符、原始 HTML 或链接地址。展开后复用现有安全 Markdown 渲染链路,支持段落、强调、列表、链接、代码块等,不开启原始 HTML 执行。实时与历史、当前 Agent 与策划 Agent 使用同一呈现。
- 工具组摘要只统计本组工具条目总数,显示“执行了 N 个操作,耗时 XXX”;运行中另有状态提示,不把操作数称作成功数,失败仍保留明确状态。回合外层执行过程统计所有工具块的操作数,不把思考段落或文本消息当工具操作;缺失耗时不伪造。
- 所有 AGC 耗时统一用中文时分秒:`5.2秒``2分05.2秒``1时02分05.2秒`。省略前导零单位,带小时则保留两位分钟,带分钟则秒补齐两位整数,秒始终一位小数;先整体舍入再拆分单位,避免出现 60 秒/60 分。工具行、组、整轮、生成任务及策划耗时共用一个纯格式化函数,不改变各自计时来源。
- 独立计时保持不变,组状态/用时在单行中作为次要信息,空间不足可移至展开内容,不能占第二行破坏紧凑入口;整轮总耗时仍只显示一次。工具输入/输出、失败信息与操作明细不因样式变更丢失。
- 只改展示与对应测试,不改变消息身份、分组顺序、生成或原生执行语义。验证覆盖 Markdown 安全与语义、预览省略、准确计数、展开/键盘操作、窄屏布局及原有计时冻结。
- “总耗时”表示同一轮用户请求从发送到回合终态的墙钟跨度,包含 LLM 推理、工具调用和等待;只在本轮运行状态或完成小结显示,不重复放进各工具组。
- 工具组顶部显示“本组用时”,范围是本组首个工具开始到最后一个工具完成,包含组内等待但不是工具耗时之和。不同工具组独立计时;本组全部终态后立即固定,即使本轮或后面的工具组仍运行,也不能显示“进行中”或继续增加。组头不展示容易与整轮混淆的发送/结束时刻,仅展示本组用时;不能沿用用户发送时间计算本组。
- 工具组顶部“执行了 N 个操作,耗时 XXX”中的耗时范围是本组首个工具开始到最后一个工具完成,包含组内等待但不是工具耗时之和。不同工具组独立计时;本组全部终态后立即固定,即使本轮或后面的工具组仍运行,也不能显示“进行中”或继续增加。组头不展示发送/结束时刻,不沿用用户发送时间计算本组。
- 回合仍运行但全部工具暂时结束时,只增长本轮总耗时。成功、失败或终止到达后整轮按实际终态时间固定;随后打开折叠块、翻页或其它回合的新事件不得改变已完成的组或回合用时。
- 单条工具从其实际开始到完成计时。运行中的工具按当前时间持续增长,不能把最近一次快照更新时间当作当前时间;已经结束的工具必须立即固定,即使同组其它工具或 LLM 仍在运行。
- 两种耗时均每 100 毫秒刷新,始终保留一位小数(例如 `0.0秒``5.1秒``1分钟 2.3秒`,单条可沿用 `5.1s` / `1m 2.3s` 的紧凑格式。以时间戳计算而不是按 tick 累加,避免后台节流后的累计漂移;缺失或倒序边界不伪造 `0.0`
- 对话的组、工具及整轮耗时运行中均每 100 毫秒刷新,统一使用上述中文时分秒格式。以时间戳计算而不是按 tick 累加,避免后台节流后的累计漂移;缺失或倒序边界不伪造 `0.0`非对话场景只统一文本格式,不改变原有刷新或后端累计时间语义。
- 各层时间范围与对应耗时使用相同的起止边界。运行标记也按本层状态判定;完成后如果展示起止时间,其精度不得造成范围差与用时矛盾。组内存在缺失或倒序的工具边界时,不能用其他组或整轮的时间补造本组耗时。
- 原生事件保留各阶段的时间语义:开始与完成不能都优先折叠成开始时间。优先采用上游明确提供的阶段时间或实际调用时长,缺失时使用宿主观察该阶段的时间;重放沿用原事件时间,不能在前端收到或重放时重新取当前时间。
- 工具开始/完成的权威边界是事件级 `at`,不是条目展示字段 `item.at`。整轮起点优先采用该轮实际用户消息的发送时间(与气泡一致,不取所有条目的最小时间),缺失时采用原生 `turn.started.at`;终点只采用 `turn.completed.at` 或明确的终止/失败收口事件。首次补到更早的真实发送时间可以校正起点,但旧历史或重复事件不能覆盖已经固定的终点。
@@ -92,20 +101,20 @@ DirectRuntime 写 `<projectRoot>/.agent/conversations/tool-calls.jsonl`;回读
```html
<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.0秒">
aria-label="执行了 3 个操作,耗时 42.0秒">
<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-duration">本组用时 42.0秒</span>
<span class="agent-process-summary-preview">执行了 3 个操作</span>
<span class="agent-process-summary-meta">,耗时 42.0秒</span>
<svg class="agent-tool-call-group-chevron" aria-hidden="true"></svg>
</button>
<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">
aria-label="已运行 npm run build,耗时 12.3">
<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>
<span class="agent-tool-call-row-duration">12.3</span>
<svg class="agent-tool-call-row-chevron" aria-hidden="true"></svg>
</button>
<div class="agent-tool-call-row-detail" hidden>
@@ -120,7 +129,7 @@ DirectRuntime 写 `<projectRoot>/.agent/conversations/tool-calls.jsonl`;回读
```
- 文案规则(按 kind,不允许自由发挥):
- 块头汇总按 kind 计数、顺序固定 `command → file_change → mcp_tool → web_search → context_compaction → other`,标签 `命令`/`文件变更`/`工具调用`/`联网搜索`/`上下文整理`/`其他操作`,形如 `执行 5 个命令、2 个文件变更`;空集合不渲染块
- 块头按实际工具条目计总数,形如 `执行了 7 个操作,耗时 1分05.2秒`;空集合不渲染块。操作数不是成功数,失败与运行状态仍单独可见
- 行文案:展示工具摘要,不重复添加动词前缀;`context_compaction` 固定为“整理上下文”。状态单独放在行尾(执行中 / 已执行 / 失败),`failed` 使用现有 `--platform-*` 错误色;已结束回合不因残留 `running` 快照显示“执行中”。
- 耗时:执行“总耗时与动态工具计时”合同。块头是本组用时,单条是该工具独立耗时,整轮总耗时只在本轮状态/小结显示;均保留一位小数,运行中每 100 毫秒刷新,各自终态固定。
- 时间:范围与对应层级用时采用同一边界,不把用户发送起点与局部工具组终点混搭。缺失的历史时间不编造。
@@ -0,0 +1,65 @@
/**
* 过程区单行摘要(思考过程 / 外层执行过程 / 工具组共用)。
*
* 只有一行、没有大底色:预览过长省略,chevron 永远贴右。展开态由宿主的
* `[aria-expanded='true']` 或 `details[open]` 决定,这里只负责视觉。
*/
.agent-process-summary {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
min-height: 20px;
line-height: 1.4;
background: none;
}
.agent-process-summary-icon {
display: inline-flex;
flex: none;
align-items: center;
color: var(--platform-text-soft, #988476);
}
.agent-process-summary-preview {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.agent-process-summary-meta {
flex: none;
color: var(--platform-text-soft, #988476);
white-space: nowrap;
}
.agent-process-summary-chevron {
display: inline-flex;
flex: none;
align-items: center;
margin-left: auto;
color: var(--platform-text-soft, #988476);
transition: transform 120ms ease;
}
[aria-expanded='true'] > .agent-process-summary .agent-process-summary-chevron,
details[open] > summary .agent-process-summary .agent-process-summary-chevron {
transform: rotate(180deg);
}
/* 焦点画在共享摘要内,避免宿主按钮的 outline:none 抹掉键盘定位。 */
button:focus-visible > .agent-process-summary,
summary:focus-visible > .agent-process-summary {
outline: 2px solid var(--platform-accent, #c26a3e);
outline-offset: 2px;
border-radius: 3px;
}
@media (prefers-reduced-motion: reduce) {
.agent-process-summary-chevron {
transition: none;
}
}
@@ -0,0 +1,48 @@
import './AgentProcessSummary.css';
import type { ReactNode } from 'react';
/**
* 过程区单行摘要的**纯展示**骨架:`icon + 预览文字 + 右对齐 meta + chevron`。
*
* 它只负责视觉,不含状态、不含业务规则,也不渲染任何交互元素:宿主决定外层是可点的
* `button`(工具组)还是 `<details><summary>`(思考过程 / 外层执行过程),并把展开态交给宿主。
* 视觉上固定一行:预览过长省略,chevron 永远贴右,展开时由 `.agent-process-summary` 的
* `[aria-expanded='true']` / `details[open]` 规则旋转。
*/
export function AgentProcessSummary({
icon,
preview,
meta,
chevron,
}: {
/** 行首图标(通常由宿主传入 lucide 图标)。 */
icon?: ReactNode;
/** 单行预览文字;调用方负责先把它压成一行(Markdown 预览模型)。 */
preview: string;
/** 右侧补充信息(耗时 / 状态),可为空。 */
meta?: ReactNode;
/** 右端 chevron。 */
chevron?: ReactNode;
}) {
return (
<span className="agent-process-summary" data-agent-process-summary="">
{icon ? (
<span className="agent-process-summary-icon" aria-hidden="true">
{icon}
</span>
) : null}
<span className="agent-process-summary-preview">
{preview}
{meta ? (
<span className="agent-process-summary-meta">{meta}</span>
) : null}
</span>
{chevron ? (
<span className="agent-process-summary-chevron" aria-hidden="true">
{chevron}
</span>
) : null}
</span>
);
}
+1
View File
@@ -220,6 +220,7 @@ export { Textarea } from './ui/textarea';
// existing application adapters while keeping the public API product-neutral.
export type { AgentMessageTone } from './AgentMessageContent';
export { AgentMessageContent } from './AgentMessageContent';
export { AgentProcessSummary } from './AgentProcessSummary';
export { CanvasCardCornerActions } from './CanvasCardCornerActions';
export { OverflowActions } from './OverflowActions';
export type {
@@ -0,0 +1,26 @@
/**
* 毫秒耗时的统一显示:5.2秒 / 2分05.2秒 / 1时02分05.2秒。
* 保留十分之一秒,先整体舍入再拆单位,避免 60.0 秒或 60 分溢出。
* 非法或未知耗时返回 null,不能以 0 冒充已测得结果。
*/
export function formatElapsedDuration(
milliseconds: number | null | undefined,
): string | null {
if (
milliseconds == null ||
!Number.isFinite(milliseconds) ||
milliseconds < 0
) {
return null;
}
const tenths = Math.round(milliseconds / 100);
const hours = Math.floor(tenths / 36_000);
const minutes = Math.floor((tenths % 36_000) / 600);
const seconds = ((tenths % 600) / 10).toFixed(1);
if (hours > 0) {
return `${hours}${String(minutes).padStart(2, '0')}${seconds.padStart(4, '0')}`;
}
return minutes > 0
? `${minutes}${seconds.padStart(4, '0')}`
: `${seconds}`;
}
+1
View File
@@ -1 +1,2 @@
export { formatElapsedDuration } from './formatElapsedDuration';
export { cn } from './utils';