From 5e634bb723885680c9e6c3aceac17dc2fb7cd191 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Fri, 26 Jun 2026 13:19:10 +0200 Subject: [PATCH 1/2] feat(dashboard): polish transcript timeline --- .../src/components/TranscriptTimeline.tsx | 472 +++++++++++++++--- .../components/transcript-timeline.test.tsx | 60 ++- 2 files changed, 436 insertions(+), 96 deletions(-) diff --git a/apps/dashboard/src/components/TranscriptTimeline.tsx b/apps/dashboard/src/components/TranscriptTimeline.tsx index 79928bff6..cfc4eccc6 100644 --- a/apps/dashboard/src/components/TranscriptTimeline.tsx +++ b/apps/dashboard/src/components/TranscriptTimeline.tsx @@ -7,7 +7,7 @@ * artifact links supplied by the caller. */ -import type { ReactNode } from 'react'; +import { type ReactNode, type SyntheticEvent, useEffect, useMemo, useState } from 'react'; import type { FileNode } from '~/lib/types'; @@ -101,6 +101,29 @@ const ROLE_STYLES: Record< }, }; +interface ToolCallViewModel { + id: string; + call: Record; + index: number; + name: string; + status?: string; + duration?: string; +} + +interface TranscriptMessageViewModel { + id: string; + anchorId: string; + line: TranscriptJsonLine; + ordinal: number; + roleStyle: { container: string; badge: string; label: string; accent: string }; + content: string; + duration?: string; + tokenUsage?: string; + toolCalls: ToolCallViewModel[]; +} + +type TranscriptFilter = 'all' | 'messages' | 'with-tools' | 'tool-results'; + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -350,6 +373,83 @@ function toolCallOutput(call: Record): unknown { return pickPayload(call, ['output', 'result', 'content']); } +function transcriptMessageId(line: TranscriptJsonLine, ordinal: number): string { + return `${line.message_index}-${line.role}-${ordinal}`; +} + +function buildTranscriptViewModel( + entries: readonly TranscriptJsonLine[], +): TranscriptMessageViewModel[] { + return [...entries] + .sort((first, second) => first.message_index - second.message_index) + .map((line, ordinal) => { + const roleStyle = ROLE_STYLES[line.role] ?? { + container: 'border-gray-800 bg-gray-900', + badge: 'border-gray-700 bg-gray-800 text-gray-300', + label: line.role, + accent: 'text-gray-300', + }; + const id = transcriptMessageId(line, ordinal); + const toolCalls = ( + Array.isArray(line.tool_calls) ? line.tool_calls.filter(isRecord) : [] + ).map((call, index) => { + const callId = pickString(call, ['id', 'call_id', 'tool_call_id']); + return { + id: `${id}-tool-${callId ?? index}`, + call, + index, + name: toolCallName(call, index), + status: pickString(call, ['status']), + duration: + typeof call.duration_ms === 'number' ? formatDurationMs(call.duration_ms) : undefined, + }; + }); + + return { + id, + anchorId: `message-${ordinal + 1}`, + line, + ordinal, + roleStyle, + content: formatContent(line.content), + duration: formatDurationMs(line.duration_ms), + tokenUsage: formatTokenUsage(line.token_usage), + toolCalls, + }; + }); +} + +function defaultExpandedMessageIds(messages: readonly TranscriptMessageViewModel[]): Set { + const ids = new Set(); + if (messages[0]) ids.add(messages[0].id); + const finalMessage = messages[messages.length - 1]; + if (finalMessage) ids.add(finalMessage.id); + return ids; +} + +function summarizeRoleCounts(messages: readonly TranscriptMessageViewModel[]): Map { + const counts = new Map(); + for (const message of messages) { + counts.set(message.line.role, (counts.get(message.line.role) ?? 0) + 1); + } + return counts; +} + +function summarizeToolCounts(messages: readonly TranscriptMessageViewModel[]): { + total: number; + byName: Map; +} { + const byName = new Map(); + let total = 0; + for (const message of messages) { + for (const call of message.toolCalls) { + total += 1; + byName.set(call.name, (byName.get(call.name) ?? 0) + 1); + } + } + return { total, byName }; +} + function hasValue(value: unknown): boolean { return ( value !== undefined && value !== null && !(typeof value === 'string' && value.length === 0) @@ -424,23 +524,39 @@ function OpenFileButton({ ); } -function ToolCallDetails({ call, index }: { call: Record; index: number }) { - const name = toolCallName(call, index); +function ToolCallDetails({ + toolCall, + expanded, + onToggle, +}: { + toolCall: ToolCallViewModel; + expanded: boolean; + onToggle: (toolCallId: string, expanded: boolean) => void; +}) { + const { call, index, name, status, duration } = toolCall; const callId = pickString(call, ['id', 'call_id', 'tool_call_id']); - const status = pickString(call, ['status']); - const duration = - typeof call.duration_ms === 'number' ? formatDurationMs(call.duration_ms) : undefined; const metadata = isRecord(call.metadata) ? call.metadata : undefined; return ( -
- - Tool call - - {name} +
) => + onToggle(toolCall.id, event.currentTarget.open) + } + > + + + {expanded ? '-' : '+'} + Tool call + + {name} + + {status && {status}} + {duration && {duration}} - {status && {status}} - {duration && {duration}}
@@ -479,38 +595,68 @@ function ToolResultDetails({ line }: { line: TranscriptJsonLine }) { ); } -function TranscriptMessageCard({ line, ordinal }: { line: TranscriptJsonLine; ordinal: number }) { - const roleStyle = ROLE_STYLES[line.role] ?? { - container: 'border-gray-800 bg-gray-900', - badge: 'border-gray-700 bg-gray-800 text-gray-300', - label: line.role, - accent: 'text-gray-300', - }; - const content = formatContent(line.content); - const duration = formatDurationMs(line.duration_ms); - const tokenUsage = formatTokenUsage(line.token_usage); - const toolCalls = Array.isArray(line.tool_calls) ? line.tool_calls.filter(isRecord) : []; +function TranscriptMessageCard({ + message, + expanded, + expandedToolIds, + onToggleMessage, + onToggleTool, +}: { + message: TranscriptMessageViewModel; + expanded: boolean; + expandedToolIds: ReadonlySet; + onToggleMessage: (messageId: string, expanded: boolean) => void; + onToggleTool: (toolCallId: string, expanded: boolean) => void; +}) { + const { line, ordinal, roleStyle, content, duration, tokenUsage, toolCalls } = message; return ( -
-
-
- - {roleStyle.label} - - {line.name && ( - {line.name} - )} -
-
- #{ordinal + 1} - {line.start_time && {line.start_time}} - {duration && {duration}} - {tokenUsage && {tokenUsage}} +
) => + onToggleMessage(message.id, event.currentTarget.open) + } + > + +
+
+ {expanded ? '-' : '+'} + + {roleStyle.label} + + {line.name && ( + + {line.name} + + )} + {toolCalls.length > 0 && ( + + {toolCalls.length} tool {toolCalls.length === 1 ? 'call' : 'calls'} + + )} +
+
+ event.stopPropagation()} + > + #{ordinal + 1} + + {line.start_time && {line.start_time}} + {duration && {duration}} + {tokenUsage && {tokenUsage}} +
-
+
-
+
{line.role === 'tool' || line.role === 'function' ? ( ) : content.trim().length > 0 ? ( @@ -523,8 +669,13 @@ function TranscriptMessageCard({ line, ordinal }: { line: TranscriptJsonLine; or {toolCalls.length > 0 && (
- {toolCalls.map((call, index) => ( - + {toolCalls.map((call) => ( + ))}
)} @@ -540,25 +691,38 @@ function TranscriptMessageCard({ line, ordinal }: { line: TranscriptJsonLine; or
)} - + ); } function TranscriptSummary({ - entries, + messages, transcriptPath, -}: { entries: readonly TranscriptJsonLine[]; transcriptPath?: string }) { - const first = entries[0]; +}: { messages: readonly TranscriptMessageViewModel[]; transcriptPath?: string }) { + const first = messages[0]?.line; const provider = first?.source?.provider ?? first?.agent; const model = first?.source?.model ?? first?.model; const sessionId = first?.source?.session_id; const duration = formatDurationMs(first?.transcript_duration_ms); const tokenUsage = formatTokenUsage(first?.transcript_token_usage); const cost = formatCurrency(first?.transcript_cost_usd); + const roleCounts = summarizeRoleCounts(messages); + const toolCounts = summarizeToolCounts(messages); return (
- {entries.length} messages + {messages.length} messages + {Array.from(roleCounts.entries()).map(([role, count]) => ( + + {role}: {count} + + ))} + {toolCounts.total > 0 && {toolCounts.total} tool calls} + {Array.from(toolCounts.byName.entries()).map(([name, count]) => ( + + {name}: {count} + + ))} {provider && provider: {provider}} {model && model: {model}} {sessionId && session: {sessionId}} @@ -570,6 +734,53 @@ function TranscriptSummary({ ); } +function filterTranscriptMessages( + messages: readonly TranscriptMessageViewModel[], + filter: TranscriptFilter, +): TranscriptMessageViewModel[] { + switch (filter) { + case 'messages': + return messages.filter( + (message) => message.line.role !== 'tool' && message.line.role !== 'function', + ); + case 'with-tools': + return messages.filter((message) => message.toolCalls.length > 0); + case 'tool-results': + return messages.filter( + (message) => message.line.role === 'tool' || message.line.role === 'function', + ); + case 'all': + return [...messages]; + } +} + +function FilterButton({ + active, + count, + children, + onClick, +}: { + active: boolean; + count: number; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + export function TranscriptTimeline({ entries, finalAnswer, @@ -580,65 +791,164 @@ export function TranscriptTimeline({ transcriptDownloadHref, onOpenFile, }: TranscriptTimelineProps) { - const sortedEntries = [...entries].sort( - (first, second) => first.message_index - second.message_index, + const messages = useMemo(() => buildTranscriptViewModel(entries), [entries]); + const [expandedMessageIds, setExpandedMessageIds] = useState>(() => + defaultExpandedMessageIds(messages), ); + const [expandedToolIds, setExpandedToolIds] = useState>(() => new Set()); + const [filter, setFilter] = useState('all'); const hasCanonicalAnswer = !!answerPath; + const allToolIds = useMemo( + () => messages.flatMap((message) => message.toolCalls.map((toolCall) => toolCall.id)), + [messages], + ); + const visibleMessages = useMemo( + () => filterTranscriptMessages(messages, filter), + [messages, filter], + ); + const messageCount = messages.filter( + (message) => message.line.role !== 'tool' && message.line.role !== 'function', + ).length; + const toolResultCount = messages.filter( + (message) => message.line.role === 'tool' || message.line.role === 'function', + ).length; + const withToolsCount = messages.filter((message) => message.toolCalls.length > 0).length; + + useEffect(() => { + setExpandedMessageIds(defaultExpandedMessageIds(messages)); + setExpandedToolIds(new Set()); + setFilter('all'); + }, [messages]); + + function setMessageExpanded(messageId: string, expanded: boolean) { + setExpandedMessageIds((current) => { + const next = new Set(current); + if (expanded) { + next.add(messageId); + } else { + next.delete(messageId); + } + return next; + }); + } + + function setToolExpanded(toolCallId: string, expanded: boolean) { + setExpandedToolIds((current) => { + const next = new Set(current); + if (expanded) { + next.add(toolCallId); + } else { + next.delete(toolCallId); + } + return next; + }); + } return (
-
-
-
-

Final answer

-

- Highlighted from canonical outputs/answer.md; transcript context stays - below. -

-
-
- - Open answer.md in Files - - Open answer.md + {hasCanonicalAnswer && ( +
+
+
+

Final answer

+

+ Highlighted from canonical outputs/answer.md; transcript context stays + below. +

+
+
+ + Open answer.md in Files + + Open answer.md +
-
- {hasCanonicalAnswer ? (
             {finalAnswer && finalAnswer.trim().length > 0 ? finalAnswer : 'answer.md is empty.'}
           
- ) : ( -

- No canonical outputs/answer.md artifact was found. The transcript viewer - does not fall back to response.md. -

- )} -
+ + )}

Transcript timeline

- +
- Open raw JSONL in Files + Open transcript.jsonl in Files - Open raw JSONL + Open normalized JSONL - Download JSONL + Download normalized JSONL
+
+
+
+ setFilter('all')} + > + All + + setFilter('messages')} + > + Messages + + setFilter('with-tools')} + > + Has tools + + setFilter('tool-results')} + > + Tool results + +
+ {allToolIds.length > 0 && ( +
+ + +
+ )} +
+
+
- {sortedEntries.map((entry, index) => ( + {visibleMessages.map((message) => ( ))}
diff --git a/apps/dashboard/src/components/transcript-timeline.test.tsx b/apps/dashboard/src/components/transcript-timeline.test.tsx index bba7b936c..dc12a699c 100644 --- a/apps/dashboard/src/components/transcript-timeline.test.tsx +++ b/apps/dashboard/src/components/transcript-timeline.test.tsx @@ -13,6 +13,21 @@ import { } from './__fixtures__/structured-transcript'; describe('TranscriptTimeline', () => { + function renderStructuredTranscript() { + const parsed = parseTranscriptJsonl(structuredTranscriptJsonl); + return renderToStaticMarkup( + , + ); + } + it('parses canonical transcript JSONL rows in chronological order', () => { const parsed = parseTranscriptJsonl(structuredTranscriptJsonl); @@ -46,19 +61,34 @@ describe('TranscriptTimeline', () => { ); }); - it('renders final answer separately from prior assistant/tool context with raw JSONL access', () => { - const parsed = parseTranscriptJsonl(structuredTranscriptJsonl); - const html = renderToStaticMarkup( - , - ); + it('keeps the first and final chronological messages expanded by default', () => { + const html = renderStructuredTranscript(); + + expect(html).toMatch(/data-testid="message-row-1" data-expanded="true"/); + expect(html).toMatch(/data-testid="message-row-3" data-expanded="true"/); + }); + + it('keeps middle user or assistant messages collapsed by default', () => { + const html = renderStructuredTranscript(); + + expect(html).toMatch(/data-testid="message-row-2" data-expanded="false"/); + }); + + it('keeps tool calls collapsed by default', () => { + const html = renderStructuredTranscript(); + + expect(html).toMatch(/data-testid="tool-call-call-read-1" data-expanded="false"/); + }); + + it('renders expand and collapse controls for tool calls', () => { + const html = renderStructuredTranscript(); + + expect(html).toContain('Expand all tool calls'); + expect(html).toContain('Collapse all tool calls'); + }); + + it('renders final answer separately from prior assistant/tool context with normalized JSONL access', () => { + const html = renderStructuredTranscript(); expect(html).toContain('Final answer'); expect(html).toContain('Transcript timeline'); @@ -68,8 +98,8 @@ describe('TranscriptTimeline', () => { expect(html).toContain('Arguments'); expect(html).toContain('Result'); expect(html).toContain('success'); - expect(html).toContain('Open raw JSONL'); - expect(html).toContain('Download JSONL'); + expect(html).toContain('Open normalized JSONL'); + expect(html).toContain('Download normalized JSONL'); expect(html).toContain('{"answer":42,"source":"src/app.ts"}'); }); }); From 82d609654a8a55277f5c3eda7011ef37ebf76364 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Fri, 26 Jun 2026 13:27:17 +0200 Subject: [PATCH 2/2] fix(dashboard): preserve normalized tool result details --- .../src/components/TranscriptTimeline.tsx | 15 ++++++- .../components/transcript-timeline.test.tsx | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/TranscriptTimeline.tsx b/apps/dashboard/src/components/TranscriptTimeline.tsx index cfc4eccc6..ddb366bb9 100644 --- a/apps/dashboard/src/components/TranscriptTimeline.tsx +++ b/apps/dashboard/src/components/TranscriptTimeline.tsx @@ -152,17 +152,30 @@ function isNormalizedTranscriptLine(value: unknown): value is Record): Record { const result = isRecord(block.result) ? block.result : undefined; + const metadata = mergeToolMetadata(block.metadata, result?.metadata); return { id: typeof block.id === 'string' ? block.id : undefined, tool: typeof block.name === 'string' ? block.name : 'tool', input: block.input, output: result?.output, + error: result?.error, status: typeof result?.status === 'string' ? result.status : undefined, duration_ms: typeof result?.duration_ms === 'number' ? result.duration_ms : undefined, - metadata: isRecord(block.metadata) ? block.metadata : undefined, + metadata, }; } +function mergeToolMetadata(blockMetadata: unknown, resultMetadata: unknown): unknown { + const hasBlockMetadata = isRecord(blockMetadata); + const hasResultMetadata = isRecord(resultMetadata); + if (hasBlockMetadata && hasResultMetadata) { + return { ...blockMetadata, result: resultMetadata }; + } + if (hasBlockMetadata) return blockMetadata; + if (hasResultMetadata) return resultMetadata; + return undefined; +} + function normalizedTranscriptLineToTimelineEntry( value: Record, messageIndex: number, diff --git a/apps/dashboard/src/components/transcript-timeline.test.tsx b/apps/dashboard/src/components/transcript-timeline.test.tsx index dc12a699c..746f0e125 100644 --- a/apps/dashboard/src/components/transcript-timeline.test.tsx +++ b/apps/dashboard/src/components/transcript-timeline.test.tsx @@ -87,6 +87,45 @@ describe('TranscriptTimeline', () => { expect(html).toContain('Collapse all tool calls'); }); + it('preserves joined tool result error and metadata from normalized rows', () => { + const parsed = parseTranscriptJsonl( + JSON.stringify({ + v: 1, + agent: 'codex', + type: 'assistant', + content: [ + { type: 'text', text: 'Trying the shell.' }, + { + type: 'tool_use', + id: 'call-fail-1', + name: 'bash', + input: { command: 'false' }, + metadata: { cwd: '/tmp/agentv-fixture' }, + result: { + status: 'error', + output: { exit_code: 1 }, + error: { message: 'command failed' }, + metadata: { signal: 'SIGTERM' }, + duration_ms: 12, + }, + }, + ], + }), + ); + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('command failed'); + expect(html).toContain('SIGTERM'); + expect(html).toContain('/tmp/agentv-fixture'); + }); + it('renders final answer separately from prior assistant/tool context with normalized JSONL access', () => { const html = renderStructuredTranscript();