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} - +