fix(trace): show tool call durations (#799)

Compute tool spans from paired tool results, surface session wall/model/tool timing, and keep pending spans updated with elapsed time.

Tested: cd desktop && bun run test -- --run src/lib/traceViewModel.test.ts src/pages/TraceSession.test.tsx
Tested: cd desktop && bun run test -- --run src/pages/TraceList.test.tsx
Tested: bun run check:desktop
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 10:50:42 +08:00
parent 58d5221ca1
commit 00b4ead0ca
13 changed files with 280 additions and 27 deletions

View File

@ -78,8 +78,11 @@ function HeaderChips({ span }: { span: TraceSpan }) {
<>
{call.model ? <MetaChip label={t('trace.model')} value={call.model} /> : null}
{call.provider?.name ? <MetaChip label={t('trace.provider')} value={call.provider.name} /> : null}
{call.durationMs !== undefined ? (
<MetaChip label={t('trace.duration')} value={formatDurationMs(call.durationMs)} />
{span.durationMs !== undefined ? (
<MetaChip
label={span.status === 'pending' ? t('trace.elapsed') : t('trace.duration')}
value={formatDurationMs(span.durationMs)}
/>
) : null}
{call.usage ? (
<MetaChip
@ -100,6 +103,7 @@ function HeaderChips({ span }: { span: TraceSpan }) {
</>
) : null}
<MetaChip label={t('trace.started')} value={formatClockTime(call.startedAt)} />
{span.completedAt ? <MetaChip label={t('trace.completed')} value={formatClockTime(span.completedAt)} /> : null}
</>
)
}
@ -107,13 +111,23 @@ function HeaderChips({ span }: { span: TraceSpan }) {
<>
{span.toolUseId ? <MetaChip label={t('trace.detail.toolUseId')} value={span.toolUseId} /> : null}
{span.durationMs !== undefined ? (
<MetaChip label={t('trace.duration')} value={formatDurationMs(span.durationMs)} />
<MetaChip
label={durationLabelForSpan(span, t)}
value={formatDurationMs(span.durationMs)}
/>
) : null}
<MetaChip label={t('trace.started')} value={formatClockTime(span.timestamp)} />
{span.completedAt ? <MetaChip label={t('trace.completed')} value={formatClockTime(span.completedAt)} /> : null}
</>
)
}
function durationLabelForSpan(span: TraceSpan, t: ReturnType<typeof useTranslation>): string {
if (span.status === 'pending') return t('trace.elapsed')
if (span.kind === 'session' || span.kind === 'turn') return t('trace.wallTime')
return t('trace.duration')
}
function usageTooltip(usage: NonNullable<TraceSpan['tokenUsage']>): string {
const parts = [
`in ${formatTokenCount(usage.inputTokens)}`,

View File

@ -8,7 +8,9 @@ type OverviewStats = {
llmCalls: number
toolCalls: number
errors: number
durationMs?: number
wallDurationMs?: number
modelDurationMs?: number
toolDurationMs?: number
inputTokens: number
outputTokens: number
models: string[]
@ -35,7 +37,9 @@ export function SessionOverview({
<Stat label={t('trace.llmCalls')} value={String(stats.llmCalls)} />
<Stat label={t('trace.toolCalls')} value={String(stats.toolCalls)} />
<Stat label={t('trace.errors')} value={String(stats.errors)} tone={stats.errors > 0 ? 'danger' : 'default'} />
<Stat label={t('trace.duration')} value={formatDurationMs(stats.durationMs)} />
<Stat label={t('trace.wallTime')} value={formatDurationMs(stats.wallDurationMs)} />
<Stat label={t('trace.modelTime')} value={formatDurationMs(stats.modelDurationMs)} />
<Stat label={t('trace.toolTime')} value={formatDurationMs(stats.toolDurationMs)} />
<Stat
label={t('trace.tokens')}
value={`${formatTokenCount(stats.inputTokens)}${formatTokenCount(stats.outputTokens)}`}
@ -99,23 +103,28 @@ function computeStats(span: TraceSpan, viewModel: TraceViewModel): OverviewStats
models: [],
}
const models = new Set<string>()
let llmDuration = 0
let modelDurationMs = 0
let toolDurationMs = 0
for (const item of scoped) {
if (item.kind === 'llm') {
stats.llmCalls += 1
if (item.call?.model) models.add(item.call.model)
if (item.durationMs !== undefined) llmDuration += item.durationMs
if (item.durationMs !== undefined) modelDurationMs += item.durationMs
if (item.tokenUsage) {
stats.inputTokens += item.tokenUsage.inputTokens
stats.outputTokens += item.tokenUsage.outputTokens
}
}
if (item.kind === 'tool') stats.toolCalls += 1
if (item.kind === 'tool') {
stats.toolCalls += 1
if (item.durationMs !== undefined) toolDurationMs += item.durationMs
}
if (item.status === 'error') stats.errors += 1
}
stats.models = [...models]
const durationMs = span.kind === 'session' ? span.durationMs ?? llmDuration : llmDuration
if (durationMs > 0) stats.durationMs = durationMs
if (span.durationMs !== undefined && span.durationMs > 0) stats.wallDurationMs = span.durationMs
if (modelDurationMs > 0) stats.modelDurationMs = modelDurationMs
if (toolDurationMs > 0) stats.toolDurationMs = toolDurationMs
return stats
}

View File

@ -1,7 +1,7 @@
import { useTranslation } from '../../../i18n'
import type { TraceSpan } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/traceViewModel'
import { formatClockTime } from '../../../lib/trace/formatters'
import { formatClockTime, formatDurationMs } from '../../../lib/trace/formatters'
import { CodeViewer } from '../../chat/CodeViewer'
import { Section } from './Section'
@ -44,6 +44,15 @@ export function ToolDetail({ span }: { span: TraceSpan }) {
value={span.status === 'error' ? t('trace.status.error') : span.status === 'pending' ? t('trace.status.pending') : t('trace.status.ok')}
/>
<MetaRow label={t('trace.started')} value={formatClockTime(span.timestamp)} />
{span.completedAt ? (
<MetaRow label={t('trace.completed')} value={formatClockTime(span.completedAt)} />
) : null}
{span.durationMs !== undefined ? (
<MetaRow
label={span.status === 'pending' ? t('trace.elapsed') : t('trace.duration')}
value={formatDurationMs(span.durationMs)}
/>
) : null}
</dl>
</Section>
</div>

View File

@ -1675,6 +1675,11 @@ export const en = {
'trace.apiCalls': 'API calls',
'trace.failedCalls': 'Failed',
'trace.duration': 'Duration',
'trace.elapsed': 'Elapsed',
'trace.completed': 'Completed',
'trace.wallTime': 'Wall time',
'trace.modelTime': 'Model time',
'trace.toolTime': 'Tool time',
'trace.models': 'Models',
'trace.request': 'Request',
'trace.response': 'Response',

View File

@ -1677,6 +1677,11 @@ export const jp: Record<TranslationKey, string> = {
'trace.apiCalls': 'API 呼び出し',
'trace.failedCalls': '失敗',
'trace.duration': '所要時間',
'trace.elapsed': '経過時間',
'trace.completed': '完了',
'trace.wallTime': '実時間',
'trace.modelTime': 'モデル時間',
'trace.toolTime': 'ツール時間',
'trace.models': 'モデル',
'trace.request': 'リクエスト',
'trace.response': 'レスポンス',

View File

@ -1677,6 +1677,11 @@ export const kr: Record<TranslationKey, string> = {
'trace.apiCalls': 'API 호출',
'trace.failedCalls': '실패',
'trace.duration': '소요 시간',
'trace.elapsed': '경과 시간',
'trace.completed': '완료',
'trace.wallTime': '실제 시간',
'trace.modelTime': '모델 시간',
'trace.toolTime': '도구 시간',
'trace.models': '모델',
'trace.request': '요청',
'trace.response': '응답',

View File

@ -1677,6 +1677,11 @@ export const zh: Record<TranslationKey, string> = {
'trace.apiCalls': 'API 呼叫',
'trace.failedCalls': '失敗',
'trace.duration': '耗時',
'trace.elapsed': '已用時',
'trace.completed': '完成',
'trace.wallTime': '牆鐘時間',
'trace.modelTime': '模型耗時',
'trace.toolTime': '工具耗時',
'trace.models': '模型',
'trace.request': '請求',
'trace.response': '回應',

View File

@ -1677,6 +1677,11 @@ export const zh: Record<TranslationKey, string> = {
'trace.apiCalls': 'API 调用',
'trace.failedCalls': '失败',
'trace.duration': '耗时',
'trace.elapsed': '已用时',
'trace.completed': '完成',
'trace.wallTime': '墙钟时间',
'trace.modelTime': '模型耗时',
'trace.toolTime': '工具耗时',
'trace.models': '模型',
'trace.request': '请求',
'trace.response': '响应',

View File

@ -121,6 +121,8 @@ describe('traceViewModel', () => {
status: 'ok',
title: 'Bash',
subtitle: 'ls -la /tmp',
completedAt: '2026-06-09T10:00:03.000Z',
durationMs: 1000,
})
expect(tool?.childIds[0]).toMatch(/^tool_result:/)
expect(viewModel.spansById.get(tool?.childIds[0] ?? '')).toMatchObject({
@ -136,6 +138,41 @@ describe('traceViewModel', () => {
expect(viewModel.spansById.get('llm:call-1')?.childIds).toContain('event:event-1')
})
it('calculates wall-clock timing for turns and the session from span end times', () => {
const viewModel = buildTraceViewModel(trace, messages)
expect(viewModel.spansById.get('turn:0')).toMatchObject({
completedAt: '2026-06-09T10:00:04.700Z',
durationMs: 4700,
})
expect(viewModel.spansById.get(viewModel.rootId)).toMatchObject({
completedAt: '2026-06-09T10:00:04.700Z',
durationMs: 4700,
})
expect(viewModel.diagnosis.lastActivityAt).toBe('2026-06-09T10:00:04.700Z')
})
it('shows elapsed time for pending tools without marking them completed', () => {
const viewModel = buildTraceViewModel(trace, messages.slice(0, 2), {
now: '2026-06-09T10:00:07.000Z',
})
expect(viewModel.spansById.get('tool:tool-1')).toMatchObject({
status: 'pending',
durationMs: 5000,
})
expect(viewModel.spansById.get('tool:tool-1')?.completedAt).toBeUndefined()
expect(viewModel.spansById.get('turn:0')).toMatchObject({
status: 'pending',
durationMs: 7000,
})
expect(viewModel.spansById.get(viewModel.rootId)).toMatchObject({
status: 'pending',
durationMs: 7000,
})
expect(viewModel.diagnosis.lastActivityAt).toBe('2026-06-09T10:00:07.000Z')
})
it('passes call usage through to llm spans as tokenUsage', () => {
const viewModel = buildTraceViewModel(trace, messages)

View File

@ -70,6 +70,10 @@ export type TraceViewModel = {
diagnosis: TraceDiagnosis
}
export type TraceViewModelOptions = {
now?: string | number | Date
}
type ToolUseBlock = {
type: 'tool_use'
id?: string
@ -90,15 +94,21 @@ type TextBlock = {
}
type MutableSpan = Omit<TraceSpan, 'childIds'> & { childIds: string[] }
type ToolResultOccurrence = {
message: MessageEntry
block: ToolResultBlock
}
export function buildTraceViewModel(
trace: TraceSessionData,
messages: MessageEntry[],
options: TraceViewModelOptions = {},
): TraceViewModel {
const spans = new Map<string, MutableSpan>()
const turns: TraceTurn[] = []
const rootId = 'session:root'
const traceEvents = trace.events ?? []
const nowTimestamp = normalizeNowTimestamp(options.now)
const fallbackTimestamp = earliestTimestamp(trace.calls, messages, traceEvents) ?? new Date().toISOString()
const hasPendingCalls = trace.calls.some((call) => getCallStatus(call) === 'pending')
const hasTraceErrors = trace.summary.failedCalls > 0 || traceEvents.some((event) => event.severity === 'error')
@ -112,7 +122,6 @@ export function buildTraceViewModel(
subtitle: `${trace.summary.apiCalls} model calls`,
timestamp: fallbackTimestamp,
completedAt: trace.summary.updatedAt ?? undefined,
durationMs: trace.summary.totalDurationMs,
raw: {
sessionId: trace.sessionId,
summary: trace.summary,
@ -145,7 +154,7 @@ export function buildTraceViewModel(
}
const toolSpanIds = new Map<string, string>()
const resultBlocksByToolUseId = new Map<string, ToolResultBlock[]>()
const resultBlocksByToolUseId = new Map<string, ToolResultOccurrence[]>()
const deferredToolResultSpans: Array<{ parentId: string; message: MessageEntry; block: ToolResultBlock }> = []
for (const message of messages) {
@ -164,7 +173,7 @@ export function buildTraceViewModel(
for (const block of resultBlocks) {
if (block.tool_use_id) {
const existing = resultBlocksByToolUseId.get(block.tool_use_id) ?? []
existing.push(block)
existing.push({ message, block })
resultBlocksByToolUseId.set(block.tool_use_id, existing)
}
const toolParentId = block.tool_use_id ? toolSpanIds.get(block.tool_use_id) : undefined
@ -197,20 +206,29 @@ export function buildTraceViewModel(
const spanId = `tool:${toolId}`
toolSpanIds.set(toolId, spanId)
const resultBlocks = block.id ? resultBlocksByToolUseId.get(block.id) ?? [] : []
const completion = latestToolResultTimestamp(message.timestamp, resultBlocks)
const status = resultBlocks.some((result) => result.block.is_error)
? 'error'
: resultBlocks.length > 0
? 'ok'
: 'pending'
addSpan(spans, {
id: spanId,
parentId,
kind: 'tool',
status: resultBlocks.some((result) => result.is_error) ? 'error' : resultBlocks.length > 0 ? 'ok' : 'pending',
status,
title: block.name ?? 'Tool call',
subtitle: summarizeToolInput(block.input),
timestamp: message.timestamp,
...spanTimingFromTimestamps(message.timestamp, completion ?? (status === 'pending' ? nowTimestamp : undefined), {
completed: Boolean(completion),
}),
turnIndex: turn.index,
message,
toolUseId: toolId,
toolName: block.name,
input: block.input,
output: resultBlocks.map((result) => result.content),
output: resultBlocks.map((result) => result.block.content),
isSidechain: message.isSidechain,
raw: block,
})
@ -229,16 +247,24 @@ export function buildTraceViewModel(
for (const call of trace.calls) {
const turn = findTurnForTimestamp(turns, call.startedAt)
const spanId = `llm:${call.id}`
const status = getCallStatus(call)
const derivedCallTiming = call.durationMs !== undefined
? {
...(call.completedAt ? { completedAt: call.completedAt } : {}),
durationMs: call.durationMs,
}
: spanTimingFromTimestamps(call.startedAt, call.completedAt ?? (status === 'pending' ? nowTimestamp : undefined), {
completed: Boolean(call.completedAt),
})
addSpan(spans, {
id: spanId,
parentId: turn.id,
kind: 'llm',
status: getCallStatus(call),
status,
title: call.model ?? call.provider?.name ?? 'Model call',
subtitle: call.provider?.name ?? call.source,
timestamp: call.startedAt,
completedAt: call.completedAt,
durationMs: call.durationMs,
...derivedCallTiming,
turnIndex: turn.index,
call,
tokenUsage: call.usage,
@ -280,9 +306,23 @@ export function buildTraceViewModel(
? 'pending'
: 'ok'
turnSpan.subtitle = `${turn.spanIds.length} spans`
applyAggregateTiming(turnSpan, turn.spanIds.map((spanId) => spans.get(spanId)).filter(Boolean) as MutableSpan[])
}
}
const rootSpan = spans.get(rootId)
if (rootSpan) {
const childStatuses = rootSpan.childIds
.map((spanId) => spans.get(spanId)?.status)
.filter(Boolean)
rootSpan.status = childStatuses.includes('error')
? 'error'
: childStatuses.includes('pending')
? 'pending'
: rootSpan.status
applyAggregateTiming(rootSpan, rootSpan.childIds.map((spanId) => spans.get(spanId)).filter(Boolean) as MutableSpan[])
}
const orderedSpanIds = orderSpans(spans, rootId)
const spanList = orderedSpanIds.map((id) => spans.get(id)).filter(Boolean) as TraceSpan[]
return {
@ -310,7 +350,7 @@ function buildDiagnosis(
const eventErrors = errorSpans.filter((span) => span.kind === 'event')
const lastSpan = meaningfulSpans
.slice()
.sort((a, b) => compareSpanTime(a, b))
.sort((a, b) => compareSpanActivityTime(a, b))
.at(-1)
const lastTurn = turns.at(-1)
const lastTurnSpans = lastTurn
@ -374,14 +414,15 @@ function createDiagnosis(
const pendingTools = spans.filter((span) => span.kind === 'tool' && span.status === 'pending')
const lastActivityAt = spans
.slice()
.sort((a, b) => compareSpanTime(a, b))
.at(-1)?.timestamp ?? fallbackTimestamp
.sort((a, b) => compareSpanActivityTime(a, b))
.at(-1)
const lastActivityTimestamp = lastActivityAt ? spanActivityTimestamp(lastActivityAt) : undefined
return {
status,
reason,
focusSpanId: evidence[0]?.id,
evidenceSpanIds: evidence.map((span) => span.id),
lastActivityAt,
lastActivityAt: lastActivityTimestamp ?? fallbackTimestamp,
errorCount: errors.length,
pendingModelCalls: pendingModels.length,
pendingToolCalls: pendingTools.length,
@ -473,6 +514,70 @@ function addToolResultSpan(
parent.status = block.is_error ? 'error' : 'ok'
const existingOutput = Array.isArray(parent.output) ? parent.output : parent.output === undefined ? [] : [parent.output]
parent.output = [...existingOutput, block.content]
applyToolCompletion(parent, message.timestamp)
}
}
function latestToolResultTimestamp(startedAt: string, results: ToolResultOccurrence[]): string | undefined {
let latest: { value: string; time: number } | undefined
const startTime = new Date(startedAt).getTime()
if (!Number.isFinite(startTime)) return undefined
for (const result of results) {
const time = new Date(result.message.timestamp).getTime()
if (!Number.isFinite(time) || time < startTime) continue
if (!latest || time > latest.time) {
latest = { value: result.message.timestamp, time }
}
}
return latest?.value
}
function spanTimingFromTimestamps(
startedAt: string,
endedAt?: string,
options: { completed?: boolean } = {},
): Pick<TraceSpan, 'completedAt' | 'durationMs'> {
if (!endedAt) return {}
const startTime = new Date(startedAt).getTime()
const endTime = new Date(endedAt).getTime()
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) return {}
return {
...(options.completed ? { completedAt: endedAt } : {}),
durationMs: endTime - startTime,
}
}
function applyToolCompletion(parent: MutableSpan, completedAt: string) {
const timing = spanTimingFromTimestamps(parent.timestamp, completedAt, { completed: true })
if (!timing.completedAt || timing.durationMs === undefined) return
const currentCompletedTime = parent.completedAt ? new Date(parent.completedAt).getTime() : Number.NEGATIVE_INFINITY
const nextCompletedTime = new Date(timing.completedAt).getTime()
if (Number.isFinite(currentCompletedTime) && currentCompletedTime > nextCompletedTime) return
parent.completedAt = timing.completedAt
parent.durationMs = timing.durationMs
}
function applyAggregateTiming(parent: MutableSpan, children: MutableSpan[]) {
const startTime = new Date(parent.timestamp).getTime()
if (!Number.isFinite(startTime)) return
let endTime = startTime
let completedAt: string | undefined
for (const child of children) {
const activity = spanActivity(child)
if (!activity) continue
if (activity.time >= endTime) {
endTime = activity.time
completedAt = activity.timestamp
}
}
if (endTime < startTime) return
parent.durationMs = endTime - startTime
if (parent.status !== 'pending' && completedAt) {
parent.completedAt = completedAt
} else if (parent.status === 'pending') {
delete parent.completedAt
}
}
@ -507,6 +612,34 @@ function compareSpanTime(a?: MutableSpan, b?: MutableSpan): number {
return a.id.localeCompare(b.id)
}
function compareSpanActivityTime(a?: TraceSpan, b?: TraceSpan): number {
if (!a || !b) return 0
const aActivity = spanActivity(a)
const bActivity = spanActivity(b)
if (aActivity && bActivity && aActivity.time !== bActivity.time) return aActivity.time - bActivity.time
if (aActivity && !bActivity) return 1
if (!aActivity && bActivity) return -1
return compareSpanTime(a as MutableSpan, b as MutableSpan)
}
function spanActivityTimestamp(span: TraceSpan): string | undefined {
return spanActivity(span)?.timestamp
}
function spanActivity(span: TraceSpan): { timestamp: string; time: number } | undefined {
const startTime = new Date(span.timestamp).getTime()
if (!Number.isFinite(startTime)) return undefined
if (span.completedAt) {
const completedTime = new Date(span.completedAt).getTime()
if (Number.isFinite(completedTime)) return { timestamp: span.completedAt, time: completedTime }
}
if (span.durationMs !== undefined && Number.isFinite(span.durationMs) && span.durationMs >= 0) {
const endTime = startTime + span.durationMs
return { timestamp: new Date(endTime).toISOString(), time: endTime }
}
return { timestamp: span.timestamp, time: startTime }
}
function createTurn(index: number, timestamp: string, title: string): TraceTurn {
return {
id: `turn:${index}`,
@ -551,6 +684,12 @@ function earliestTimestamp(
return timestamps[0]?.value ?? null
}
function normalizeNowTimestamp(value: TraceViewModelOptions['now']): string | undefined {
if (value === undefined) return new Date().toISOString()
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
function hasToolResultBlocks(content: unknown): boolean {
return extractToolResultBlocks(content).length > 0
}

View File

@ -310,7 +310,7 @@ function TraceRow({
</div>
<div className="grid shrink-0 grid-cols-[3.5rem_4rem_4rem] items-center gap-3">
<MetricCell label={t('trace.apiCalls')} value={String(trace.summary.apiCalls)} />
<MetricCell label={t('trace.duration')} value={formatDuration(trace.summary.totalDurationMs)} />
<MetricCell label={t('trace.modelTime')} value={formatDuration(trace.summary.totalDurationMs)} />
<MetricCell label={t('trace.tokens')} value={formatCompact(totalTokens)} />
</div>
</button>

View File

@ -200,6 +200,12 @@ describe('TraceSession', () => {
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByTestId('trace-overview')).toBeInTheDocument()
expect(detail.getByText('LLM calls')).toBeInTheDocument()
expect(detail.getAllByText('Wall time').length).toBeGreaterThan(0)
expect(detail.getAllByText('6.00s').length).toBeGreaterThan(0)
expect(detail.getAllByText('Model time').length).toBeGreaterThan(0)
expect(detail.getAllByText('2.00s').length).toBeGreaterThan(0)
expect(detail.getByText('Tool time')).toBeInTheDocument()
expect(detail.getAllByText('1.00s').length).toBeGreaterThan(0)
// Timeline rows for messages, the model call, and the tool call.
const tree = within(screen.getByTestId('trace-tree'))
@ -238,6 +244,9 @@ describe('TraceSession', () => {
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByRole('heading', { level: 2, name: 'Bash' })).toBeInTheDocument()
expect(detail.getByText('Duration')).toBeInTheDocument()
expect(detail.getByText('1.00s')).toBeInTheDocument()
expect(detail.getAllByText('Completed').length).toBeGreaterThan(0)
expect(detail.getByTestId('trace-tool-detail')).toBeInTheDocument()
expect(detail.getByText('Input')).toBeInTheDocument()
expect(detail.getByText('Result')).toBeInTheDocument()

View File

@ -52,6 +52,7 @@ export function TraceSession({
const [refreshNonce, setRefreshNonce] = useState(0)
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [clockNowMs, setClockNowMs] = useState(() => Date.now())
const snapshotSignatureRef = useRef<string | null>(null)
const lastSpanIdRef = useRef<string | null>(null)
@ -75,6 +76,7 @@ export function TraceSession({
if (silent && snapshotSignatureRef.current === signature) return
snapshotSignatureRef.current = signature
setState({ status: 'ready', trace, messages: messageResponse.messages })
setClockNowMs(Date.now())
setLastLoadedAt(new Date().toISOString())
} catch (error) {
if (cancelled) return
@ -112,10 +114,19 @@ export function TraceSession({
const readyState = state.status === 'ready' ? state : null
const viewModel = useMemo(
() => readyState ? buildTraceViewModel(readyState.trace, readyState.messages) : null,
[readyState],
() => readyState
? buildTraceViewModel(readyState.trace, readyState.messages, { now: new Date(clockNowMs).toISOString() })
: null,
[readyState, clockNowMs],
)
useEffect(() => {
if (!viewModel) return
if (viewModel.diagnosis.pendingModelCalls === 0 && viewModel.diagnosis.pendingToolCalls === 0) return
const timer = window.setInterval(() => setClockNowMs(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [viewModel?.diagnosis.pendingModelCalls, viewModel?.diagnosis.pendingToolCalls])
useEffect(() => {
if (!viewModel) return
const lastSpanId = viewModel.orderedSpanIds.at(-1) ?? null
@ -284,7 +295,7 @@ function TraceHeader({
{summary.failedCalls > 0 ? (
<MetaChip label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone="danger" />
) : null}
<MetaChip label={t('trace.duration')} value={formatDurationMs(summary.totalDurationMs)} />
<MetaChip label={t('trace.modelTime')} value={formatDurationMs(summary.totalDurationMs)} />
<MetaChip
label={t('trace.tokens')}
value={formatTokenCount(summary.totalInputTokens + summary.totalOutputTokens)}