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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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('—')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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/ }))
|
||||||
|
|||||||
Reference in New Issue
Block a user