Drop the details-panel Duration toggle: Duration rows always show integer milliseconds, matching the cell time column. Timeline labels (Total/TTFT/Decoding) and step-group descriptions previously fell back to second labels at or above one second; they now also show exact milliseconds via the shared formatDurationMillis formatter.
201 lines
6.3 KiB
TypeScript
201 lines
6.3 KiB
TypeScript
/** Operation-sequence and recorded-time projections for the trajectory overview. */
|
|
|
|
import type { TrajectoryTurnModel } from './layout.ts'
|
|
import { formatDurationMillis } from './trajectory-record.ts'
|
|
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
|
|
|
|
/** Horizontal projection used by the trajectory timeline. */
|
|
export type TrajectoryTimelineMode = 'sequence' | 'duration' | 'time' | 'actual'
|
|
|
|
/** Inclusive selection in the active timeline projection's domain. */
|
|
export interface TrajectoryTimeRange {
|
|
start: number
|
|
end: number
|
|
}
|
|
|
|
/** One ledger record projected into the active timeline domain. */
|
|
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
|
|
index: number
|
|
isError: boolean
|
|
kind: TrajectoryCellKind
|
|
label: string
|
|
lane: number
|
|
}
|
|
|
|
/** One turn boundary in the active timeline domain. */
|
|
export interface TrajectoryTimelineTurnBoundary {
|
|
turn: number
|
|
time: number
|
|
}
|
|
|
|
/** Full-domain model used by the overview. */
|
|
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
|
|
spans: readonly TrajectoryTimelineSpan[]
|
|
turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
|
|
}
|
|
|
|
/**
|
|
* Format a timeline duration as an integer-millisecond label.
|
|
* @param milliseconds - Non-negative duration in milliseconds.
|
|
* @returns Millisecond label with thousands separators.
|
|
*/
|
|
export function formatTimelineOffset(milliseconds: number): string {
|
|
return formatDurationMillis(milliseconds)
|
|
}
|
|
|
|
function laneFor(kind: TrajectoryCellKind): number {
|
|
if (kind === 'tool' || kind === 'subtool') return 2
|
|
if (kind === 'message' || kind === 'compacted') return 1
|
|
return 0
|
|
}
|
|
|
|
function finite(value: number | null | undefined): value is number {
|
|
return value !== null && value !== undefined && Number.isFinite(value)
|
|
}
|
|
|
|
function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
|
|
if (!finite(cell.startedAt)) return null
|
|
const durationMs = finite(cell.timeSeconds)
|
|
? Math.max(0, cell.timeSeconds * 1_000)
|
|
: 0
|
|
return { start: cell.startedAt, end: cell.startedAt + durationMs }
|
|
}
|
|
|
|
/**
|
|
* Project every visible record into a stable three-lane timeline.
|
|
* @param turns - Unfiltered trajectory layout.
|
|
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
|
|
* @returns Timeline model, or `null` when no record is visible.
|
|
*/
|
|
export function deriveTrajectoryTimeline(
|
|
turns: readonly TrajectoryTurnModel[],
|
|
mode: TrajectoryTimelineMode = 'sequence',
|
|
): TrajectoryTimelineModel | null {
|
|
if (mode !== 'sequence') {
|
|
return deriveTimedTimeline(
|
|
turns,
|
|
mode === 'duration' || mode === 'actual',
|
|
mode === 'duration',
|
|
)
|
|
}
|
|
const spans: TrajectoryTimelineSpan[] = []
|
|
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
|
|
|
for (const turn of turns) {
|
|
const cells = turn.groups.flatMap(group =>
|
|
group.cells.filter(cell => cell.requestOnly !== true),
|
|
)
|
|
if (cells.length === 0) continue
|
|
if (turn.turn !== null) {
|
|
turnBoundaries.push({
|
|
turn: turn.turn,
|
|
time: spans.length,
|
|
})
|
|
}
|
|
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
|
|
start: spans.length + offset,
|
|
end: spans.length + offset + 1,
|
|
index: cell.index,
|
|
isError: cell.isError === true,
|
|
kind: cell.kind,
|
|
label: cell.text,
|
|
lane: laneFor(cell.kind),
|
|
})))
|
|
}
|
|
|
|
if (spans.length === 0) return null
|
|
return {
|
|
start: 0,
|
|
end: spans.length,
|
|
spans,
|
|
turnBoundaries,
|
|
}
|
|
}
|
|
|
|
function deriveTimedTimeline(
|
|
turns: readonly TrajectoryTurnModel[],
|
|
actualDuration: boolean,
|
|
compressIdle: boolean,
|
|
): TrajectoryTimelineModel | null {
|
|
const timedTurns = turns.flatMap((turn) => {
|
|
const rawSpans = turn.groups.flatMap(group =>
|
|
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
|
|
if (cell.requestOnly === true) return []
|
|
const range = cellRange(cell)
|
|
return range === null
|
|
? []
|
|
: [{
|
|
...range,
|
|
index: cell.index,
|
|
isError: cell.isError === true,
|
|
kind: cell.kind,
|
|
label: cell.text,
|
|
lane: laneFor(cell.kind),
|
|
}]
|
|
}),
|
|
)
|
|
return rawSpans.length === 0 ? [] : [{ turn: turn.turn, rawSpans }]
|
|
})
|
|
const rawSpans = timedTurns.flatMap(turn => turn.rawSpans)
|
|
if (rawSpans.length === 0) return null
|
|
|
|
const removedIdleBySpan = new Map<TrajectoryTimelineSpan, number>()
|
|
let removedIdle = 0
|
|
let coveredUntil: number | null = null
|
|
for (const span of [...rawSpans].sort((left, right) =>
|
|
left.start - right.start || left.end - right.end)) {
|
|
if (compressIdle && coveredUntil !== null && span.start > coveredUntil) {
|
|
removedIdle += span.start - coveredUntil
|
|
}
|
|
removedIdleBySpan.set(span, removedIdle)
|
|
coveredUntil = coveredUntil === null ? span.end : Math.max(coveredUntil, span.end)
|
|
}
|
|
|
|
const spans: TrajectoryTimelineSpan[] = []
|
|
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
|
for (const turn of timedTurns) {
|
|
const projected = turn.rawSpans.map((span): TrajectoryTimelineSpan => {
|
|
const offset = removedIdleBySpan.get(span) ?? 0
|
|
return {
|
|
...span,
|
|
start: span.start - offset,
|
|
end: (actualDuration ? span.end : span.start) - offset,
|
|
}
|
|
})
|
|
spans.push(...projected)
|
|
if (turn.turn !== null) {
|
|
turnBoundaries.push({
|
|
turn: turn.turn,
|
|
time: Math.min(...projected.map(span => span.start)),
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
start: Math.min(...spans.map(span => span.start)),
|
|
end: Math.max(...spans.map(span => span.end)),
|
|
spans,
|
|
turnBoundaries,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Identify records active at any point inside an inclusive selected interval.
|
|
* @param turns - Unfiltered trajectory layout.
|
|
* @param range - Selected interval in the active projection.
|
|
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
|
|
* @returns Record indexes inside the focus interval.
|
|
*/
|
|
export function trajectoryTimelineFocusIndexes(
|
|
turns: readonly TrajectoryTurnModel[],
|
|
range: TrajectoryTimeRange,
|
|
mode: TrajectoryTimelineMode = 'sequence',
|
|
): ReadonlySet<number> {
|
|
const model = deriveTrajectoryTimeline(turns, mode)
|
|
return new Set(
|
|
model?.spans
|
|
.filter(span => span.start <= range.end && span.end >= range.start)
|
|
.map(span => span.index),
|
|
)
|
|
}
|