Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,22 @@ function buildDashboardInteractionSessionDetailResponse(sessionId?: string) {
filesModified: [] as string[],
checkpointId: null,
toolUses: [] as Array<Record<string, unknown>>,
transcriptEntries: [
{
entryId: 'stub-turn-01-e0',
sessionId: 'sess-01',
turnId: 'turn-01',
order: 0,
timestamp: '2025-02-14T10:12:00.000Z',
actor: 'USER',
variant: 'CHAT',
source: 'TRANSCRIPT',
text: FIRST_SESSION_TURN_ONE_PROMPT,
toolUseId: null,
toolKind: null,
isError: false,
},
],
},
{
turnId: 'turn-02',
Expand All @@ -323,6 +339,22 @@ function buildDashboardInteractionSessionDetailResponse(sessionId?: string) {
filesModified: [] as string[],
checkpointId: null,
toolUses: [] as Array<Record<string, unknown>>,
transcriptEntries: [
{
entryId: 'stub-turn-02-e0',
sessionId: 'sess-01',
turnId: 'turn-02',
order: 0,
timestamp: '2025-02-14T10:28:00.000Z',
actor: 'USER',
variant: 'CHAT',
source: 'TRANSCRIPT',
text: FIRST_SESSION_TURN_TWO_PROMPT,
toolUseId: null,
toolKind: null,
isError: false,
},
],
},
]
: ([] as Array<Record<string, unknown>>)
Expand All @@ -333,6 +365,7 @@ function buildDashboardInteractionSessionDetailResponse(sessionId?: string) {
summary,
turns,
rawEvents: [] as Array<Record<string, unknown>>,
sessionTranscriptEntries: [] as Array<Record<string, unknown>>,
}
: null,
},
Expand Down Expand Up @@ -1051,7 +1084,7 @@ test.describe('Session detail', () => {
await expect(page.getByText(FIRST_SESSION_PROMPT).last()).toBeVisible()
})

test('sidebar turns tab falls back to turn prompts when transcript fragments are unavailable', async ({
test('sidebar turns tab renders canonical transcript entries per turn', async ({
page,
}) => {
await stubApiRoutes(page)
Expand Down
44 changes: 44 additions & 0 deletions src/features/dashboard/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ export type DashboardTokenUsageDto = {
api_call_count: number
}

export type DashboardTranscriptActorDto = 'USER' | 'ASSISTANT' | 'SYSTEM'

export type DashboardTranscriptVariantDto =
| 'CHAT'
| 'THINKING'
| 'TOOL_USE'
| 'TOOL_RESULT'

export type DashboardTranscriptSourceDto = 'TRANSCRIPT' | 'PROMPT_FALLBACK'

export type DashboardTranscriptEntryDto = {
entry_id: string
session_id: string
turn_id: string | null
order: number
timestamp: string | null
actor: DashboardTranscriptActorDto
variant: DashboardTranscriptVariantDto
source: DashboardTranscriptSourceDto
text: string
tool_use_id: string | null
tool_kind: string | null
is_error: boolean
}

export type DashboardCommitFileDiffDto = {
filepath: string
additionsCount: number
Expand Down Expand Up @@ -82,6 +107,13 @@ export type DashboardCheckpointSessionDetailDto = {
transcript_jsonl: string
prompts_text: string
context_text: string
/**
* Canonical transcript rows derived by the backend agent's deriver.
* Empty when the agent has no deriver or transcript_jsonl was unparseable.
* Prefer these for rendering; fall back to parsing transcript_jsonl only
* during the dual-read migration window.
*/
transcript_entries: DashboardTranscriptEntryDto[]
}

export type DashboardCheckpointDetailResponse = {
Expand Down Expand Up @@ -163,6 +195,12 @@ export type DashboardInteractionTurnDto = {
files_modified: string[]
checkpoint_id: string | null
tool_uses: DashboardInteractionToolUseDto[]
/**
* Canonical transcript rows for this turn, derived by the backend.
* Empty when the agent's deriver returned nothing — components should
* fall back to the existing transcript-fragment parser during migration.
*/
transcript_entries: DashboardTranscriptEntryDto[]
}

export type DashboardInteractionEventDto = {
Expand All @@ -184,6 +222,12 @@ export type DashboardInteractionSessionDetailResponse = {
summary: DashboardInteractionSessionDto
turns: DashboardInteractionTurnDto[]
raw_events: DashboardInteractionEventDto[]
/**
* Canonical transcript rows for the entire session. Used by the session
* sidebar and tool-use tab. Empty during the dual-read window when the
* backend has no deriver for the session's agent.
*/
session_transcript_entries: DashboardTranscriptEntryDto[]
}

export type DashboardInteractionUpdateDto = {
Expand Down
47 changes: 0 additions & 47 deletions src/features/dashboard/components/checkpoint-sheet-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
formatPromptForDisplay,
parseTranscriptEntries,
stripUserQueryTags,
} from './checkpoint-sheet-utils'

Expand All @@ -25,49 +24,3 @@ describe('formatPromptForDisplay', () => {
expect(formatPromptForDisplay(' ')).toBe('')
})
})

describe('parseTranscriptEntries', () => {
it('parses cursor-style role/content transcript lines', () => {
const jsonl = [
JSON.stringify({
role: 'user',
message: {
content: [
{
type: 'text',
text: '<user_query>\nshow me dashboard data\n</user_query>',
},
],
},
}),
JSON.stringify({
role: 'assistant',
message: {
content: [
{
type: 'text',
text: 'Fetching dashboard data now.',
},
],
},
}),
].join('\n')

expect(parseTranscriptEntries(jsonl)).toEqual([
{
id: 'msg-0-0',
timestamp: '',
actor: 'user',
variant: 'chat',
text: 'show me dashboard data\n',
},
{
id: 'msg-1-0',
timestamp: '',
actor: 'assistant',
variant: 'chat',
text: 'Fetching dashboard data now.',
},
])
})
})
168 changes: 11 additions & 157 deletions src/features/dashboard/components/checkpoint-sheet-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,17 @@ export const prettyPrintJson = (value: string): string => {
}
}

/** Normalized transcript message: two actors (user | assistant), variant drives styling. */
/**
* Normalized transcript message: two actors (user | assistant), variant drives styling.
*
* This is the renderer-facing shape consumed by `<ChatTranscript>`. It is produced
* from canonical `DashboardTranscriptEntryDto` rows by
* `transcriptEntriesToMessages` in `utils/transcript-entries.ts`.
*
* The legacy Claude-shape JSONL parser (`parseTranscriptEntries`) that used to
* live here was removed once all six agents started serving canonical
* transcript entries from the backend.
*/
export type TranscriptMessage = {
id: string
timestamp: string
Expand All @@ -46,159 +56,3 @@ export type TranscriptMessage = {
isError?: boolean
toolUseId?: string
}

function genId(index: number): string {
return `msg-${index}`
}

function toText(content: unknown): string {
if (typeof content === 'string') return content
return JSON.stringify(content ?? '', null, 2)
}

function readToolUseId(block: Record<string, unknown>): string | undefined {
for (const key of ['tool_use_id', 'toolUseId', 'id']) {
const value = block[key]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return undefined
}

function extractTextBlocks(content: unknown): string[] {
if (typeof content === 'string') {
const stripped = stripUserQueryTags(content).trimStart()
return stripped.trim() ? [stripped] : []
}

if (!Array.isArray(content)) {
return []
}

return content
.map((block) => {
const item = block as Record<string, unknown>
if (item.type === 'text' && typeof item.text === 'string') {
return stripUserQueryTags(item.text).trimStart()
}
return ''
})
.filter((text) => text.trim().length > 0)
}

export const parseTranscriptEntries = (jsonl: string): TranscriptMessage[] => {
const lines = jsonl
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)

const collected: TranscriptMessage[] = []

lines.forEach((line, lineIndex) => {
try {
const parsed = JSON.parse(line) as Record<string, unknown>
const type =
typeof parsed.type === 'string'
? parsed.type
: typeof parsed.role === 'string'
? parsed.role
: ''
const timestamp =
typeof parsed.timestamp === 'string' ? parsed.timestamp : ''
const uuid =
typeof parsed.uuid === 'string' ? parsed.uuid : genId(lineIndex)
const message = parsed.message as Record<string, unknown> | undefined
const messageContent = message?.content

if (type === 'user') {
const textBlocks = extractTextBlocks(messageContent)
textBlocks.forEach((text, i) => {
collected.push({
id: `${uuid}-${i}`,
timestamp,
actor: 'user',
variant: 'chat',
text,
})
})
if (textBlocks.length > 0) return

if (Array.isArray(messageContent)) {
const allToolResult = messageContent.every(
(b: unknown) =>
(b as Record<string, unknown>)?.type === 'tool_result',
)
if (allToolResult) {
messageContent.forEach((block: unknown, i: number) => {
const b = block as Record<string, unknown>
const content = b.content
const isError = b.is_error === true
collected.push({
id: `${uuid}-${i}`,
timestamp,
actor: 'assistant',
variant: 'tool_result',
text: toText(content),
isError,
toolUseId: readToolUseId(b),
})
})
}
}
return
}

if (type === 'assistant' && Array.isArray(messageContent)) {
messageContent.forEach((block: unknown, i: number) => {
const b = block as Record<string, unknown>
const blockType = b.type as string | undefined
const blockId = `${uuid}-${i}`

if (blockType === 'thinking' && typeof b.thinking === 'string') {
collected.push({
id: blockId,
timestamp,
actor: 'assistant',
variant: 'thinking',
text: `Thinking: ${b.thinking}`,
})
return
}
if (blockType === 'text' && typeof b.text === 'string') {
collected.push({
id: blockId,
timestamp,
actor: 'assistant',
variant: 'chat',
text: b.text,
})
return
}
if (blockType === 'tool_use' && typeof b.name === 'string') {
const input =
b.input != null ? JSON.stringify(b.input, null, 2) : ''
collected.push({
id: blockId,
timestamp,
actor: 'assistant',
variant: 'tool_use',
text: `Tool: ${b.name}\n${input}`,
toolUseId: readToolUseId(b),
})
}
})
}
} catch {
// Skip malformed lines; new format only per plan.
}
})

collected.sort((a, b) => {
const t = (a.timestamp || '').localeCompare(b.timestamp || '')
if (t !== 0) return t
return a.id.localeCompare(b.id)
})

return collected
}
Loading
Loading