diff --git a/desktop/src/components/trace/TraceDetail.tsx b/desktop/src/components/trace/TraceDetail.tsx
index 65277a6b..bf2af3b0 100644
--- a/desktop/src/components/trace/TraceDetail.tsx
+++ b/desktop/src/components/trace/TraceDetail.tsx
@@ -78,8 +78,11 @@ function HeaderChips({ span }: { span: TraceSpan }) {
<>
{call.model ? : null}
{call.provider?.name ? : null}
- {call.durationMs !== undefined ? (
-
+ {span.durationMs !== undefined ? (
+
) : null}
{call.usage ? (
) : null}
+ {span.completedAt ? : null}
>
)
}
@@ -107,13 +111,23 @@ function HeaderChips({ span }: { span: TraceSpan }) {
<>
{span.toolUseId ? : null}
{span.durationMs !== undefined ? (
-
+
) : null}
+ {span.completedAt ? : null}
>
)
}
+function durationLabelForSpan(span: TraceSpan, t: ReturnType): 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): string {
const parts = [
`in ${formatTokenCount(usage.inputTokens)}`,
diff --git a/desktop/src/components/trace/detail/SessionOverview.tsx b/desktop/src/components/trace/detail/SessionOverview.tsx
index 4724bf1f..26e9fc65 100644
--- a/desktop/src/components/trace/detail/SessionOverview.tsx
+++ b/desktop/src/components/trace/detail/SessionOverview.tsx
@@ -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({
0 ? 'danger' : 'default'} />
-
+
+
+
()
- 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
}
diff --git a/desktop/src/components/trace/detail/ToolDetail.tsx b/desktop/src/components/trace/detail/ToolDetail.tsx
index 50f4871b..e74c1741 100644
--- a/desktop/src/components/trace/detail/ToolDetail.tsx
+++ b/desktop/src/components/trace/detail/ToolDetail.tsx
@@ -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')}
/>
+ {span.completedAt ? (
+
+ ) : null}
+ {span.durationMs !== undefined ? (
+
+ ) : null}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 0122b022..2fa83257 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -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',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index b0781967..06e486b2 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -1677,6 +1677,11 @@ export const jp: Record = {
'trace.apiCalls': 'API 呼び出し',
'trace.failedCalls': '失敗',
'trace.duration': '所要時間',
+ 'trace.elapsed': '経過時間',
+ 'trace.completed': '完了',
+ 'trace.wallTime': '実時間',
+ 'trace.modelTime': 'モデル時間',
+ 'trace.toolTime': 'ツール時間',
'trace.models': 'モデル',
'trace.request': 'リクエスト',
'trace.response': 'レスポンス',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 0561056d..fec144f4 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -1677,6 +1677,11 @@ export const kr: Record = {
'trace.apiCalls': 'API 호출',
'trace.failedCalls': '실패',
'trace.duration': '소요 시간',
+ 'trace.elapsed': '경과 시간',
+ 'trace.completed': '완료',
+ 'trace.wallTime': '실제 시간',
+ 'trace.modelTime': '모델 시간',
+ 'trace.toolTime': '도구 시간',
'trace.models': '모델',
'trace.request': '요청',
'trace.response': '응답',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index 9e9ab57d..524a6da6 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -1677,6 +1677,11 @@ export const zh: Record = {
'trace.apiCalls': 'API 呼叫',
'trace.failedCalls': '失敗',
'trace.duration': '耗時',
+ 'trace.elapsed': '已用時',
+ 'trace.completed': '完成',
+ 'trace.wallTime': '牆鐘時間',
+ 'trace.modelTime': '模型耗時',
+ 'trace.toolTime': '工具耗時',
'trace.models': '模型',
'trace.request': '請求',
'trace.response': '回應',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index bd06b081..31eaeb18 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -1677,6 +1677,11 @@ export const zh: Record = {
'trace.apiCalls': 'API 调用',
'trace.failedCalls': '失败',
'trace.duration': '耗时',
+ 'trace.elapsed': '已用时',
+ 'trace.completed': '完成',
+ 'trace.wallTime': '墙钟时间',
+ 'trace.modelTime': '模型耗时',
+ 'trace.toolTime': '工具耗时',
'trace.models': '模型',
'trace.request': '请求',
'trace.response': '响应',
diff --git a/desktop/src/lib/traceViewModel.test.ts b/desktop/src/lib/traceViewModel.test.ts
index f8d2eace..5693cdcc 100644
--- a/desktop/src/lib/traceViewModel.test.ts
+++ b/desktop/src/lib/traceViewModel.test.ts
@@ -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)
diff --git a/desktop/src/lib/traceViewModel.ts b/desktop/src/lib/traceViewModel.ts
index d76250f6..162c9c8c 100644
--- a/desktop/src/lib/traceViewModel.ts
+++ b/desktop/src/lib/traceViewModel.ts
@@ -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 & { childIds: string[] }
+type ToolResultOccurrence = {
+ message: MessageEntry
+ block: ToolResultBlock
+}
export function buildTraceViewModel(
trace: TraceSessionData,
messages: MessageEntry[],
+ options: TraceViewModelOptions = {},
): TraceViewModel {
const spans = new Map()
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()
- const resultBlocksByToolUseId = new Map()
+ const resultBlocksByToolUseId = new Map()
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 {
+ 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
}
diff --git a/desktop/src/pages/TraceList.tsx b/desktop/src/pages/TraceList.tsx
index 2df2347d..f8653164 100644
--- a/desktop/src/pages/TraceList.tsx
+++ b/desktop/src/pages/TraceList.tsx
@@ -310,7 +310,7 @@ function TraceRow({
-
+
diff --git a/desktop/src/pages/TraceSession.test.tsx b/desktop/src/pages/TraceSession.test.tsx
index fce481d1..664505ce 100644
--- a/desktop/src/pages/TraceSession.test.tsx
+++ b/desktop/src/pages/TraceSession.test.tsx
@@ -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()
diff --git a/desktop/src/pages/TraceSession.tsx b/desktop/src/pages/TraceSession.tsx
index 68de54e2..a597b277 100644
--- a/desktop/src/pages/TraceSession.tsx
+++ b/desktop/src/pages/TraceSession.tsx
@@ -52,6 +52,7 @@ export function TraceSession({
const [refreshNonce, setRefreshNonce] = useState(0)
const [lastLoadedAt, setLastLoadedAt] = useState(null)
const [refreshing, setRefreshing] = useState(false)
+ const [clockNowMs, setClockNowMs] = useState(() => Date.now())
const snapshotSignatureRef = useRef(null)
const lastSpanIdRef = useRef(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 ? (
) : null}
-
+