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.
This commit is contained in:
_Kerman
2026-08-05 15:18:39 +08:00
parent 34650059ef
commit afb6ee2c9b
6 changed files with 92 additions and 25 deletions

View File

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

View File

@@ -270,6 +270,15 @@ function formatStartedAt(timestamp: number | null): string {
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 }) {
const [showUnix, setShowUnix] = useState(false)
if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
@@ -280,13 +289,7 @@ function StartedAtValue({ timestamp }: { timestamp: number | null }) {
className={css.timestampToggle}
title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
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)
}}
>
@@ -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 {
if (!metrics.timingRecorded) return 'Not recorded'
if (metrics.stepStartTime === null) return 'Step start unavailable'
@@ -1353,7 +1376,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><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>
</dl>
)
@@ -1376,7 +1399,7 @@ function RequestTiming({
return (
<dl className={css.overview}>
<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>
<dt>Timing source</dt>
<dd>{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}</dd>
@@ -1390,7 +1413,7 @@ function RequestTiming({
<dt>Started</dt>
<StartedAtValue timestamp={anchor?.cell.startedAt ?? null} />
</div>
<div><dt>Duration</dt><dd></dd></div>
<div><dt>Duration</dt><DurationValue seconds={null} /></div>
</dl>
)
}
@@ -1574,7 +1597,12 @@ function OverviewSection({
<IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
</button>
</h3>
<div className={css.overviewPreview}>{children}</div>
<div
className={`${css.overviewPreview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{children}
</div>
</section>
)
}
@@ -2563,7 +2591,10 @@ export function TrajectoryTable({
&& selectedRequestState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedRequestState === 'error' ? css.error : undefined}>
@@ -2714,7 +2745,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedState === 'error' ? css.error : undefined}>
@@ -2723,7 +2757,7 @@ export function TrajectoryTable({
</div>
<div>
<dt>Duration</dt>
<dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
<DurationValue seconds={selected.cell.timeSeconds} />
</div>
<div>
<dt>Tokens</dt>
@@ -2731,7 +2765,10 @@ export function TrajectoryTable({
</div>
</dl>
{selected.cell.outputDetail !== undefined && (
<div className={css.compactedSummary}>
<div
className={`${css.compactedSummary} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<MarkdownRecordContent
record={selected}
rendered
@@ -2749,7 +2786,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{selected.cell.messageSource !== undefined && (
<div>
<dt>Origin</dt>
@@ -2832,7 +2872,7 @@ export function TrajectoryTable({
{(selected.cell.kind === 'user' || selected.cell.kind === 'context') && (
<div>
<dt>Duration</dt>
<dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
<DurationValue seconds={selected.cell.timeSeconds} />
</div>
)}
</dl>

View File

@@ -17,6 +17,7 @@ import type {
TrajectoryCellProps,
TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
/** One Message or Step group inside a turn. */
export interface TrajectoryGroupModel {
@@ -603,9 +604,7 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
function formatGroupDuration(seconds: number): string | 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. */

View File

@@ -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.
* @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 {
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`

View File

@@ -20,7 +20,10 @@ describe('formatElapsedSeconds', () => {
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(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('—')
})
})

View File

@@ -97,6 +97,31 @@ describe('TrajectoryTable', () => {
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', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))