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
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()
}