Преглед изворни кода

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.
_Kerman пре 1 месец
родитељ
комит
c634fc2917

+ 1 - 1
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":

+ 5 - 25
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 <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 {
   if (!metrics.timingRecorded) return 'Not recorded'
   if (metrics.stepStartTime === null) return 'Step start unavailable'
@@ -1376,7 +1356,7 @@ function RecordTiming({ record }: { record: TableRecord }) {
     : (
       <dl className={css.overview}>
         <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>
       </dl>
     )
@@ -1399,7 +1379,7 @@ function RequestTiming({
     return (
       <dl className={css.overview}>
         <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>
           <dt>Timing source</dt>
           <dd>{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}</dd>
@@ -1413,7 +1393,7 @@ function RequestTiming({
         <dt>Started</dt>
         <StartedAtValue timestamp={anchor?.cell.startedAt ?? null} />
       </div>
-      <div><dt>Duration</dt><DurationValue seconds={null} /></div>
+      <div><dt>Duration</dt><dd>{formatElapsedSeconds(null)}</dd></div>
     </dl>
   )
 }
@@ -2757,7 +2737,7 @@ export function TrajectoryTable({
                   </div>
                   <div>
                     <dt>Duration</dt>
-                    <DurationValue seconds={selected.cell.timeSeconds} />
+                    <dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
                   </div>
                   <div>
                     <dt>Tokens</dt>
@@ -2872,7 +2852,7 @@ export function TrajectoryTable({
                   {(selected.cell.kind === 'user' || selected.cell.kind === 'context') && (
                     <div>
                       <dt>Duration</dt>
-                      <DurationValue seconds={selected.cell.timeSeconds} />
+                      <dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
                     </div>
                   )}
                 </dl>

+ 4 - 5
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 {

+ 13 - 8
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)
 }

+ 21 - 8
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', () => {

+ 1 - 1
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', () => {

+ 2 - 6
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(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
     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', () => {

+ 2 - 2
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<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('Decoding 1.5 s')
+      expect(tooltip?.textContent).toContain('Decoding 1,500 ms')
     } finally {
       vi.useRealTimers()
     }