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; 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;
} }

View File

@@ -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 (clickSelectsText(event.currentTarget)) return
if (
selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(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>

View File

@@ -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 return formatElapsedSeconds(seconds)
if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)} s`
} }
/** 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. */

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

View File

@@ -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('—')
}) })
}) })

View File

@@ -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/ }))