Kaynağa Gözat

fix(trajectory): display sub-second durations as exact milliseconds

formatElapsedSeconds rounded every duration to one decimal, so calls
under 50 ms rendered as 0 s. Durations below one second now show integer
milliseconds (29 ms); at or above one second the tenth-of-a-second label
is unchanged (1.5 s). Step group descriptions reuse the same formatter.

The details-panel Duration rows toggle between the readable label and
exact milliseconds on click, mirroring the StartedAt toggle; the shared
text-selection guard is extracted for both. tabular-nums is dropped from
detail values and the toggle buttons because SF's tnum feature widens
the decimal point and leaves excessive space after it.
_Kerman 1 ay önce
ebeveyn
işleme
afb6ee2c9b

+ 0 - 2
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -1096,7 +1096,6 @@
   margin: 0;
   margin: 0;
   overflow: hidden;
   overflow: hidden;
   color: var(--dsw-alias-label-primary);
   color: var(--dsw-alias-label-primary);
-  font-variant-numeric: tabular-nums;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
 }
 }
@@ -1106,7 +1105,6 @@
   color: inherit;
   color: inherit;
   cursor: pointer;
   cursor: pointer;
   font: inherit;
   font: inherit;
-  font-variant-numeric: tabular-nums;
   user-select: text;
   user-select: text;
 }
 }
 
 

+ 57 - 17
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -270,6 +270,15 @@ function formatStartedAt(timestamp: number | null): string {
   return `${day} ${time}`
   return `${day} ${time}`
 }
 }
 
 
+/** Whether a click lands on an active text selection and should keep it. */
+function clickSelectsText(target: Node): boolean {
+  const selection = window.getSelection()
+  return selection !== null
+    && !selection.isCollapsed
+    && selection.rangeCount > 0
+    && selection.getRangeAt(0).intersectsNode(target)
+}
+
 function StartedAtValue({ timestamp }: { timestamp: number | null }) {
 function StartedAtValue({ timestamp }: { timestamp: number | null }) {
   const [showUnix, setShowUnix] = useState(false)
   const [showUnix, setShowUnix] = useState(false)
   if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
   if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
@@ -280,13 +289,7 @@ function StartedAtValue({ timestamp }: { timestamp: number | null }) {
         className={css.timestampToggle}
         className={css.timestampToggle}
         title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
         title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
         onClick={(event) => {
         onClick={(event) => {
-          const selection = window.getSelection()
-          if (
-            selection !== null
-            && !selection.isCollapsed
-            && selection.rangeCount > 0
-            && selection.getRangeAt(0).intersectsNode(event.currentTarget)
-          ) return
+          if (clickSelectsText(event.currentTarget)) return
           setShowUnix(current => !current)
           setShowUnix(current => !current)
         }}
         }}
       >
       >
@@ -296,6 +299,26 @@ 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'
@@ -1353,7 +1376,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><dd>{formatElapsedSeconds(record.cell.timeSeconds)}</dd></div>
+        <div><dt>Duration</dt><DurationValue seconds={record.cell.timeSeconds} /></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>
     )
     )
@@ -1376,7 +1399,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><dd>{formatElapsedSeconds(duration)}</dd></div>
+        <div><dt>Duration</dt><DurationValue seconds={duration} /></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>
@@ -1390,7 +1413,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><dd>—</dd></div>
+      <div><dt>Duration</dt><DurationValue seconds={null} /></div>
     </dl>
     </dl>
   )
   )
 }
 }
@@ -1574,7 +1597,12 @@ function OverviewSection({
           <IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
           <IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
         </button>
         </button>
       </h3>
       </h3>
-      <div className={css.overviewPreview}>{children}</div>
+      <div
+        className={`${css.overviewPreview} ${css.summaryScrollRegion}`}
+        data-summary-scroll-region=""
+      >
+        {children}
+      </div>
     </section>
     </section>
   )
   )
 }
 }
@@ -2563,7 +2591,10 @@ export function TrajectoryTable({
               && selectedRequestState !== undefined
               && selectedRequestState !== undefined
               && activeTab === 'overview' && (
               && activeTab === 'overview' && (
               <>
               <>
-                <dl className={css.overview}>
+                <dl
+                  className={`${css.overview} ${css.summaryScrollRegion}`}
+                  data-summary-scroll-region=""
+                >
                   <div>
                   <div>
                     <dt>Status</dt>
                     <dt>Status</dt>
                     <dd className={selectedRequestState === 'error' ? css.error : undefined}>
                     <dd className={selectedRequestState === 'error' ? css.error : undefined}>
@@ -2714,7 +2745,10 @@ export function TrajectoryTable({
               && selectedState !== undefined
               && selectedState !== undefined
               && activeTab === 'overview' && (
               && activeTab === 'overview' && (
               <>
               <>
-                <dl className={css.overview}>
+                <dl
+                  className={`${css.overview} ${css.summaryScrollRegion}`}
+                  data-summary-scroll-region=""
+                >
                   <div>
                   <div>
                     <dt>Status</dt>
                     <dt>Status</dt>
                     <dd className={selectedState === 'error' ? css.error : undefined}>
                     <dd className={selectedState === 'error' ? css.error : undefined}>
@@ -2723,7 +2757,7 @@ export function TrajectoryTable({
                   </div>
                   </div>
                   <div>
                   <div>
                     <dt>Duration</dt>
                     <dt>Duration</dt>
-                    <dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
+                    <DurationValue seconds={selected.cell.timeSeconds} />
                   </div>
                   </div>
                   <div>
                   <div>
                     <dt>Tokens</dt>
                     <dt>Tokens</dt>
@@ -2731,7 +2765,10 @@ export function TrajectoryTable({
                   </div>
                   </div>
                 </dl>
                 </dl>
                 {selected.cell.outputDetail !== undefined && (
                 {selected.cell.outputDetail !== undefined && (
-                  <div className={css.compactedSummary}>
+                  <div
+                    className={`${css.compactedSummary} ${css.summaryScrollRegion}`}
+                    data-summary-scroll-region=""
+                  >
                     <MarkdownRecordContent
                     <MarkdownRecordContent
                       record={selected}
                       record={selected}
                       rendered
                       rendered
@@ -2749,7 +2786,10 @@ export function TrajectoryTable({
               && selectedState !== undefined
               && selectedState !== undefined
               && activeTab === 'overview' && (
               && activeTab === 'overview' && (
               <>
               <>
-                <dl className={css.overview}>
+                <dl
+                  className={`${css.overview} ${css.summaryScrollRegion}`}
+                  data-summary-scroll-region=""
+                >
                   {selected.cell.messageSource !== undefined && (
                   {selected.cell.messageSource !== undefined && (
                     <div>
                     <div>
                       <dt>Origin</dt>
                       <dt>Origin</dt>
@@ -2832,7 +2872,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>
-                      <dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
+                      <DurationValue seconds={selected.cell.timeSeconds} />
                     </div>
                     </div>
                   )}
                   )}
                 </dl>
                 </dl>

+ 2 - 3
packages/client/ui-trajectory/src/client/layout.ts

@@ -17,6 +17,7 @@ import type {
   TrajectoryCellProps,
   TrajectoryCellProps,
   TrajectorySourceBlock,
   TrajectorySourceBlock,
 } from './trajectory-record.ts'
 } from './trajectory-record.ts'
+import { formatElapsedSeconds } from './trajectory-record.ts'
 
 
 /** One Message or Step group inside a turn. */
 /** One Message or Step group inside a turn. */
 export interface TrajectoryGroupModel {
 export interface TrajectoryGroupModel {
@@ -603,9 +604,7 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
 
 
 function formatGroupDuration(seconds: number): string | undefined {
 function formatGroupDuration(seconds: number): string | undefined {
   if (!Number.isFinite(seconds)) return undefined
   if (!Number.isFinite(seconds)) return undefined
-  const rounded = Math.round(seconds * 10) / 10
-  if (Number.isInteger(rounded)) return `${rounded} s`
-  return `${rounded.toFixed(1)} s`
+  return formatElapsedSeconds(seconds)
 }
 }
 
 
 /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
 /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */

+ 4 - 2
packages/client/ui-trajectory/src/client/trajectory-record.ts

@@ -106,12 +106,14 @@ export function trajectoryRecordId(cell: TrajectoryCellProps): string {
 }
 }
 
 
 /**
 /**
- * Format own-duration for the trailing time column.
+ * Format a duration with a precision that matches its magnitude.
  * @param seconds - Duration seconds, or `null` when absent.
  * @param seconds - Duration seconds, or `null` when absent.
- * @returns `—` when unknown, otherwise a seconds 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 '—'
   if (seconds === null || !Number.isFinite(seconds)) return '—'
+  if (seconds < 1) return `${Math.round(seconds * 1000)} ms`
   const rounded = Math.round(seconds * 10) / 10
   const rounded = Math.round(seconds * 10) / 10
   if (Number.isInteger(rounded)) return `${rounded} s`
   if (Number.isInteger(rounded)) return `${rounded} s`
   return `${rounded.toFixed(1)} s`
   return `${rounded.toFixed(1)} s`

+ 4 - 1
packages/client/ui-trajectory/tests/cell.spec.tsx

@@ -20,7 +20,10 @@ describe('formatElapsedSeconds', () => {
     expect(formatElapsedSeconds(235.0)).toBe('235 s')
     expect(formatElapsedSeconds(235.0)).toBe('235 s')
     expect(formatElapsedSeconds(235.2)).toBe('235.2 s')
     expect(formatElapsedSeconds(235.2)).toBe('235.2 s')
     expect(formatElapsedSeconds(235.25)).toBe('235.3 s')
     expect(formatElapsedSeconds(235.25)).toBe('235.3 s')
-    expect(formatElapsedSeconds(0)).toBe('0 s')
+    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(Number.NaN)).toBe('—')
     expect(formatElapsedSeconds(Number.NaN)).toBe('—')
   })
   })
 })
 })

+ 25 - 0
packages/client/ui-trajectory/tests/table.spec.tsx

@@ -97,6 +97,31 @@ 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', () => {
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'tool',
+          text: 'bash · {"command":"pwd"}',
+          inputDetail: '{"command":"pwd"}',
+          timeSeconds: 1.5,
+        }],
+      }],
+    }]
+
+    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()
+  })
+
   it('breaks output tokens into labeled reasoning and content rows', () => {
   it('breaks output tokens into labeled reasoning and content rows', () => {
     render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
     render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
     fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
     fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))