统一思考与执行过程折叠样式及中文耗时
思考展开复用安全Markdown渲染,折叠显示浅色单行纯文本预览与右侧箭头。 共享过程摘要组件,执行过程按真实操作数显示统计并保留各组独立计时。 工具、整轮、生成任务和策划耗时统一为中文时分秒格式。 补齐格式与Markdown回归、更新已有界面断言并验证窄屏键盘操作。
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
+50
-4
@@ -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()}…`;
|
||||
}
|
||||
+15
-72
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-4
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 +1,2 @@
|
||||
export { formatElapsedDuration } from './formatElapsedDuration';
|
||||
export { cn } from './utils';
|
||||
|
||||
Reference in New Issue
Block a user