收口思考等待反馈与紧凑过程样式及失败状态

思考展开时隐藏重复预览并使用灯泡图标,首个响应前显示临时思考提示。
统一分钟内小数和分钟以上整数秒,保持内外过程的紧凑间距与正文层级。
失败工具与分组显示错误色,非零退出码不再误判为成功。
补齐状态、格式、样式和交互回归,本批其余需求留待后续PR。
This commit is contained in:
2026-09-18 15:46:30 +08:00
parent a0f6b7c67d
commit 2257a78d6b
15 changed files with 499 additions and 48 deletions
@@ -1,8 +1,8 @@
import {
ArrowUp,
AtSign,
Brain,
ChevronDown,
Lightbulb,
Loader2,
Settings,
Wrench,
@@ -125,6 +125,7 @@ function AgentReasoning({
label?: string;
testId?: string;
}) {
const [expanded, setExpanded] = useState(false);
// 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。
const preview = agentProcessPreview(text);
return (
@@ -134,11 +135,14 @@ function AgentReasoning({
className="design-agent-reasoning"
aria-label={label}
data-testid={testId}
onToggle={(event) =>
setExpanded((event.currentTarget as HTMLDetailsElement).open)
}
>
<summary>
<AgentProcessSummary
icon={<Brain size={12} aria-hidden="true" />}
preview={preview || '思考过程'}
icon={<Lightbulb size={12} aria-hidden="true" />}
preview={expanded ? '思考过程' : preview || '思考过程'}
chevron={<ChevronDown size={12} aria-hidden="true" />}
/>
</summary>
@@ -319,6 +323,16 @@ export function ProjectSupervisorView({
: [];
const activeTurnStartedAt =
directTurns.find((turn) => turn.active)?.startedAt ?? 0;
const latestDirectTurn = directTurns.at(-1);
// 仅是首个响应到达前的临时提示,不创建聊天条目或第二套回合状态。
const awaitingFirstResponse =
directCodex &&
(runBusy || directTurnRunning) &&
!turnCancelling &&
!runtimePanelProps.error &&
Boolean(latestDirectTurn?.users.length) &&
latestDirectTurn?.process.length === 0 &&
latestDirectTurn?.finals.length === 0;
// 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染:
// - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位;
// - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。
@@ -648,6 +662,18 @@ export function ProjectSupervisorView({
</AgentMessageContent>
</div>
) : null}
{awaitingFirstResponse ? (
<AgentMessageContent
tone="process"
className="agent-awaiting-response"
data-testid="agent-awaiting-response"
role="status"
aria-live="polite"
>
<Lightbulb size={12} aria-hidden="true" />
<span></span>
</AgentMessageContent>
) : null}
</div>
{directCodex ? null : planningSurfaceActive ? (
<PlanningLaneRuntimeStrip
@@ -74,16 +74,14 @@ export function ToolCallGroup({
// 本组用时:只看这一组自己的工具边界;本组全完成即冻结。
const timing = resolveToolGroupTiming(orderedCalls, { running: active, now });
const summary = toolCallGroupSummary(orderedCalls);
const status = toolRunning
? 'running'
: orderedCalls.some((call) => call.status === 'failed')
? 'failed'
: 'completed';
const hasFailure = orderedCalls.some((call) => call.status === 'failed');
const status = toolRunning ? 'running' : hasFailure ? 'failed' : 'completed';
// 用户口径:`执行了 X 个操作,耗时 XXX`。耗时属于**这一组**(不是整轮),可见文本里用
// 中文逗号连起来,所以分隔符跟着 meta 一起进 DOM。
// 顺序按用户口径:`执行了 X 个操作,耗时 XXX`,运行状态挂到最后,不插在中间。
const headMeta = [
timing.durationText ? `耗时 ${timing.durationText}` : '',
hasFailure ? '有操作失败' : '',
toolRunning ? '进行中' : '',
]
.filter(Boolean)
@@ -101,6 +99,7 @@ export function ToolCallGroup({
data-testid="agent-tool-call-group"
aria-label="陶泥儿执行过程"
data-status={status}
data-has-failure={hasFailure ? 'true' : undefined}
data-duration-ms={timing.durationMs ?? ''}
>
<button
@@ -57,12 +57,15 @@ function toolStatus(
status: string | null,
exitCode: number | null,
): GameCreatorDirectToolCallStatus {
if (status === 'completed') return 'completed';
// 显式失败 / 取消终态最优先:上游已经说这次调用失败,就不能再被别的字段洗成成功。
if (status && FAILED_ITEM_STATUS.has(status)) return 'failed';
// Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。
if (typeof exitCode === 'number') {
return exitCode === 0 ? 'completed' : 'failed';
}
// 非零退出码优先于 `completed`:命令跑完了不等于成功(退出码 1 是失败,只认退出码,
// 不从 output 的自然语言里推断)。
if (typeof exitCode === 'number' && exitCode !== 0) return 'failed';
if (status === 'completed') return 'completed';
// 退出码 0:正常完成。
if (typeof exitCode === 'number') return 'completed';
// 还有状态但没有终态信息:仍在跑;完全没有状态字段的调用按「已完成」处理。
return status ? 'running' : 'completed';
}
+71 -3
View File
@@ -12605,10 +12605,9 @@ button.design-workspace-tree__entry:hover,
}
.message-turn-process-body {
display: grid;
gap: 12px;
display: block;
min-width: 0;
padding-top: 8px;
padding-top: 4px;
}
/* 整轮会话的结束时间与耗时:比消息本身更轻,属于轮次级信息。 */
@@ -12705,6 +12704,13 @@ button.design-workspace-tree__entry:hover,
/* 紧凑过程入口2026-09-18思考过程 / 外层执行过程 / 工具组共用一行摘要
放在文件末尾避免后段规则再把它们恢复成两行或加大底色 */
.agent-awaiting-response {
display: flex;
align-items: center;
gap: 6px;
min-height: 20px;
}
.design-agent-reasoning > summary,
.message-turn-process > summary,
.agent-tool-call-group-head {
@@ -12740,6 +12746,18 @@ button.design-workspace-tree__entry:hover,
margin: 0;
}
/* 仅最外层摘要使用正文色,内部思考/工具的过程色与行高不变。 */
.message-turn-process > summary > .agent-process-summary {
color: var(--platform-text-strong, #3d1f10);
}
.message-turn-process
> summary
> .agent-process-summary
:is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron) {
color: inherit;
}
.agent-tool-call-group-head {
width: 100%;
text-align: left;
@@ -12823,3 +12841,53 @@ details.design-agent-reasoning[open]
.agent-process-summary-chevron {
transform: rotate(180deg);
}
/* 思考与工具使用紧凑连续列表;收口后的外层只负责折叠,不额外改变行距。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
> :is(.design-agent-reasoning, .agent-tool-call-group),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.message-turn-process-body
> :is(.design-agent-reasoning, .agent-tool-call-group) {
margin: 4px 0 0;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.message-turn-process-body
> :first-child {
margin-top: 0;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-row-head {
padding: 2px 0;
line-height: 1.5;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-body {
padding: 2px 0 0 18px;
}
/* 失败状态独立于普通过程色;并行组里其它工具仍运行时也不能隐藏失败。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group[data-has-failure='true']
> .agent-tool-call-group-head
.agent-process-summary,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group[data-has-failure='true']
> .agent-tool-call-group-head
:is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-row[data-status='failed']
:is(.agent-tool-call-row-head, .agent-tool-call-row-text, .agent-tool-call-row-status, .agent-tool-call-row-icon, .agent-tool-call-row-duration, .agent-tool-call-row-chevron) {
color: var(--platform-button-danger-text, #a6402f);
}
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const styles = readFileSync(
new URL('../src/styles.css', import.meta.url),
'utf8',
);
describe('回合结束后的过程样式', () => {
it('折叠层不再用 grid gap 叠加内部条目的原有 margin', () => {
const body = styles.match(/\.message-turn-process-body\s*\{([^}]+)\}/)?.[1];
expect(body).toBeDefined();
expect(body).toMatch(/display:\s*block/);
expect(body).not.toMatch(/(?:^|[;\s])gap\s*:/);
expect(styles).toMatch(
/\.message-turn-process-body\s*>\s*:is\(\.design-agent-reasoning,\s*\.agent-tool-call-group\)\s*\{[^}]*margin:\s*4px 0 0/,
);
expect(styles).toMatch(
/\.project-supervisor-message-list\s*>\s*:is\(\.design-agent-reasoning,\s*\.agent-tool-call-group\),/,
);
});
it('仅最外层摘要使用正文色,不覆盖内部过程色', () => {
expect(styles).toMatch(
/\.message-turn-process\s*>\s*summary\s*>\s*\.agent-process-summary\s*\{[^}]*color:\s*var\(--platform-text-strong/,
);
expect(styles).not.toMatch(
/\.message-turn-process-body\s*\{[^}]*color:\s*var\(--platform-text-strong/,
);
});
it('失败工具行与失败组摘要使用错误色', () => {
expect(styles).toContain(".agent-tool-call-group[data-has-failure='true']");
expect(styles).toMatch(
/\.agent-tool-call-group-row\[data-status='failed'\]\s*:is\([^}]+\)\s*\{\s*color:\s*var\(--platform-button-danger-text/,
);
});
});
@@ -237,14 +237,23 @@ export function registerDesignAgentSurfaceTests() {
expect(summary.textContent).toContain('结论');
expect(summary.textContent).not.toContain('##');
expect(summary.textContent).not.toContain('`');
expect(summary.querySelector('.lucide-lightbulb')).not.toBeNull();
expect(summary.querySelector('.lucide-brain')).toBeNull();
fireEvent.click(summary);
expect(details.open).toBe(true);
// 展开态复用助手正文的 Markdown 安全链路:标题 / 列表 / 行内代码都成为真实语义元素。
await waitFor(() => {
expect(details.querySelector('h2')?.textContent).toBe('结论');
expect(summary.textContent).toBe('思考过程');
});
expect(details.querySelectorAll('li')).toHaveLength(2);
expect(details.querySelector('code')?.textContent).toBe('npm run build');
expect(details.textContent?.match(/结论/g)).toHaveLength(1);
fireEvent.click(summary);
await waitFor(() => {
expect(details.open).toBe(false);
expect(summary.textContent).toContain('结论');
});
});
it('renders historical reasoning as independent collapsed sections', async () => {
@@ -281,5 +290,6 @@ export function registerDesignAgentSurfaceTests() {
fireEvent.click(summaries[0]);
expect(details[0].open).toBe(true);
expect(details[1].open).toBe(false);
await waitFor(() => expect(summaries[0].textContent).toBe('思考过程'));
});
}
@@ -9034,6 +9034,217 @@ export function registerProjectSupervisorSurfaceTests() {
).toContain('总耗时 1.0秒');
});
/** 直接渲染 ProjectSupervisorView 的最小 props(与下面 initial 占位用例同源)。 */
function supervisorSurfaceProps(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
activeVersionId: null,
chatInput: '',
chatReferences: [],
chatProjectAssets: [],
composerRef: createRef(),
directCodex: true,
directTurnRunning: false,
hiddenConversationCount: 0,
messagesRef: createRef(),
needsUserInput: false,
onCancelConfirmation: vi.fn(),
onCancelPendingCommand: vi.fn(),
onChatInputChange: vi.fn(),
onConfirmConfirmation: vi.fn(),
onConfirmPendingCommand: vi.fn(),
onScroll: vi.fn(),
onShowEarlierMessages: vi.fn(),
onSubmit: vi.fn(),
pendingConfirmation: null,
pendingCommand: null,
projectPath: '/tmp/launcher-codex-placeholder-game',
transientReply: '',
visibleMessages: [],
visibleProfessionalAgentCards: [],
workspaceStatus: '等待指令',
planGddState: createPlanGddStateView(),
planGddHydrateBusy: false,
planGddDecisionBusy: false,
planGddError: null,
onPlanGddRefresh: vi.fn(),
onPlanGddDecision: vi.fn(),
runtime: null,
error: '',
runtimeByAgentId: {},
controlBusy: false,
professionalResultsByAgentId: {},
onToolAction: vi.fn(),
onSupervisorRetry: vi.fn(),
onProfessionalToolAction: vi.fn(),
onProfessionalRetry: vi.fn(),
onUserInput: vi.fn(),
initialSupervisorMessage: '',
...overrides,
};
}
/**
* "思考中…" Direct ** /
* ** reasoning / assistant / tool
*/
it('shows a temporary thinking row until the first reasoning, text or tool arrives', () => {
const historyUser = {
itemId: 'direct-codex:turn-1:user',
kind: 'message',
role: 'user',
text: '上一轮的问题',
at: 1_000_000,
};
const historyFinal = {
itemId: 'direct-codex:turn-1:assistant',
kind: 'message',
role: 'assistant',
text: '上一轮的答复',
at: 1_001_000,
};
// 本地乐观新用户消息:还没有任何正式条目,所以本轮 process / finals 都是空的。
const optimisticUser = {
role: 'user' as const,
text: '这一轮的新问题',
messageId: 'direct-codex:turn-2:user',
updatedAt: 2_000_000,
};
const busyProps = supervisorSurfaceProps({
directTurnRunning: true,
directEntries: [historyUser, historyFinal],
conversationMessages: [optimisticUser],
});
const thinkingRows = (surface: HTMLElement) =>
Array.from(surface.querySelectorAll('*')).filter(
(node) =>
node.children.length === 0 &&
/思考中/.test(node.textContent ?? '') &&
!node.closest('[data-testid="agent-tool-call-group"]'),
);
// 旧历史 final + 本地新用户 + 忙碌:思考中顶位,且不在工具组里。
const view = render(
React.createElement(ProjectSupervisorView, busyProps as never),
);
const surface = screen.getByLabelText('陶泥儿项目对话');
expect(thinkingRows(surface)).toHaveLength(1);
// 本轮开口条目落盘后 reasoning 到达 → 消失。
const turn2User = {
itemId: 'direct-codex:turn-2:user',
kind: 'message',
role: 'user',
text: '这一轮的新问题',
at: 2_000_050,
};
view.rerender(
React.createElement(ProjectSupervisorView, {
...busyProps,
directEntries: [
historyUser,
historyFinal,
turn2User,
{
itemId: 'direct-codex:turn-2:reasoning',
kind: 'reasoning',
role: null,
text: '先看目录',
at: 2_000_100,
},
],
} as never),
);
expect(thinkingRows(surface)).toHaveLength(0);
// 本轮 assistant 正文到达 → 消失。
view.rerender(
React.createElement(ProjectSupervisorView, {
...busyProps,
directEntries: [
historyUser,
historyFinal,
turn2User,
{
itemId: 'direct-codex:turn-2:assistant',
kind: 'message',
role: 'assistant',
text: '我先看一下项目',
at: 2_000_200,
},
],
} as never),
);
expect(thinkingRows(surface)).toHaveLength(0);
// 本轮工具到达(工具组出现)→ 消失,且不是被工具组内文字顶掉的。
view.rerender(
React.createElement(ProjectSupervisorView, {
...busyProps,
directEntries: [
historyUser,
historyFinal,
turn2User,
{
itemId: 'call-2',
kind: 'tool',
role: null,
text: null,
at: 0,
toolCall: {
schemaVersion: 'agc-tool-call.v1',
id: 'call-2',
kind: 'command',
title: '执行命令',
summary: 'npm run build',
status: 'running',
detail: { command: 'npm run build' },
startedAt: 2_000_300,
updatedAt: 0,
},
},
],
} as never),
);
expect(
within(surface).getAllByTestId('agent-tool-call-group'),
).toHaveLength(1);
expect(thinkingRows(surface)).toHaveLength(0);
// 空闲(不忙)→ 不显示;终止中 → 不显示。
view.rerender(
React.createElement(ProjectSupervisorView, {
...busyProps,
directTurnRunning: false,
directEntries: [historyUser, historyFinal],
directTurnStartedAt: 0,
} as never),
);
expect(thinkingRows(surface)).toHaveLength(0);
view.rerender(
React.createElement(ProjectSupervisorView, {
...busyProps,
turnCancelling: true,
} as never),
);
expect(thinkingRows(surface)).toHaveLength(0);
view.unmount();
// 只有旧历史、没有任何本轮用户消息:即使忙碌也不显示。
const historyOnly = render(
React.createElement(ProjectSupervisorView, {
...busyProps,
directEntries: [historyUser, historyFinal],
conversationMessages: [],
} as never),
);
expect(thinkingRows(screen.getByLabelText('陶泥儿项目对话'))).toHaveLength(
0,
);
historyOnly.unmount();
});
it('keeps the initial supervisor placeholder from duplicating the landed direct user entry', () => {
// 初始占位气泡只在"最初那条消息还没有正式条目"时顶位:
// Direct 模式的正式条目走 `directEntries`、不进 `conversationMessages`
@@ -34,6 +34,34 @@ function toolCall(
}
export function registerToolCallGroupTests() {
it('keeps failed operations visible when another tool in the group is running', () => {
const view = render(
React.createElement(ToolCallGroup, {
active: true,
calls: [
toolCall({ id: 'failed', kind: 'command', status: 'failed' }),
toolCall({ id: 'live', kind: 'command', status: 'running' }),
toolCall({ id: 'done', kind: 'command', status: 'completed' }),
],
}),
);
const group = within(view.container).getByTestId('agent-tool-call-group');
const head = within(group).getByTestId('agent-tool-call-group-head');
expect(group.getAttribute('data-status')).toBe('running');
expect(group.getAttribute('data-has-failure')).toBe('true');
expect(head.textContent).toContain('有操作失败');
expect(head.textContent).toContain('进行中');
fireEvent.click(head);
const rows = within(group).getAllByTestId('agent-tool-call-row');
expect(rows.map((row) => row.getAttribute('data-status'))).toEqual([
'failed',
'running',
'completed',
]);
expect(within(rows[0]).getByText('失败')).not.toBeNull();
expect(within(rows[2]).queryByText('失败')).toBeNull();
});
it('summarizes the group as its own operation count', () => {
// 用户口径:`执行了 X 个操作`,X = 这一组自己的调用数(不拆 kind、不看段落数)。
expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe(
@@ -233,15 +261,15 @@ export function registerToolCallGroupTests() {
}),
),
).toBeNull();
// 合法 0 显示 `0.0s`;两种耗时都始终一位小数
// 合法 0 显示 0.0秒;分钟以内保留小数,达到分钟后显示整数秒
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(formatToolCallDuration(60000)).toBe('1分00秒');
expect(formatToolCallDuration(125000)).toBe('2分05秒');
// 整轮总耗时 = 本轮起点 → 本轮终态(不是块内工具的时间跨度)。
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 9000 })).toBe(8000);
@@ -254,14 +282,14 @@ export function registerToolCallGroupTests() {
expect(turnTotalDurationMs({ startedAt: 1049, endedAt: 1001 })).toBeNull();
expect(turnTotalDurationMs({ startedAt: 0, endedAt: 9000 })).toBeNull();
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 0 })).toBeNull();
// 总耗时文案:合法 0 显示 `0.0秒`,始终一位小数。
// 总耗时复用同一格式,达到分钟后不再显示小数。
expect(formatTurnDuration(0)).toBe('0.0秒');
expect(formatTurnDuration(8000)).toBe('8.0秒');
expect(formatTurnDuration(42000)).toBe('42.0秒');
expect(formatTurnDuration(59900)).toBe('59.9秒');
expect(formatTurnDuration(60000)).toBe('1分00.0秒');
expect(formatTurnDuration(240000)).toBe('4分00.0秒');
expect(formatTurnDuration(345000)).toBe('5分45.0秒');
expect(formatTurnDuration(60000)).toBe('1分00秒');
expect(formatTurnDuration(240000)).toBe('4分00秒');
expect(formatTurnDuration(345000)).toBe('5分45秒');
expect(formatTurnDuration(null)).toBeNull();
expect(formatTurnDuration(undefined)).toBeNull();
@@ -372,7 +400,7 @@ export function registerToolCallGroupTests() {
});
/**
* 动态计时:组用时与单条耗时都每 100ms 刷新、始终一位小数
* 动态计时:每 100ms 更新,分钟以内显示小数,达到分钟后显示整数秒
*
* 假时钟同时接管 `Date.now()``useLiveNow` 读时间戳算差值,不按 tick 累加。
*/
@@ -417,11 +445,11 @@ export function registerToolCallGroupTests() {
});
expect(group.getAttribute('data-duration-ms')).toBe('5400');
expect(head.textContent).toContain('耗时 5.4秒');
// 分钟进位:65400ms → `1分05.4秒`
// 分钟进位后隐藏小数65400ms → 1分05
act(() => {
vi.advanceTimersByTime(60000);
});
expect(head.textContent).toContain('耗时 1分05.4秒');
expect(head.textContent).toContain('耗时 1分05秒');
// 组内工具全部拿到终态:组用时冻结在**工具终点**上(不是当前时钟),
// 继续推进时钟不再变化,也不等回合收口。
@@ -444,7 +472,7 @@ export function registerToolCallGroupTests() {
vi.advanceTimersByTime(30000);
});
expect(group.getAttribute('data-duration-ms')).toBe('65000');
expect(head.textContent).toContain('耗时 1分05.0秒');
expect(head.textContent).toContain('耗时 1分05秒');
expect(head.textContent).not.toContain('进行中');
view.unmount();
vi.useRealTimers();
@@ -211,6 +211,65 @@ describe('DirectProject 聊天 reducer', () => {
expect(selectDirectChatEntries(state)).toHaveLength(0);
});
it('退出码非零优先于 completed:命令跑完但失败不标成功', () => {
const command = (
status: string | null,
exitCode: number | null,
itemId: string,
) => ({
itemType: 'commandExecution' as const,
itemId,
command: 'npm test',
output: 'boom',
status,
exitCode,
at: 2_000,
});
// status=completed + exitCode=1:失败(不能因为"跑完了"就标成功)。
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.completed',
at: 2_000,
item: command('completed', 1, 'call-fail'),
}),
]);
expect(selectDirectChatEntries(failed)[0]?.toolCall?.status).toBe('failed');
// status=completed + exitCode=0:成功。
const ok = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.completed',
at: 2_000,
item: command('completed', 0, 'call-ok'),
}),
]);
expect(selectDirectChatEntries(ok)[0]?.toolCall?.status).toBe('completed');
// status=inProgress 且没有退出码:仍在跑。
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.started',
at: 2_000,
item: command('inProgress', null, 'call-running'),
}),
]);
expect(selectDirectChatEntries(running)[0]?.toolCall?.status).toBe(
'running',
);
// 上游显式失败终态优先于 completed:即使 exitCode=0 也算失败。
const declined = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.completed',
at: 2_000,
item: command('declined', 0, 'call-declined'),
}),
]);
expect(selectDirectChatEntries(declined)[0]?.toolCall?.status).toBe(
'failed',
);
});
it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => {
const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
toolStarted(),
@@ -12,11 +12,14 @@ describe('统一中文耗时', () => {
[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秒'],
[59_950, '1分00秒'],
[60_000, '1分00秒'],
[60_100, '1分00秒'],
[65_600, '1分06秒'],
[125_200, '2分05秒'],
[3_599_950, '1时00分00秒'],
[3_725_200, '1时02分05秒'],
[90_061_200, '25时01分01秒'],
])('%s ms → %s,所有入口一致', (ms, expected) => {
expect(formatElapsedDuration(ms)).toBe(expected);
expect(formatToolCallDuration(ms)).toBe(expected);
@@ -209,7 +209,7 @@ describe('生成任务模型', () => {
test('已耗时文案按分秒呈现', () => {
expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12.0秒');
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1分12.0秒');
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1分12秒');
expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('—');
});
});
@@ -124,7 +124,8 @@ describe('「生成任务」侧栏', () => {
expect(within(doneSection).getByText('生成已完成。')).not.toBeNull();
expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝');
expect(
screen.getAllByText(/^已耗时 (?:\d+时)?(?:\d+)?\d+\.\d秒$/).length,
screen.getAllByText(/^已耗时 (?:\d+\.\d秒|(?:\d+)?\d+分\d{2}秒)$/)
.length,
).toBeGreaterThan(0);
});
@@ -16,7 +16,7 @@
## 开发中
- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 只在展开正文走既有安全渲染,折叠预览只取纯文本,不在 summary 嵌套链接或按钮。耗时统一复用中文时分秒格式(秒一位小数),格式化与各层计时边界分离,不因统一文案改变状态来源
- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 只在展开正文走既有安全渲染,折叠预览只取纯文本,不在 summary 嵌套链接或按钮。耗时统一复用中文时分秒格式(不足一分钟一位小数,达到分钟后整数秒),格式化与各层计时边界分离。过程行在运行中和完成后的折叠层内保持同一紧凑间距;失败状态按明确终态与非零退出码呈现红色,不由自然语言输出猜测
- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。
@@ -11,7 +11,7 @@
## 一句话交付
把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」改成可折叠卡片,整轮总耗时只在回合状态/完成小结显示一处,工具组与内部工具分别显示各自耗时;运行中的计时动态增长并保留一位小数
把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」改成可折叠卡片,整轮总耗时只在回合状态/完成小结显示一处,工具组与内部工具分别显示各自耗时;运行中的计时动态增长,不足一分钟保留一位小数,达到分钟后显示整数秒
## 总耗时与动态工具计时
@@ -19,8 +19,12 @@
- 思考过程、执行过程和工具组统一为无大块底色的单行折叠入口:左侧小图标/简短预览,最右侧展开箭头;长文本省略,不把箭头挤出窄聊天列。沿用共享过程色和 12px 层级,键盘可展开、有可见焦点。
- 思考折叠态直接显示浅色的内容预览,而不是只有“思考过程”标题;预览不显示 Markdown 控制符、原始 HTML 或链接地址。展开后复用现有安全 Markdown 渲染链路,支持段落、强调、列表、链接、代码块等,不开启原始 HTML 执行。实时与历史、当前 Agent 与策划 Agent 使用同一呈现。
- 思考入口使用灯泡图标;展开后入口文字切换为“思考过程”,原内容仅在 Markdown 正文显示一份,不同时保留相同摘要。收起后恢复浅色内容预览;键盘开合与鼠标开合行为一致。
- 回合结束后最外层“执行了 N 个操作,耗时 XXX”使用正文颜色;收进该折叠层的内部过程保持运行中原有颜色、行高和块间距,不能因额外 Grid gap 与子元素 margin 叠加而拉大间距。
- 思考与工具组按紧凑连续列表呈现,相邻过程块间距 4px,内部工具行使用紧凑的 2px 上下内边距;运行与完成后的容器使用同一口径。失败工具的名称、图标、状态及耗时使用错误红色,所属组摘要有“有操作失败”提示且标红;同组其它工具仍运行时也不能吞掉失败标识,成功行不得被连带染红。
- 工具组摘要只统计本组工具条目总数,显示“执行了 N 个操作,耗时 XXX”;运行中另有状态提示,不把操作数称作成功数,失败仍保留明确状态。回合外层执行过程统计所有工具块的操作数,不把思考段落或文本消息当工具操作;缺失耗时不伪造。
- 所有 AGC 耗时统一用中文时分秒:`5.2秒``2分05.2秒``1时02分05.2秒`。省略前导零单位,带小时则保留两位分钟,带分钟则秒补齐两位整数,秒始终一位小数;先整体舍入再拆分单位,避免出现 60 秒/60 分。工具行、组、整轮、生成任务及策划耗时共用一个纯格式化函数,不改变各自计时来源。
- 所有 AGC 耗时统一用中文时分秒:不足一分钟显示 `5.2秒`,达到分钟后显示整数秒,如 `2分05秒``1时02分05秒`。省略前导零单位,带小时则保留两位分钟,带分钟则秒补齐两位整数;先整体舍入再拆分单位,避免出现 60 秒/60 分。工具行、组、整轮、生成任务及策划耗时共用一个纯格式化函数,不改变各自计时来源和动态刷新
- Direct 对话发送后、本轮首个可见思考/文字/工具响应到达前,在消息列表内、工具调用块外临时显示“思考中…”。收到首个响应后移除,失败、取消和空闲时同样不显示;直接从现有忙碌状态与本轮投影派生,不新增原生事件、定时器或持久化消息。工具间等待不在本次轻量实现范围内。
- 独立计时保持不变,组状态/用时在单行中作为次要信息,空间不足可移至展开内容,不能占第二行破坏紧凑入口;整轮总耗时仍只显示一次。工具输入/输出、失败信息与操作明细不因样式变更丢失。
- 只改展示与对应测试,不改变消息身份、分组顺序、生成或原生执行语义。验证覆盖 Markdown 安全与语义、预览省略、准确计数、展开/键盘操作、窄屏布局及原有计时冻结。
@@ -129,9 +133,9 @@ DirectRuntime 写 `<projectRoot>/.agent/conversations/tool-calls.jsonl`;回读
```
- 文案规则(按 kind,不允许自由发挥):
- 块头按实际工具条目计总数,形如 `执行了 7 个操作,耗时 1分05.2秒`;空集合不渲染块。操作数不是成功数,失败与运行状态仍单独可见。
- 块头按实际工具条目计总数,形如 `执行了 7 个操作,耗时 1分05秒`;空集合不渲染块。操作数不是成功数,失败与运行状态仍单独可见。
- 行文案:展示工具摘要,不重复添加动词前缀;`context_compaction` 固定为“整理上下文”。状态单独放在行尾(执行中 / 已执行 / 失败),`failed` 使用现有 `--platform-*` 错误色;已结束回合不因残留 `running` 快照显示“执行中”。
- 耗时:执行“总耗时与动态工具计时”合同。块头是本组用时,单条是该工具独立耗时,整轮总耗时只在本轮状态/小结显示;均保留一位小数,运行中每 100 毫秒刷新,各自终态固定。
- 耗时:执行“总耗时与动态工具计时”合同。块头是本组用时,单条是该工具独立耗时,整轮总耗时只在本轮状态/小结显示;不足一分钟显示一位小数,达到分钟后显示整数秒,运行中每 100 毫秒刷新,各自终态固定。
- 时间:范围与对应层级用时采用同一边界,不把用户发送起点与局部工具组终点混搭。缺失的历史时间不编造。
- 回合结束时间与耗时在正文下方右对齐;Direct 对话输入框提示统一为“描述你的想法,或 @ 引用素材”,引用按钮保留输入盒的 12px 内边距,不使用负边距贴边。
- 当前 Agent 与策划 Agent 共用 `packages/shared``AgentMessageContent` 表现组件:正文为 14px / `--platform-text-strong`,思考、中间输出和工具调用为 12px / `--platform-text-soft`。实时与历史思考共用同一个折叠入口;工具输入输出继承过程色,失败状态保留错误色。Markdown 标题、表格及代码高亮在过程区同步弱化,最终回复和文档预览仍保留正常排版,不按 Agent 类型复制样式。输入提示与禁用状态保持原有反馈。
@@ -1,6 +1,6 @@
/**
* 毫秒耗时的统一显示:5.2秒 / 2分05.2秒 / 1时02分05.2秒。
* 保留十分之一秒,先整体舍入再拆单位,避免 60.0 秒或 60 分溢出。
* 毫秒耗时的统一显示:5.2秒 / 2分05秒 / 1时02分05秒。
* 不足一分钟保留一位小数,达到分钟后显示整数秒;先舍入再拆单位,避免进位溢出。
* 非法或未知耗时返回 null,不能以 0 冒充已测得结果。
*/
export function formatElapsedDuration(
@@ -14,13 +14,13 @@ export function formatElapsedDuration(
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 (tenths < 600) return `${(tenths / 10).toFixed(1)}`;
const totalSeconds = Math.round(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = String(totalSeconds % 60).padStart(2, '0');
if (hours > 0) {
return `${hours}${String(minutes).padStart(2, '0')}${seconds.padStart(4, '0')}`;
return `${hours}${String(minutes).padStart(2, '0')}${seconds}`;
}
return minutes > 0
? `${minutes}${seconds.padStart(4, '0')}`
: `${seconds}`;
return `${minutes}${seconds}`;
}