fix(trajectory): show exact millisecond durations consistently

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.
This commit is contained in:
_Kerman
2026-08-05 16:18:10 +08:00
parent a00b11be76
commit c634fc2917
8 changed files with 49 additions and 56 deletions

View File

@@ -5,7 +5,7 @@
- img - img
- searchbox "Search trajectory" - searchbox "Search trajectory"
- region "Trajectory timeline": - region "Trajectory timeline":
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s" - tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms"
- table: - table:
- rowgroup: - rowgroup:
- row "SYSTEM, Initial System Prompt": - row "SYSTEM, Initial System Prompt":

View File

@@ -299,26 +299,6 @@ function StartedAtValue({ timestamp }: { timestamp: number | null }) {
) )
} }
function DurationValue({ seconds }: { seconds: number | null }) {
const [showMillis, setShowMillis] = useState(false)
if (seconds === null || !Number.isFinite(seconds)) return <dd></dd>
return (
<dd>
<button
type="button"
className={css.timestampToggle}
title={showMillis ? 'Show readable duration' : 'Show exact milliseconds'}
onClick={(event) => {
if (clickSelectsText(event.currentTarget)) return
setShowMillis(current => !current)
}}
>
{showMillis ? `${Math.round(seconds * 1000)} ms` : formatElapsedSeconds(seconds)}
</button>
</dd>
)
}
function totalTime(metrics: AssistantMetricDetail): string { function totalTime(metrics: AssistantMetricDetail): string {
if (!metrics.timingRecorded) return 'Not recorded' if (!metrics.timingRecorded) return 'Not recorded'
if (metrics.stepStartTime === null) return 'Step start unavailable' if (metrics.stepStartTime === null) return 'Step start unavailable'
@@ -1376,7 +1356,7 @@ function RecordTiming({ record }: { record: TableRecord }) {
: ( : (
<dl className={css.overview}> <dl className={css.overview}>
<div><dt>Started</dt><StartedAtValue timestamp={record.cell.startedAt ?? null} /></div> <div><dt>Started</dt><StartedAtValue timestamp={record.cell.startedAt ?? null} /></div>
<div><dt>Duration</dt><DurationValue seconds={record.cell.timeSeconds} /></div> <div><dt>Duration</dt><dd>{formatElapsedSeconds(record.cell.timeSeconds)}</dd></div>
<div><dt>Timing source</dt><dd>{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}</dd></div> <div><dt>Timing source</dt><dd>{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}</dd></div>
</dl> </dl>
) )
@@ -1399,7 +1379,7 @@ function RequestTiming({
return ( return (
<dl className={css.overview}> <dl className={css.overview}>
<div><dt>Started</dt><StartedAtValue timestamp={request.startedAt} /></div> <div><dt>Started</dt><StartedAtValue timestamp={request.startedAt} /></div>
<div><dt>Duration</dt><DurationValue seconds={duration} /></div> <div><dt>Duration</dt><dd>{formatElapsedSeconds(duration)}</dd></div>
<div> <div>
<dt>Timing source</dt> <dt>Timing source</dt>
<dd>{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}</dd> <dd>{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}</dd>
@@ -1413,7 +1393,7 @@ function RequestTiming({
<dt>Started</dt> <dt>Started</dt>
<StartedAtValue timestamp={anchor?.cell.startedAt ?? null} /> <StartedAtValue timestamp={anchor?.cell.startedAt ?? null} />
</div> </div>
<div><dt>Duration</dt><DurationValue seconds={null} /></div> <div><dt>Duration</dt><dd>{formatElapsedSeconds(null)}</dd></div>
</dl> </dl>
) )
} }
@@ -2757,7 +2737,7 @@ export function TrajectoryTable({
</div> </div>
<div> <div>
<dt>Duration</dt> <dt>Duration</dt>
<DurationValue seconds={selected.cell.timeSeconds} /> <dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
</div> </div>
<div> <div>
<dt>Tokens</dt> <dt>Tokens</dt>
@@ -2872,7 +2852,7 @@ export function TrajectoryTable({
{(selected.cell.kind === 'user' || selected.cell.kind === 'context') && ( {(selected.cell.kind === 'user' || selected.cell.kind === 'context') && (
<div> <div>
<dt>Duration</dt> <dt>Duration</dt>
<DurationValue seconds={selected.cell.timeSeconds} /> <dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
</div> </div>
)} )}
</dl> </dl>

View File

@@ -1,6 +1,7 @@
/** Operation-sequence and recorded-time projections for the trajectory overview. */ /** Operation-sequence and recorded-time projections for the trajectory overview. */
import type { TrajectoryTurnModel } from './layout.ts' import type { TrajectoryTurnModel } from './layout.ts'
import { formatDurationMillis } from './trajectory-record.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Horizontal projection used by the trajectory timeline. */ /** Horizontal projection used by the trajectory timeline. */
@@ -34,14 +35,12 @@ export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
} }
/** /**
* Format a timeline duration with a compact unit. * Format a timeline duration as an integer-millisecond label.
* @param milliseconds - Non-negative duration in milliseconds. * @param milliseconds - Non-negative duration in milliseconds.
* @returns Millisecond or second label. * @returns Millisecond label with thousands separators.
*/ */
export function formatTimelineOffset(milliseconds: number): string { export function formatTimelineOffset(milliseconds: number): string {
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms` return formatDurationMillis(milliseconds)
const seconds = milliseconds / 1_000
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
} }
function laneFor(kind: TrajectoryCellKind): number { function laneFor(kind: TrajectoryCellKind): number {

View File

@@ -106,15 +106,20 @@ export function trajectoryRecordId(cell: TrajectoryCellProps): string {
} }
/** /**
* Format a duration with a precision that matches its magnitude. * Format a duration in milliseconds with thousands separators.
* @param milliseconds - Duration in milliseconds, or `null` when absent.
* @returns `—` when unknown, otherwise an integer-millisecond label.
*/
export function formatDurationMillis(milliseconds: number | null): string {
if (milliseconds === null || !Number.isFinite(milliseconds)) return '—'
return `${Math.round(milliseconds).toLocaleString('en-US')} ms`
}
/**
* Format an elapsed duration given in seconds as a millisecond label.
* @param seconds - Duration seconds, or `null` when absent. * @param seconds - Duration seconds, or `null` when absent.
* @returns `—` when unknown, otherwise an integer-millisecond label * @returns `—` when unknown, otherwise an integer-millisecond label.
* below one second and a tenth-of-a-second label at or above it.
*/ */
export function formatElapsedSeconds(seconds: number | null): string { export function formatElapsedSeconds(seconds: number | null): string {
if (seconds === null || !Number.isFinite(seconds)) return '—' return formatDurationMillis(seconds === null ? null : seconds * 1000)
if (seconds < 1) return `${Math.round(seconds * 1000)} ms`
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)} s`
} }

View File

@@ -10,20 +10,33 @@ import {
TrajectoryCell, TrajectoryCell,
type TrajectoryCellKind, type TrajectoryCellKind,
} from '../src/client/TrajectoryCell.tsx' } from '../src/client/TrajectoryCell.tsx'
import { formatDurationMillis } from '../src/client/trajectory-record.ts'
afterEach(cleanup) afterEach(cleanup)
describe('formatDurationMillis', () => {
it('formats exact millisecond labels with thousands separators', () => {
expect(formatDurationMillis(0)).toBe('0 ms')
expect(formatDurationMillis(29)).toBe('29 ms')
expect(formatDurationMillis(500)).toBe('500 ms')
expect(formatDurationMillis(1_500)).toBe('1,500 ms')
expect(formatDurationMillis(235_200)).toBe('235,200 ms')
expect(formatDurationMillis(null)).toBe('—')
expect(formatDurationMillis(Number.NaN)).toBe('—')
})
})
describe('formatElapsedSeconds', () => { describe('formatElapsedSeconds', () => {
it('formats known durations and uses an em dash when absent', () => { it('formats known durations and uses an em dash when absent', () => {
expect(formatElapsedSeconds(null)).toBe('—') expect(formatElapsedSeconds(null)).toBe('—')
expect(formatElapsedSeconds(235)).toBe('235 s') expect(formatElapsedSeconds(235)).toBe('235,000 ms')
expect(formatElapsedSeconds(235.0)).toBe('235 s') expect(formatElapsedSeconds(235.0)).toBe('235,000 ms')
expect(formatElapsedSeconds(235.2)).toBe('235.2 s') expect(formatElapsedSeconds(235.2)).toBe('235,200 ms')
expect(formatElapsedSeconds(235.25)).toBe('235.3 s') expect(formatElapsedSeconds(235.25)).toBe('235,250 ms')
expect(formatElapsedSeconds(0)).toBe('0 ms') expect(formatElapsedSeconds(0)).toBe('0 ms')
expect(formatElapsedSeconds(0.029)).toBe('29 ms') expect(formatElapsedSeconds(0.029)).toBe('29 ms')
expect(formatElapsedSeconds(0.5)).toBe('500 ms') expect(formatElapsedSeconds(0.5)).toBe('500 ms')
expect(formatElapsedSeconds(1.5)).toBe('1.5 s') expect(formatElapsedSeconds(1.5)).toBe('1,500 ms')
expect(formatElapsedSeconds(Number.NaN)).toBe('—') expect(formatElapsedSeconds(Number.NaN)).toBe('—')
}) })
}) })
@@ -41,7 +54,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('#6')).toBeTruthy() expect(screen.getByText('#6')).toBeTruthy()
expect(screen.getByText('Tool')).toBeTruthy() expect(screen.getByText('Tool')).toBeTruthy()
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy() expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
expect(screen.getByText('5 s')).toBeTruthy() expect(screen.getByText('5,000 ms')).toBeTruthy()
}) })
it('Message rows expose Input / Output / Think metric columns before time', () => { it('Message rows expose Input / Output / Think metric columns before time', () => {
@@ -60,11 +73,11 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('136')).toBeTruthy() expect(screen.getByText('136')).toBeTruthy()
expect(screen.getByText('381')).toBeTruthy() expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy() expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('235.2 s')).toBeTruthy() expect(screen.getByText('235,200 ms')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map(el => el.textContent) const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381')) expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155')) expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235.2 s')) expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235,200 ms'))
}) })
it('selected marks the row for the brand-primary inset ring', () => { it('selected marks the row for the brand-primary inset ring', () => {

View File

@@ -207,7 +207,7 @@ describe('deriveTrajectoryLayout', () => {
}, },
] as unknown as ConversationSnapshot['nodes'] ] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('3 s bash×2') expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
}) })
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => { it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {

View File

@@ -97,7 +97,7 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('20.0 tok/s')).toBeTruthy() expect(screen.getByText('20.0 tok/s')).toBeTruthy()
}) })
it('toggles a tool record Duration between readable and exact milliseconds', () => { it('shows a tool record Duration as exact milliseconds', () => {
const turns: readonly TrajectoryTurnModel[] = [{ const turns: readonly TrajectoryTurnModel[] = [{
turn: 1, turn: 1,
groups: [{ groups: [{
@@ -115,11 +115,7 @@ describe('TrajectoryTable', () => {
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />) render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
const readable = screen.getByRole('button', { name: '1.5 s' }) expect(screen.getByText('1,500 ms', { selector: 'dd' })).toBeTruthy()
fireEvent.click(readable)
expect(screen.getByRole('button', { name: '1500 ms' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '1500 ms' }))
expect(screen.getByRole('button', { name: '1.5 s' })).toBeTruthy()
}) })
it('breaks output tokens into labeled reasoning and content rows', () => { it('breaks output tokens into labeled reasoning and content rows', () => {

View File

@@ -580,9 +580,9 @@ describe('timeline projection', () => {
expect(view.container.querySelector('[role="tooltip"]')).toBeNull() expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) }) act(() => { vi.advanceTimersByTime(1) })
const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]') const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
expect(tooltip?.textContent).toContain('Total 2.0 s') expect(tooltip?.textContent).toContain('Total 2,000 ms')
expect(tooltip?.textContent).toContain('TTFT 500 ms') expect(tooltip?.textContent).toContain('TTFT 500 ms')
expect(tooltip?.textContent).toContain('Decoding 1.5 s') expect(tooltip?.textContent).toContain('Decoding 1,500 ms')
} finally { } finally {
vi.useRealTimers() vi.useRealTimers()
} }