This commit is contained in:
07akioni
2026-07-24 14:36:44 +08:00
parent aeb8b1f486
commit 06143b1a86
5 changed files with 124 additions and 17 deletions
@@ -54,6 +54,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
let index = 0
let prevAbsTime: number | null = null
let lastAssistantTurn: number | null = null
const bucket = (turn: number) => {
let entry = turns.get(turn)
@@ -74,9 +75,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
steps.set(step, list)
}
for (const node of nodes) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
if (node === undefined) continue
if (node.kind === 'user' || node.kind === 'steering') {
const turn = node.kind === 'steering' ? node.turn : 0
// user/message has no turn on the wire; enclose it in the next assistant
// (or partial) turn, else open the turn after the last assistant.
const turn = node.kind === 'steering'
? node.turn
: enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
@@ -96,6 +104,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
lastAssistantTurn = node.turn
continue
}
if (node.kind === 'context') {
// No trajectory cell, but the surface still advances the duration cursor.
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'tool-result') {
@@ -149,6 +163,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
})
}
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
const prologue = turns.get(0)
if (prologue !== undefined) {
turns.delete(0)
@@ -269,11 +284,9 @@ function expandAssistant(
index: ++index, kind: 'message', text: summarizeText(block.text),
timeSeconds: messageDuration,
}
if (!usageAttached && usage !== undefined) {
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
usageAttached = true
if (!usageAttached) {
attachUsage(cell, usage)
usageAttached = usage !== undefined
}
out.push({ absTime: nodeAbs, cell })
continue
@@ -302,14 +315,45 @@ function expandAssistant(
}
if (out.length === 0 && !streaming) {
out.push({
absTime: nodeAbs,
cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration },
})
// Reasoning-only / empty success still owns provider usage on the Message row.
const cell: TrajectoryCellProps = {
index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
}
attachUsage(cell, usage)
out.push({ absTime: nodeAbs, cell })
}
return out
}
/**
* Turn that encloses a user/message: next assistant/steering turn, else the
* in-flight partial, else the turn after the last finalized assistant (or 1).
*/
function enclosingUserTurn(
nodes: ConversationSnapshot['nodes'],
userIndex: number,
partial: ConversationSnapshot['partial'],
lastAssistantTurn: number | null,
): number {
for (let i = userIndex + 1; i < nodes.length; i++) {
const n = nodes[i]
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
if (n === undefined) continue
if (n.kind === 'assistant' || n.kind === 'steering') return n.turn
}
if (partial !== null) return partial.turn
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
return 1
}
/** Copy provider usage onto a Message cell when present. */
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
if (usage === undefined) return
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
}
function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> {
const map = new Map<string, ToolResultNode>()
for (const node of nodes) {