From c634fc2917f1e1effd00a7e7e56126c00182a4ab Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 5 Aug 2026 16:18:10 +0800 Subject: [PATCH] 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. --- .../navigation-panes/trajectory.expected.md | 2 +- .../src/client/TrajectoryTable.tsx | 30 ++++--------------- .../ui-trajectory/src/client/timeline.ts | 9 +++--- .../src/client/trajectory-record.ts | 21 ++++++++----- .../client/ui-trajectory/tests/cell.spec.tsx | 29 +++++++++++++----- .../ui-trajectory/tests/layout.spec.tsx | 2 +- .../client/ui-trajectory/tests/table.spec.tsx | 8 ++--- .../client/ui-trajectory/tests/views.spec.tsx | 4 +-- 8 files changed, 49 insertions(+), 56 deletions(-) diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 3572409fc0..a9b5dbb982 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -5,7 +5,7 @@ - img - searchbox "Search trajectory" - 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: - rowgroup: - row "SYSTEM, Initial System Prompt": diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index fb62d82e8d..9cef2dccfb 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -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
- return ( -
- -
- ) -} - function totalTime(metrics: AssistantMetricDetail): string { if (!metrics.timingRecorded) return 'Not recorded' if (metrics.stepStartTime === null) return 'Step start unavailable' @@ -1376,7 +1356,7 @@ function RecordTiming({ record }: { record: TableRecord }) { : (
Started
-
Duration
+
Duration
{formatElapsedSeconds(record.cell.timeSeconds)}
Timing source
{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}
) @@ -1399,7 +1379,7 @@ function RequestTiming({ return (
Started
-
Duration
+
Duration
{formatElapsedSeconds(duration)}
Timing source
{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}
@@ -1413,7 +1393,7 @@ function RequestTiming({
Started
-
Duration
+
Duration
{formatElapsedSeconds(null)}
) } @@ -2757,7 +2737,7 @@ export function TrajectoryTable({
Duration
- +
{formatElapsedSeconds(selected.cell.timeSeconds)}
Tokens
@@ -2872,7 +2852,7 @@ export function TrajectoryTable({ {(selected.cell.kind === 'user' || selected.cell.kind === 'context') && (
Duration
- +
{formatElapsedSeconds(selected.cell.timeSeconds)}
)} diff --git a/packages/client/ui-trajectory/src/client/timeline.ts b/packages/client/ui-trajectory/src/client/timeline.ts index 6b9eac10b7..6d3a0ef917 100644 --- a/packages/client/ui-trajectory/src/client/timeline.ts +++ b/packages/client/ui-trajectory/src/client/timeline.ts @@ -1,6 +1,7 @@ /** 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. */ @@ -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. - * @returns Millisecond or second label. + * @returns Millisecond label with thousands separators. */ export function formatTimelineOffset(milliseconds: number): string { - if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms` - const seconds = milliseconds / 1_000 - return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s` + return formatDurationMillis(milliseconds) } function laneFor(kind: TrajectoryCellKind): number { diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 87cd2e9b21..a979278f3f 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -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. - * @returns `—` when unknown, otherwise an integer-millisecond label - * below one second and a tenth-of-a-second label at or above it. + * @returns `—` when unknown, otherwise an integer-millisecond label. */ export function formatElapsedSeconds(seconds: number | null): string { - if (seconds === null || !Number.isFinite(seconds)) return '—' - 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` + return formatDurationMillis(seconds === null ? null : seconds * 1000) } diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.spec.tsx index 69e4d638af..2e7c0ddb45 100644 --- a/packages/client/ui-trajectory/tests/cell.spec.tsx +++ b/packages/client/ui-trajectory/tests/cell.spec.tsx @@ -10,20 +10,33 @@ import { TrajectoryCell, type TrajectoryCellKind, } from '../src/client/TrajectoryCell.tsx' +import { formatDurationMillis } from '../src/client/trajectory-record.ts' 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', () => { it('formats known durations and uses an em dash when absent', () => { expect(formatElapsedSeconds(null)).toBe('—') - expect(formatElapsedSeconds(235)).toBe('235 s') - expect(formatElapsedSeconds(235.0)).toBe('235 s') - expect(formatElapsedSeconds(235.2)).toBe('235.2 s') - expect(formatElapsedSeconds(235.25)).toBe('235.3 s') + expect(formatElapsedSeconds(235)).toBe('235,000 ms') + expect(formatElapsedSeconds(235.0)).toBe('235,000 ms') + expect(formatElapsedSeconds(235.2)).toBe('235,200 ms') + expect(formatElapsedSeconds(235.25)).toBe('235,250 ms') expect(formatElapsedSeconds(0)).toBe('0 ms') expect(formatElapsedSeconds(0.029)).toBe('29 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('—') }) }) @@ -41,7 +54,7 @@ describe('TrajectoryCell', () => { expect(screen.getByText('#6')).toBeTruthy() expect(screen.getByText('Tool')).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', () => { @@ -60,11 +73,11 @@ describe('TrajectoryCell', () => { expect(screen.getByText('136')).toBeTruthy() expect(screen.getByText('381')).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) expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381')) 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', () => { diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index d071b0b141..bd25cc4d51 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -207,7 +207,7 @@ describe('deriveTrajectoryLayout', () => { }, ] as unknown as ConversationSnapshot['nodes'] 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', () => { diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 184466ccb2..dc4d2c9188 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -97,7 +97,7 @@ describe('TrajectoryTable', () => { 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[] = [{ turn: 1, groups: [{ @@ -115,11 +115,7 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) - const readable = screen.getByRole('button', { name: '1.5 s' }) - 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() + expect(screen.getByText('1,500 ms', { selector: 'dd' })).toBeTruthy() }) it('breaks output tokens into labeled reasoning and content rows', () => { diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index c37f780738..22270c1294 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -580,9 +580,9 @@ describe('timeline projection', () => { expect(view.container.querySelector('[role="tooltip"]')).toBeNull() act(() => { vi.advanceTimersByTime(1) }) const tooltip = view.container.querySelector('[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('Decoding 1.5 s') + expect(tooltip?.textContent).toContain('Decoding 1,500 ms') } finally { vi.useRealTimers() }