From d90bde0b889bf3a9d461bb8c62f74a5b653cc757 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sun, 26 Jul 2026 15:08:40 +0800 Subject: [PATCH] feat(web): show tool execution timing (#1140) --- web/src/components/ToolCard/ToolCard.test.ts | 79 +++++++++++- web/src/components/ToolCard/ToolCard.tsx | 121 +++++++++++++++--- .../ToolCard/ToolGroupCard.test.tsx | 68 +++++++++- web/src/components/ToolCard/ToolGroupCard.tsx | 70 ++++++++-- .../ToolCard/toolDetailDuration.test.tsx | 19 ++- .../ToolCard/toolTimingSummary.test.tsx | 44 +++++++ web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + 8 files changed, 367 insertions(+), 38 deletions(-) create mode 100644 web/src/components/ToolCard/toolTimingSummary.test.tsx diff --git a/web/src/components/ToolCard/ToolCard.test.ts b/web/src/components/ToolCard/ToolCard.test.ts index 94f6ae54..74678447 100644 --- a/web/src/components/ToolCard/ToolCard.test.ts +++ b/web/src/components/ToolCard/ToolCard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatSubagentModelLabel, getSubagentModel, shouldShowInlineToolCardBody, shouldUseCompactTerminalToolCard } from '@/components/ToolCard/ToolCard' +import { formatSubagentModelLabel, getSubagentModel, getToolTimingDetails, shouldShowInlineToolCardBody, shouldUseCompactTerminalToolCard } from '@/components/ToolCard/ToolCard' import type { AgentTextBlock, ChatBlock, ToolCallBlock } from '@/chat/types' function makeAgentTextBlock(overrides: Partial = {}): AgentTextBlock { @@ -64,6 +64,83 @@ describe('ToolCard terminal display mode helpers', () => { }) }) +describe('getToolTimingDetails', () => { + it('shows start, finish, and duration for completed tools', () => { + const tool = makeToolCallChild({ + tool: { + ...makeToolCallChild().tool, + createdAt: 900, + startedAt: 1_000, + completedAt: 4_000, + execStartedAt: 1_500, + execCompletedAt: 3_500, + }, + }).tool + + expect(getToolTimingDetails(tool, 10_000)).toEqual({ + startedAt: 1_500, + completedAt: 3_500, + durationMs: 2_000, + }) + }) + + it('shows a live duration and omits finish while a tool is running', () => { + const tool = makeToolCallChild({ + tool: { + ...makeToolCallChild().tool, + state: 'running', + createdAt: 900, + startedAt: 1_000, + completedAt: null, + execStartedAt: 1_200, + execCompletedAt: null, + }, + }).tool + + expect(getToolTimingDetails(tool, 4_000)).toEqual({ + startedAt: 1_000, + completedAt: null, + durationMs: 3_000, + }) + }) + + it('keeps start, finish, and duration on the hub clock when exec timing is incomplete', () => { + const tool = makeToolCallChild({ + tool: { + ...makeToolCallChild().tool, + createdAt: 900, + startedAt: 1_000, + completedAt: 4_000, + execStartedAt: 1_500, + execCompletedAt: null, + }, + }).tool + + expect(getToolTimingDetails(tool, 10_000)).toEqual({ + startedAt: 1_000, + completedAt: 4_000, + durationMs: 3_000, + }) + }) + + it('does not present a pending tool as started', () => { + const tool = makeToolCallChild({ + tool: { + ...makeToolCallChild().tool, + state: 'pending', + startedAt: null, + completedAt: null, + }, + }).tool + + expect(getToolTimingDetails(tool, 4_000)).toEqual({ + startedAt: null, + completedAt: null, + durationMs: null, + }) + }) +}) + describe('formatSubagentModelLabel', () => { it('uses getClaudeModelLabel for a preset alias', () => { expect(formatSubagentModelLabel('opus')).toBe('Opus') diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index e7277960..4ca8adcd 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -1,4 +1,4 @@ -import type { ChatBlock, ToolCallBlock } from '@/chat/types' +import type { ChatBlock, ChatToolCall, ToolCallBlock } from '@/chat/types' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' import { memo, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' @@ -17,7 +17,7 @@ import { getToolFullViewComponent, getToolViewComponent } from '@/components/Too import { getToolResultViewComponent } from '@/components/ToolCard/views/_results' import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers' import { toolDurationMs } from '@/components/ToolCard/toolDuration' -import { formatDuration } from '@/chat/presentation' +import { formatDuration, formatMessageTimestampTitle } from '@/chat/presentation' import type { TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { usePointerFocusRing } from '@/hooks/usePointerFocusRing' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' @@ -45,25 +45,112 @@ export function shouldShowInlineToolCardBody( return !presentationMinimal } -function ElapsedView(props: { from: number; active: boolean }) { +export function getToolTimingDetails(tool: ChatToolCall, now: number): { + startedAt: number | null + completedAt: number | null + durationMs: number | null +} { + if (tool.state === 'pending') { + return { startedAt: null, completedAt: null, durationMs: null } + } + + const active = tool.state === 'running' + const hasExecPair = tool.execStartedAt != null && tool.execCompletedAt != null + const startedAt = active || !hasExecPair + ? (tool.startedAt ?? tool.createdAt) + : tool.execStartedAt + const completedAt = active + ? null + : (hasExecPair ? tool.execCompletedAt : tool.completedAt) + const liveDurationMs = active && startedAt != null ? Math.max(0, now - startedAt) : null + + return { + startedAt, + completedAt, + durationMs: toolDurationMs(tool) ?? liveDurationMs, + } +} + +export function formatCompactToolTimestamp(value: number): string { + return new Date(value).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }) +} + +export function ToolTimingSummary(props: { + startedAt: number | null + completedAt: number | null + durationMs: number | null + typography?: 'detail' | 'group' +}) { + const { t } = useTranslation() + const items = [ + props.startedAt != null ? { label: t('tool.startedAt'), value: formatCompactToolTimestamp(props.startedAt) } : null, + props.completedAt != null ? { label: t('tool.completedAt'), value: formatCompactToolTimestamp(props.completedAt) } : null, + props.durationMs != null ? { label: t('tool.duration'), value: formatDuration(props.durationMs) } : null, + ].filter((item): item is { label: string; value: string } => item !== null) + + if (items.length === 0) return null + + return ( +
+ {items.map((item) => ( + + {item.label} + {item.value} + + ))} +
+ ) +} + +function ToolCardTimingSummary(props: { tool: ChatToolCall }) { + const active = props.tool.state === 'running' const [now, setNow] = useState(() => Date.now()) useEffect(() => { - if (!props.active) return + if (!active) return setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS) return () => clearInterval(id) - }, [props.active, props.from]) + }, [active, props.tool.startedAt, props.tool.createdAt]) - if (!props.active) return null + return +} - const elapsed = Math.max(0, now - props.from) / 1000 - if (!Number.isFinite(elapsed)) return null +function ToolTimingDetails(props: { block: ToolCallBlock }) { + const { t } = useTranslation() + const tool = props.block.tool + const active = tool.state === 'running' + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!active) return + setNow(Date.now()) + const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS) + return () => clearInterval(id) + }, [active, tool.startedAt, tool.createdAt]) + + const { startedAt, completedAt, durationMs } = getToolTimingDetails(tool, now) + const rows = [ + startedAt != null ? [t('tool.startedAt'), formatMessageTimestampTitle(new Date(startedAt))] : null, + !active && completedAt != null ? [t('tool.completedAt'), formatMessageTimestampTitle(new Date(completedAt))] : null, + durationMs != null ? [t('tool.duration'), formatDuration(durationMs)] : null, + ].filter((row): row is string[] => row !== null) + + if (rows.length === 0) return null return ( - - {elapsed.toFixed(1)}s - +
+ {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
) } @@ -280,16 +367,9 @@ export function ToolDetailDialogContent(props: { const isQuestionToolWithAnswers = isQuestionTool && permission?.answers && Object.keys(permission.answers).length > 0 - const durationMs = toolDurationMs(props.block.tool) - return (
- {durationMs != null ? ( -
- {t('tool.duration')} - {formatDuration(durationMs)} -
- ) : null} +
{isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')} @@ -337,7 +417,6 @@ function ToolCardInner(props: ToolCardProps) { const subtitle = presentation.subtitle ?? props.block.tool.description const taskSummary = renderTaskSummary(props.block, props.metadata, t) const subagentModel = isSubagentToolName(toolName) ? getSubagentModel(props.block.children) : null - const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt const isCodexAgentCard = toolName === 'CodexAgent' const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode) const showInline = shouldShowInlineToolCardBody(toolName, presentation.minimal, props.terminalToolDisplayMode) @@ -390,6 +469,7 @@ function ToolCardInner(props: ToolCardProps) { {truncate(subtitle, 160)} ) : null} +
) : null} - diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx index 3e94e0bd..7610cdca 100644 --- a/web/src/components/ToolCard/ToolGroupCard.test.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ToolCallBlock } from '@/chat/types' import type { ToolGroupBlock } from '@/chat/toolGroups' import { HappyChatProvider } from '@/components/AssistantChat/context' -import { ToolGroupCard } from '@/components/ToolCard/ToolGroupCard' +import { getToolGroupTiming, ToolGroupCard } from '@/components/ToolCard/ToolGroupCard' import { I18nProvider } from '@/lib/i18n-context' function makeToolBlock(id: string, name: string, input: unknown = {}): ToolCallBlock { @@ -102,13 +102,72 @@ describe('ToolGroupCard', () => { expect(screen.getByRole('button', { name: /inspect a\.ts/i })).toHaveAttribute('aria-expanded', 'false') expect(screen.getByText('Run 1 · Read 1')).toBeInTheDocument() - expect(screen.getByText('2 actions')).toBeInTheDocument() + expect(screen.queryByText('2 actions')).not.toBeInTheDocument() + expect(screen.getByText('Run 1 · Read 1')).toHaveClass('text-xs', 'font-normal', 'text-[var(--app-hint)]') expect(screen.queryByText('src/a.ts')).not.toBeInTheDocument() expect(screen.queryByText('bun test')).not.toBeInTheDocument() expect(view.container.innerHTML).toContain('bg-[var(--app-tool-group-bg)]') }) + it('derives completed group wall-clock timing from the earliest start and latest finish', () => { + const first = makeToolBlock('read-1', 'Read') + first.tool.startedAt = 1_000 + first.tool.completedAt = 2_000 + const second = makeToolBlock('bash-1', 'Bash') + second.tool.startedAt = 1_500 + second.tool.completedAt = 4_000 + + expect(getToolGroupTiming([first, second], 10_000)).toEqual({ + startedAt: 1_000, + completedAt: 4_000, + durationMs: 3_000, + running: false, + }) + }) + + it('shows group start, live duration, and a spinner while collapsed and running', () => { + const startedAt = Date.now() - 5_000 + const completed = makeToolBlock('read-1', 'Read') + completed.tool.startedAt = startedAt + completed.tool.completedAt = startedAt + 1_000 + const running = makeToolBlock('bash-1', 'Bash') + running.tool.state = 'running' + running.tool.startedAt = startedAt + 1_000 + running.tool.completedAt = null + + const group = makeGroup({ + tools: [completed, running], + summary: { + ...makeGroup().summary, + runningCount: 1, + }, + }) + const view = renderCard(group) + + expect(screen.getByText('Started')).toBeInTheDocument() + expect(screen.getByText('Duration')).toBeInTheDocument() + expect(screen.queryByText('Finished')).not.toBeInTheDocument() + expect(within(view.container).getByLabelText('Running')).toBeInTheDocument() + }) + + it('shows final timing in the collapsed header after every tool finishes', () => { + const startedAt = Date.now() - 4_000 + const first = makeToolBlock('read-1', 'Read') + first.tool.startedAt = startedAt + first.tool.completedAt = startedAt + 1_000 + const second = makeToolBlock('bash-1', 'Bash') + second.tool.startedAt = startedAt + 1_000 + second.tool.completedAt = startedAt + 4_000 + + renderCard(makeGroup({ tools: [first, second] })) + + expect(screen.getByText('Started')).toBeInTheDocument() + expect(screen.getByText('Finished')).toBeInTheDocument() + expect(screen.getByText('Duration')).toBeInTheDocument() + expect(screen.getByText('4.0s')).toBeInTheDocument() + }) + it('expands to show compact rows and opens a detail dialog per row', async () => { const view = renderCard(makeGroup()) const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i }) @@ -117,7 +176,8 @@ describe('ToolGroupCard', () => { fireEvent.click(groupToggle) expect(groupToggle).toHaveAttribute('aria-expanded', 'true') expect(view.container.querySelector('svg[data-state="open"]')).toBeInTheDocument() - expect(screen.getByText('2 actions')).toBeInTheDocument() + expect(screen.getByText('Run 1 · Read 1')).toBeInTheDocument() + expect(screen.queryByText('2 actions')).not.toBeInTheDocument() expect(screen.getByText('src/a.ts')).toBeInTheDocument() expect(screen.getByText('Terminal')).toBeInTheDocument() expect(screen.getByText('bun test')).toBeInTheDocument() @@ -177,7 +237,7 @@ describe('ToolGroupCard', () => { } })) - expect(within(view.container).getByRole('button', { name: /^explored$/i })).toHaveAttribute('aria-expanded', 'true') + expect(within(view.container).getByRole('button', { name: /^explored\b/i })).toHaveAttribute('aria-expanded', 'true') expect(screen.getByText('Read')).toBeInTheDocument() expect(screen.getByText('package.json')).toBeInTheDocument() expect(screen.getByText('Search')).toBeInTheDocument() diff --git a/web/src/components/ToolCard/ToolGroupCard.tsx b/web/src/components/ToolCard/ToolGroupCard.tsx index f65af51c..fb57eb62 100644 --- a/web/src/components/ToolCard/ToolGroupCard.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.tsx @@ -4,13 +4,41 @@ import type { ToolCallBlock } from '@/chat/types' import { getCodexCommandActions, type CodexCommandAction } from '@/chat/codexCommandPresentation' import type { SessionMetadataSummary } from '@/types/api' import { useHappyChatContext } from '@/components/AssistantChat/context' -import { ToolDetailDialogContent, ToolStatusIcon, toolStatusColorClass } from '@/components/ToolCard/ToolCard' +import { getToolTimingDetails, ToolDetailDialogContent, ToolStatusIcon, ToolTimingSummary, toolStatusColorClass } from '@/components/ToolCard/ToolCard' import { getToolPresentation } from '@/components/ToolCard/knownTools' import { formatGroupedHeaderSubtitle, formatGroupedHeaderTitle, safeGroupedLabelValue } from '@/components/ToolCard/groupedPresentation' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' +import { formatDuration } from '@/chat/presentation' + +const TIMING_INTERVAL_MS = 1000 + +export function getToolGroupTiming(tools: ToolCallBlock[], now: number): { + startedAt: number | null + completedAt: number | null + durationMs: number | null + running: boolean +} { + const startedValues = tools + .filter((tool) => tool.tool.state !== 'pending') + .map((tool) => tool.tool.startedAt ?? tool.tool.createdAt) + .filter((value): value is number => Number.isFinite(value)) + const startedAt = startedValues.length > 0 ? Math.min(...startedValues) : null + const running = tools.some((tool) => tool.tool.state === 'running') + const allFinished = tools.length > 0 && tools.every((tool) => tool.tool.state === 'completed' || tool.tool.state === 'error') + const completedValues = allFinished + ? tools.map((tool) => tool.tool.completedAt).filter((value): value is number => value != null && Number.isFinite(value)) + : [] + const completedAt = allFinished && completedValues.length === tools.length ? Math.max(...completedValues) : null + const durationEnd = running ? now : completedAt + const durationMs = startedAt != null && durationEnd != null && durationEnd >= startedAt + ? durationEnd - startedAt + : null + + return { startedAt, completedAt, durationMs, running } +} function DetailsIcon(props: { open: boolean }) { return ( @@ -169,8 +197,17 @@ export function ToolGroupCard(props: { const [isHydratingHistory, setIsHydratingHistory] = useState(false) const [historyExhausted, setHistoryExhausted] = useState(false) const [retryNonce, setRetryNonce] = useState(0) + const [now, setNow] = useState(() => Date.now()) const hydrationRunRef = useRef(0) const retryTimerRef = useRef | null>(null) + const groupTiming = getToolGroupTiming(props.block.tools, now) + + useEffect(() => { + if (!groupTiming.running) return + setNow(Date.now()) + const id = setInterval(() => setNow(Date.now()), TIMING_INTERVAL_MS) + return () => clearInterval(id) + }, [groupTiming.running, groupTiming.startedAt]) function clearRetryTimer() { if (retryTimerRef.current === null) { @@ -281,6 +318,9 @@ export function ToolGroupCard(props: { const subtitle = props.block.presentationMode === 'codex-exploration' ? null : formatGroupedHeaderSubtitle(props.block, t) ?? formatActionSummary(props.block, t) + const summaryBadgeText = props.block.presentationMode === 'codex-exploration' + ? null + : subtitle ?? t('toolGroup.toolCount', { n: props.block.tools.length }) const fileCount = props.block.summary.fileTargets.length return ( @@ -302,18 +342,24 @@ export function ToolGroupCard(props: { {primaryTitle}
- {subtitle ? ( - - {subtitle} - - ) : null} +
- {props.block.presentationMode !== 'codex-exploration' ? ( + {groupTiming.running ? ( + + + + ) : null} + {summaryBadgeText ? ( ) : null} {props.block.summary.runningCount > 0 ? ( @@ -351,6 +397,7 @@ export function ToolGroupCard(props: { {props.block.presentationMode === 'codex-exploration' ? ( ) : props.block.tools.map((tool) => { + const timing = getToolTimingDetails(tool.tool, now) return ( diff --git a/web/src/components/ToolCard/toolDetailDuration.test.tsx b/web/src/components/ToolCard/toolDetailDuration.test.tsx index 37413097..fb0eb6c4 100644 --- a/web/src/components/ToolCard/toolDetailDuration.test.tsx +++ b/web/src/components/ToolCard/toolDetailDuration.test.tsx @@ -33,7 +33,7 @@ function makeBlock(tool: Partial): ToolCallBlock { } } -describe('ToolDetailDialogContent — duration row', () => { +describe('ToolDetailDialogContent — execution timing', () => { it('shows a Duration row for a completed tool', () => { renderWithI18n() expect(screen.getByText('Duration')).toBeTruthy() @@ -46,9 +46,11 @@ describe('ToolDetailDialogContent — duration row', () => { expect(screen.getByText('0.8s')).toBeTruthy() }) - it('does not show a Duration row while running (no completedAt)', () => { + it('shows start and live duration but no finish while running', () => { renderWithI18n() - expect(screen.queryByText('Duration')).toBeNull() + expect(screen.getByText('Started')).toBeTruthy() + expect(screen.getByText('Duration')).toBeTruthy() + expect(screen.queryByText('Finished')).toBeNull() }) it('does not show a Duration row on clock skew (completedAt precedes startedAt)', () => { @@ -121,4 +123,15 @@ describe('ToolDetailDialogContent — duration row', () => { expect(screen.getByText('Duration')).toBeTruthy() expect(screen.getByText('2.5s')).toBeTruthy() }) + + it('shows start and finish timestamps for a completed tool', () => { + renderWithI18n() + expect(screen.getByText('Started')).toBeTruthy() + expect(screen.getByText('Finished')).toBeTruthy() + expect(screen.getByText('Duration')).toBeTruthy() + }) }) diff --git a/web/src/components/ToolCard/toolTimingSummary.test.tsx b/web/src/components/ToolCard/toolTimingSummary.test.tsx new file mode 100644 index 00000000..9871387c --- /dev/null +++ b/web/src/components/ToolCard/toolTimingSummary.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ToolTimingSummary } from '@/components/ToolCard/ToolCard' +import { I18nProvider } from '@/lib/i18n-context' + +describe('ToolTimingSummary', () => { + it('renders aligned label/value pairs for completed timing', () => { + render( + + + + ) + + expect(screen.getByText('Started')).toBeInTheDocument() + expect(screen.getByText('Finished')).toBeInTheDocument() + expect(screen.getByText('Duration')).toBeInTheDocument() + expect(screen.getByText('2.5s')).toBeInTheDocument() + }) + + it('omits finish while timing is still live', () => { + render( + + + + ) + + expect(screen.getByText('Started')).toBeInTheDocument() + expect(screen.queryByText('Finished')).not.toBeInTheDocument() + expect(screen.getByText('Duration')).toBeInTheDocument() + }) + + it('supports the compact UI typography used by grouped cards', () => { + render( + + + + ) + + const summary = screen.getByText('Started').parentElement?.parentElement + expect(summary).toHaveClass('text-xs', 'items-baseline') + expect(summary).not.toHaveClass('items-center', 'font-sans', 'font-normal', 'leading-5') + expect(summary).not.toHaveClass('font-mono') + }) +}) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 2e5bd0f1..eb0749bc 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -416,6 +416,8 @@ export default { 'tool.trace.callsSuffix': 'calls', 'tool.result': 'Result', 'tool.duration': 'Duration', + 'tool.startedAt': 'Started', + 'tool.completedAt': 'Finished', 'tool.semanticTitle.readFile': 'Read file', 'tool.semanticTitle.runShell': 'Run shell', 'tool.semanticTitle.search': 'Search', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 1a3b664c..4133bf01 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -420,6 +420,8 @@ export default { 'tool.trace.callsSuffix': '次调用', 'tool.result': '结果', 'tool.duration': '耗时', + 'tool.startedAt': '开始', + 'tool.completedAt': '结束', 'tool.semanticTitle.readFile': '读取文件', 'tool.semanticTitle.runShell': '运行命令', 'tool.semanticTitle.search': '搜索',