mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): show tool execution timing (#1140)
This commit is contained in:
@@ -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> = {}): 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')
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span className={props.typography === 'group' ? undefined : 'font-medium'}>{item.label}</span>
|
||||
<span className={props.typography === 'group' ? undefined : 'font-mono'}>{item.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <ToolTimingSummary {...getToolTimingDetails(props.tool, now)} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="font-mono text-xs text-[var(--app-hint)]">
|
||||
{elapsed.toFixed(1)}s
|
||||
</span>
|
||||
<div className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="contents">
|
||||
<span className="font-medium text-[var(--app-hint)]">{label}</span>
|
||||
<span className="font-mono text-[var(--app-hint)]">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mt-3 flex max-h-[75vh] flex-col gap-4 overflow-auto">
|
||||
{durationMs != null ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-[var(--app-hint)]">{t('tool.duration')}</span>
|
||||
<span className="font-mono text-[var(--app-hint)]">{formatDuration(durationMs)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<ToolTimingDetails block={props.block} />
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">
|
||||
{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)}
|
||||
</CardDescription>
|
||||
) : null}
|
||||
<ToolCardTimingSummary tool={props.block.tool} />
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
@@ -404,7 +484,6 @@ function ToolCardInner(props: ToolCardProps) {
|
||||
{subagentModel}
|
||||
</span>
|
||||
) : null}
|
||||
<ElapsedView from={runningFrom} active={props.block.tool.state === 'running'} />
|
||||
<span className={stateColor}>
|
||||
<ToolStatusIcon state={props.block.tool.state} />
|
||||
</span>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<CardDescription className="truncate whitespace-nowrap font-mono text-xs text-[var(--app-tool-card-subtitle)]">
|
||||
{subtitle}
|
||||
</CardDescription>
|
||||
) : null}
|
||||
<ToolTimingSummary
|
||||
startedAt={groupTiming.startedAt}
|
||||
completedAt={groupTiming.completedAt}
|
||||
durationMs={groupTiming.durationMs}
|
||||
typography="group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 self-center text-[var(--app-hint)]">
|
||||
{props.block.presentationMode !== 'codex-exploration' ? (
|
||||
{groupTiming.running ? (
|
||||
<span className={toolStatusColorClass('running')} aria-label={t('toolGroup.rowStatus.running')}>
|
||||
<ToolStatusIcon state="running" />
|
||||
</span>
|
||||
) : null}
|
||||
{summaryBadgeText ? (
|
||||
<SummaryBadge
|
||||
className="bg-[var(--app-subtle-bg)] text-[var(--app-hint)]"
|
||||
text={t('toolGroup.toolCount', { n: props.block.tools.length })}
|
||||
className="bg-[var(--app-subtle-bg)] text-xs font-normal text-[var(--app-hint)]"
|
||||
text={summaryBadgeText}
|
||||
/>
|
||||
) : null}
|
||||
{props.block.summary.runningCount > 0 ? (
|
||||
@@ -351,6 +397,7 @@ export function ToolGroupCard(props: {
|
||||
{props.block.presentationMode === 'codex-exploration' ? (
|
||||
<CodexExplorationRows tools={props.block.tools} onSelect={setSelectedToolId} />
|
||||
) : props.block.tools.map((tool) => {
|
||||
const timing = getToolTimingDetails(tool.tool, now)
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
@@ -363,6 +410,11 @@ export function ToolGroupCard(props: {
|
||||
</span>
|
||||
<RowLabel block={tool} metadata={props.metadata} />
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{timing.durationMs != null ? (
|
||||
<span className="font-mono text-xs text-[var(--app-hint)]">
|
||||
{formatDuration(timing.durationMs)}
|
||||
</span>
|
||||
) : null}
|
||||
<RowStatusBadge block={tool} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -33,7 +33,7 @@ function makeBlock(tool: Partial<ChatToolCall>): ToolCallBlock {
|
||||
}
|
||||
}
|
||||
|
||||
describe('ToolDetailDialogContent — duration row', () => {
|
||||
describe('ToolDetailDialogContent — execution timing', () => {
|
||||
it('shows a Duration row for a completed tool', () => {
|
||||
renderWithI18n(<ToolDetailDialogContent block={makeBlock({ state: 'completed', startedAt: 1000, completedAt: 3500 })} metadata={null} />)
|
||||
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(<ToolDetailDialogContent block={makeBlock({ state: 'running', startedAt: 1000, completedAt: null })} metadata={null} />)
|
||||
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(<ToolDetailDialogContent block={makeBlock({
|
||||
state: 'completed',
|
||||
startedAt: 1_700_000_000_000,
|
||||
completedAt: 1_700_000_002_500,
|
||||
})} metadata={null} />)
|
||||
expect(screen.getByText('Started')).toBeTruthy()
|
||||
expect(screen.getByText('Finished')).toBeTruthy()
|
||||
expect(screen.getByText('Duration')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider>
|
||||
<ToolTimingSummary startedAt={1_700_000_000_000} completedAt={1_700_000_002_500} durationMs={2_500} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
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(
|
||||
<I18nProvider>
|
||||
<ToolTimingSummary startedAt={1_700_000_000_000} completedAt={null} durationMs={2_500} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
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(
|
||||
<I18nProvider>
|
||||
<ToolTimingSummary startedAt={1_700_000_000_000} completedAt={null} durationMs={2_500} typography="group" />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '搜索',
|
||||
|
||||
Reference in New Issue
Block a user