Skip to content

Commit 9345c6f

Browse files
Merge pull request #35 from bitloops/render-normalised-data
Render normalised data
2 parents a39a344 + dcb6665 commit 9345c6f

22 files changed

Lines changed: 1040 additions & 1112 deletions

‎e2e/dashboard.spec.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,22 @@ function buildDashboardInteractionSessionDetailResponse(sessionId?: string) {
307307
filesModified: [] as string[],
308308
checkpointId: null,
309309
toolUses: [] as Array<Record<string, unknown>>,
310+
transcriptEntries: [
311+
{
312+
entryId: 'stub-turn-01-e0',
313+
sessionId: 'sess-01',
314+
turnId: 'turn-01',
315+
order: 0,
316+
timestamp: '2025-02-14T10:12:00.000Z',
317+
actor: 'USER',
318+
variant: 'CHAT',
319+
source: 'TRANSCRIPT',
320+
text: FIRST_SESSION_TURN_ONE_PROMPT,
321+
toolUseId: null,
322+
toolKind: null,
323+
isError: false,
324+
},
325+
],
310326
},
311327
{
312328
turnId: 'turn-02',
@@ -323,6 +339,22 @@ function buildDashboardInteractionSessionDetailResponse(sessionId?: string) {
323339
filesModified: [] as string[],
324340
checkpointId: null,
325341
toolUses: [] as Array<Record<string, unknown>>,
342+
transcriptEntries: [
343+
{
344+
entryId: 'stub-turn-02-e0',
345+
sessionId: 'sess-01',
346+
turnId: 'turn-02',
347+
order: 0,
348+
timestamp: '2025-02-14T10:28:00.000Z',
349+
actor: 'USER',
350+
variant: 'CHAT',
351+
source: 'TRANSCRIPT',
352+
text: FIRST_SESSION_TURN_TWO_PROMPT,
353+
toolUseId: null,
354+
toolKind: null,
355+
isError: false,
356+
},
357+
],
326358
},
327359
]
328360
: ([] as Array<Record<string, unknown>>)
@@ -333,6 +365,7 @@ function buildDashboardInteractionSessionDetailResponse(sessionId?: string) {
333365
summary,
334366
turns,
335367
rawEvents: [] as Array<Record<string, unknown>>,
368+
sessionTranscriptEntries: [] as Array<Record<string, unknown>>,
336369
}
337370
: null,
338371
},
@@ -1051,7 +1084,7 @@ test.describe('Session detail', () => {
10511084
await expect(page.getByText(FIRST_SESSION_PROMPT).last()).toBeVisible()
10521085
})
10531086

1054-
test('sidebar turns tab falls back to turn prompts when transcript fragments are unavailable', async ({
1087+
test('sidebar turns tab renders canonical transcript entries per turn', async ({
10551088
page,
10561089
}) => {
10571090
await stubApiRoutes(page)

‎src/features/dashboard/api-types.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,31 @@ export type DashboardTokenUsageDto = {
3030
api_call_count: number
3131
}
3232

33+
export type DashboardTranscriptActorDto = 'USER' | 'ASSISTANT' | 'SYSTEM'
34+
35+
export type DashboardTranscriptVariantDto =
36+
| 'CHAT'
37+
| 'THINKING'
38+
| 'TOOL_USE'
39+
| 'TOOL_RESULT'
40+
41+
export type DashboardTranscriptSourceDto = 'TRANSCRIPT' | 'PROMPT_FALLBACK'
42+
43+
export type DashboardTranscriptEntryDto = {
44+
entry_id: string
45+
session_id: string
46+
turn_id: string | null
47+
order: number
48+
timestamp: string | null
49+
actor: DashboardTranscriptActorDto
50+
variant: DashboardTranscriptVariantDto
51+
source: DashboardTranscriptSourceDto
52+
text: string
53+
tool_use_id: string | null
54+
tool_kind: string | null
55+
is_error: boolean
56+
}
57+
3358
export type DashboardCommitFileDiffDto = {
3459
filepath: string
3560
additionsCount: number
@@ -82,6 +107,15 @@ export type DashboardCheckpointSessionDetailDto = {
82107
transcript_jsonl: string
83108
prompts_text: string
84109
context_text: string
110+
/**
111+
* Canonical transcript rows derived by the backend agent's deriver.
112+
* Empty when the agent has no deriver or transcript_jsonl was unparseable.
113+
* This is the sole rendering source — the dashboard no longer parses
114+
* `transcript_jsonl` directly. `transcript_jsonl` remains on the wire for
115+
* downstream consumers (debug tooling, exports) but should not be parsed
116+
* for display.
117+
*/
118+
transcript_entries: DashboardTranscriptEntryDto[]
85119
}
86120

87121
export type DashboardCheckpointDetailResponse = {
@@ -163,6 +197,13 @@ export type DashboardInteractionTurnDto = {
163197
files_modified: string[]
164198
checkpoint_id: string | null
165199
tool_uses: DashboardInteractionToolUseDto[]
200+
/**
201+
* Canonical transcript rows for this turn, derived by the backend.
202+
* Empty when the agent's deriver returned nothing. The dashboard renders
203+
* an empty section in that case rather than parsing raw events — the
204+
* legacy transcript-fragment fallback has been removed.
205+
*/
206+
transcript_entries: DashboardTranscriptEntryDto[]
166207
}
167208

168209
export type DashboardInteractionEventDto = {
@@ -184,6 +225,14 @@ export type DashboardInteractionSessionDetailResponse = {
184225
summary: DashboardInteractionSessionDto
185226
turns: DashboardInteractionTurnDto[]
186227
raw_events: DashboardInteractionEventDto[]
228+
/**
229+
* Canonical transcript rows for the entire session. Used by the session
230+
* sidebar and tool-use tab. Empty when the backend has no deriver for the
231+
* session's agent or the transcript was unparseable — in that case the
232+
* affected panels render an empty state rather than falling back to raw
233+
* events.
234+
*/
235+
session_transcript_entries: DashboardTranscriptEntryDto[]
187236
}
188237

189238
export type DashboardInteractionUpdateDto = {
Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import {
33
formatPromptForDisplay,
4-
parseTranscriptEntries,
54
stripUserQueryTags,
65
} from './checkpoint-sheet-utils'
76

@@ -25,49 +24,3 @@ describe('formatPromptForDisplay', () => {
2524
expect(formatPromptForDisplay(' ')).toBe('')
2625
})
2726
})
28-
29-
describe('parseTranscriptEntries', () => {
30-
it('parses cursor-style role/content transcript lines', () => {
31-
const jsonl = [
32-
JSON.stringify({
33-
role: 'user',
34-
message: {
35-
content: [
36-
{
37-
type: 'text',
38-
text: '<user_query>\nshow me dashboard data\n</user_query>',
39-
},
40-
],
41-
},
42-
}),
43-
JSON.stringify({
44-
role: 'assistant',
45-
message: {
46-
content: [
47-
{
48-
type: 'text',
49-
text: 'Fetching dashboard data now.',
50-
},
51-
],
52-
},
53-
}),
54-
].join('\n')
55-
56-
expect(parseTranscriptEntries(jsonl)).toEqual([
57-
{
58-
id: 'msg-0-0',
59-
timestamp: '',
60-
actor: 'user',
61-
variant: 'chat',
62-
text: 'show me dashboard data\n',
63-
},
64-
{
65-
id: 'msg-1-0',
66-
timestamp: '',
67-
actor: 'assistant',
68-
variant: 'chat',
69-
text: 'Fetching dashboard data now.',
70-
},
71-
])
72-
})
73-
})

‎src/features/dashboard/components/checkpoint-sheet-utils.ts‎

Lines changed: 11 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,17 @@ export const prettyPrintJson = (value: string): string => {
3636
}
3737
}
3838

39-
/** Normalized transcript message: two actors (user | assistant), variant drives styling. */
39+
/**
40+
* Normalized transcript message: two actors (user | assistant), variant drives styling.
41+
*
42+
* This is the renderer-facing shape consumed by `<ChatTranscript>`. It is produced
43+
* from canonical `DashboardTranscriptEntryDto` rows by
44+
* `transcriptEntriesToMessages` in `utils/transcript-entries.ts`.
45+
*
46+
* The legacy Claude-shape JSONL parser (`parseTranscriptEntries`) that used to
47+
* live here was removed once all six agents started serving canonical
48+
* transcript entries from the backend.
49+
*/
4050
export type TranscriptMessage = {
4151
id: string
4252
timestamp: string
@@ -46,159 +56,3 @@ export type TranscriptMessage = {
4656
isError?: boolean
4757
toolUseId?: string
4858
}
49-
50-
function genId(index: number): string {
51-
return `msg-${index}`
52-
}
53-
54-
function toText(content: unknown): string {
55-
if (typeof content === 'string') return content
56-
return JSON.stringify(content ?? '', null, 2)
57-
}
58-
59-
function readToolUseId(block: Record<string, unknown>): string | undefined {
60-
for (const key of ['tool_use_id', 'toolUseId', 'id']) {
61-
const value = block[key]
62-
if (typeof value === 'string' && value.trim()) {
63-
return value.trim()
64-
}
65-
}
66-
return undefined
67-
}
68-
69-
function extractTextBlocks(content: unknown): string[] {
70-
if (typeof content === 'string') {
71-
const stripped = stripUserQueryTags(content).trimStart()
72-
return stripped.trim() ? [stripped] : []
73-
}
74-
75-
if (!Array.isArray(content)) {
76-
return []
77-
}
78-
79-
return content
80-
.map((block) => {
81-
const item = block as Record<string, unknown>
82-
if (item.type === 'text' && typeof item.text === 'string') {
83-
return stripUserQueryTags(item.text).trimStart()
84-
}
85-
return ''
86-
})
87-
.filter((text) => text.trim().length > 0)
88-
}
89-
90-
export const parseTranscriptEntries = (jsonl: string): TranscriptMessage[] => {
91-
const lines = jsonl
92-
.split('\n')
93-
.map((line) => line.trim())
94-
.filter((line) => line.length > 0)
95-
96-
const collected: TranscriptMessage[] = []
97-
98-
lines.forEach((line, lineIndex) => {
99-
try {
100-
const parsed = JSON.parse(line) as Record<string, unknown>
101-
const type =
102-
typeof parsed.type === 'string'
103-
? parsed.type
104-
: typeof parsed.role === 'string'
105-
? parsed.role
106-
: ''
107-
const timestamp =
108-
typeof parsed.timestamp === 'string' ? parsed.timestamp : ''
109-
const uuid =
110-
typeof parsed.uuid === 'string' ? parsed.uuid : genId(lineIndex)
111-
const message = parsed.message as Record<string, unknown> | undefined
112-
const messageContent = message?.content
113-
114-
if (type === 'user') {
115-
const textBlocks = extractTextBlocks(messageContent)
116-
textBlocks.forEach((text, i) => {
117-
collected.push({
118-
id: `${uuid}-${i}`,
119-
timestamp,
120-
actor: 'user',
121-
variant: 'chat',
122-
text,
123-
})
124-
})
125-
if (textBlocks.length > 0) return
126-
127-
if (Array.isArray(messageContent)) {
128-
const allToolResult = messageContent.every(
129-
(b: unknown) =>
130-
(b as Record<string, unknown>)?.type === 'tool_result',
131-
)
132-
if (allToolResult) {
133-
messageContent.forEach((block: unknown, i: number) => {
134-
const b = block as Record<string, unknown>
135-
const content = b.content
136-
const isError = b.is_error === true
137-
collected.push({
138-
id: `${uuid}-${i}`,
139-
timestamp,
140-
actor: 'assistant',
141-
variant: 'tool_result',
142-
text: toText(content),
143-
isError,
144-
toolUseId: readToolUseId(b),
145-
})
146-
})
147-
}
148-
}
149-
return
150-
}
151-
152-
if (type === 'assistant' && Array.isArray(messageContent)) {
153-
messageContent.forEach((block: unknown, i: number) => {
154-
const b = block as Record<string, unknown>
155-
const blockType = b.type as string | undefined
156-
const blockId = `${uuid}-${i}`
157-
158-
if (blockType === 'thinking' && typeof b.thinking === 'string') {
159-
collected.push({
160-
id: blockId,
161-
timestamp,
162-
actor: 'assistant',
163-
variant: 'thinking',
164-
text: `Thinking: ${b.thinking}`,
165-
})
166-
return
167-
}
168-
if (blockType === 'text' && typeof b.text === 'string') {
169-
collected.push({
170-
id: blockId,
171-
timestamp,
172-
actor: 'assistant',
173-
variant: 'chat',
174-
text: b.text,
175-
})
176-
return
177-
}
178-
if (blockType === 'tool_use' && typeof b.name === 'string') {
179-
const input =
180-
b.input != null ? JSON.stringify(b.input, null, 2) : ''
181-
collected.push({
182-
id: blockId,
183-
timestamp,
184-
actor: 'assistant',
185-
variant: 'tool_use',
186-
text: `Tool: ${b.name}\n${input}`,
187-
toolUseId: readToolUseId(b),
188-
})
189-
}
190-
})
191-
}
192-
} catch {
193-
// Skip malformed lines; new format only per plan.
194-
}
195-
})
196-
197-
collected.sort((a, b) => {
198-
const t = (a.timestamp || '').localeCompare(b.timestamp || '')
199-
if (t !== 0) return t
200-
return a.id.localeCompare(b.id)
201-
})
202-
203-
return collected
204-
}

0 commit comments

Comments
 (0)