diff --git a/.changeset/trace-viewer-precise-duration.md b/.changeset/trace-viewer-precise-duration.md
new file mode 100644
index 0000000000..807ea73189
--- /dev/null
+++ b/.changeset/trace-viewer-precise-duration.md
@@ -0,0 +1,5 @@
+---
+"@workflow/web-shared": patch
+---
+
+Show precise, non-rounded durations in the new trace viewer hover labels, event list, and detail pane (e.g. `1.5s` instead of `2s`).
diff --git a/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx b/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx
index 5bda8b74aa..3c60fc1e78 100644
--- a/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx
+++ b/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx
@@ -79,7 +79,7 @@ const EventRow = ({
- {formatDuration(durationMs)}
+ {formatDuration(durationMs, { precise: true })}
diff --git a/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx b/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx
index 0ff2c8a92e..a5f3cebdf9 100644
--- a/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx
+++ b/packages/web-shared/src/components/new-trace-viewer/components/timeline.tsx
@@ -130,7 +130,7 @@ const TimelineBar = memo(function TimelineBar({
);
const getMinDurationLabelWidthPx = (label: string) =>
Math.max(40, label.length * 6 + 12);
- const totalDurationLabel = formatDuration(totalDurationMs);
+ const totalDurationLabel = formatDuration(totalDurationMs, { precise: true });
const showBarDurationLabel =
isRowHovered &&
pixelWidth >= getMinDurationLabelWidthPx(totalDurationLabel);
@@ -152,7 +152,8 @@ const TimelineBar = memo(function TimelineBar({
const segPixelWidth =
(seg.endFraction - seg.startFraction) * pixelWidth;
const segDurationLabel = formatDuration(
- (seg.endFraction - seg.startFraction) * totalDurationMs
+ (seg.endFraction - seg.startFraction) * totalDurationMs,
+ { precise: true }
);
const showSegmentDurationLabel =
isRowHovered &&
@@ -371,7 +372,7 @@ export function Timeline({
key={gap.rowIndex}
leftFrac={gap.leftFrac}
rightFrac={gap.rightFrac}
- label={formatDuration(gap.gapMs, true)}
+ label={formatDuration(gap.gapMs, { precise: true })}
rowIndex={gap.rowIndex}
/>
))}
diff --git a/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx b/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx
index 64956d78fc..ca667b3a46 100644
--- a/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx
+++ b/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx
@@ -41,11 +41,11 @@ export function DetailPanel({
{span.resource}
Duration
- {formatDuration(durationMs)}
+ {formatDuration(durationMs, { precise: true })}
Offset
- +{formatDuration(offsetMs)}
+ +{formatDuration(offsetMs, { precise: true })}
Status
diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts
index c934721c81..273f17c727 100644
--- a/packages/web-shared/src/index.ts
+++ b/packages/web-shared/src/index.ts
@@ -54,6 +54,7 @@ export type { DecodedStreamChunkSource } from './lib/stream-display';
export type { ToastAdapter } from './lib/toast';
export { ToastProvider, useToast } from './lib/toast';
export type { StreamStep } from './lib/utils';
+export type { FormatDurationOptions } from './lib/utils';
export {
extractConversation,
formatDuration,
diff --git a/packages/web-shared/src/lib/utils.ts b/packages/web-shared/src/lib/utils.ts
index 4e36599785..90864abc9c 100644
--- a/packages/web-shared/src/lib/utils.ts
+++ b/packages/web-shared/src/lib/utils.ts
@@ -12,29 +12,86 @@ const MS_IN_MINUTE = 60 * MS_IN_SECOND;
const MS_IN_HOUR = 60 * MS_IN_MINUTE;
const MS_IN_DAY = 24 * MS_IN_HOUR;
+export interface FormatDurationOptions {
+ /**
+ * Compact multi-unit format that drops the smallest unit at the
+ * hour/minute boundary (e.g. `2h 30m` instead of `2h 30m 15s`).
+ */
+ compact?: boolean;
+ /**
+ * Preserve sub-second / sub-minute precision instead of rounding to
+ * whole seconds. Use for labels where the value is meant to be read
+ * as an exact figure — a 1500ms duration renders as `1.5s` rather
+ * than `2s`. Values are truncated (floored) rather than rounded so
+ * the label can never overstate the underlying duration or roll over
+ * into the next-larger unit at the boundary.
+ */
+ precise?: boolean;
+}
+
/**
* Formats a duration in milliseconds to a human-readable string.
*
* @param ms - Duration in milliseconds
- * @param compact - If true, returns a compact format (e.g., "380ms", "2m 30s").
- * If false (default), returns multi-part format (e.g., "1m 13s", "2d 5h 3m 12s").
+ * @param options - Either an options object, or a boolean shorthand
+ * equivalent to `{ compact: true }` for backwards
+ * compatibility with the original two-arg signature.
+ *
+ * Default format (no options):
+ * - < 1s: milliseconds (e.g. `380ms`)
+ * - < 1m: whole seconds, rounded (e.g. `45s`)
+ * - >= 1m: decomposed into days/hours/minutes/seconds (e.g. `1m 13s`,
+ * `2d 5h 3m 12s`)
+ *
+ * Compact format (`{ compact: true }`, used for timeline tick labels):
+ * - < 1s: milliseconds (e.g. `380ms`)
+ * - < 1m: whole seconds (e.g. `45s`)
+ * - < 1h: minutes + seconds (e.g. `2m 30s`)
+ * - >= 1h: hours + minutes (e.g. `2h 30m`)
*
- * Compact format (timeline markers):
- * - < 1s: shows milliseconds (e.g., "380ms")
- * - < 1m: shows seconds (e.g., "45s")
- * - < 1h: shows minutes and seconds (e.g., "2m 30s")
- * - >= 1h: shows hours and minutes (e.g., "2h 30m")
+ * Precise format (`{ precise: true }`, used for hover labels and detail
+ * panes — never rounds up into the next-larger unit):
+ * - < 1s: truncated milliseconds (e.g. `380ms`, `999ms`)
+ * - < 1m: seconds truncated to up to two decimal places, trailing
+ * zeros trimmed (e.g. `1.5s`, `12.34s`, `59.99s`)
+ * - < 1h: `Xm Y.Zs` with one decimal of seconds, truncated
+ * (e.g. `1m 5.2s`, `1m 59.9s`)
+ * - >= 1h / >= 1d: same decomposition as the default format, but
+ * seconds are floored so the label can't exceed the true value
*
- * Full format:
- * - < 1s: shows milliseconds (e.g., "380ms")
- * - < 1m: shows seconds (e.g., "45s")
- * - >= 1m: shows decomposed format with whole seconds (e.g., "1m 13s", "2m 13s")
+ * `precise` takes precedence over `compact` when both are set.
*/
-export function formatDuration(ms: number, compact = false): string {
+export function formatDuration(
+ ms: number,
+ options: boolean | FormatDurationOptions = false
+): string {
+ const { compact = false, precise = false } =
+ typeof options === 'boolean' ? { compact: options } : options;
+
if (ms === 0) {
return '0s';
}
+ if (precise) {
+ if (ms < MS_IN_SECOND) {
+ return `${Math.floor(ms)}ms`;
+ }
+
+ if (ms < MS_IN_MINUTE) {
+ return `${trimTrailingZeros(truncateToFixed(ms / MS_IN_SECOND, 2))}s`;
+ }
+
+ if (ms < MS_IN_HOUR) {
+ const m = Math.floor(ms / MS_IN_MINUTE);
+ const s = (ms % MS_IN_MINUTE) / MS_IN_SECOND;
+ return s === 0
+ ? `${m}m`
+ : `${m}m ${trimTrailingZeros(truncateToFixed(s, 1))}s`;
+ }
+
+ return decomposeLargeUnits(ms, { dropZeroSeconds: true });
+ }
+
if (ms < MS_IN_SECOND) {
const roundedMs = Math.round(ms);
return roundedMs < MS_IN_SECOND ? `${roundedMs}ms` : '1s';
@@ -58,28 +115,39 @@ export function formatDuration(ms: number, compact = false): string {
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
- // Full format: decompose into larger units + whole seconds.
- const days = Math.floor(roundedMs / MS_IN_DAY);
- const hours = Math.floor((roundedMs % MS_IN_DAY) / MS_IN_HOUR);
- const minutes = Math.floor((roundedMs % MS_IN_HOUR) / MS_IN_MINUTE);
- const seconds = Math.floor((roundedMs % MS_IN_MINUTE) / MS_IN_SECOND);
+ return decomposeLargeUnits(roundedMs, { dropZeroSeconds: false });
+}
- const parts: string[] = [];
+function decomposeLargeUnits(
+ ms: number,
+ { dropZeroSeconds }: { dropZeroSeconds: boolean }
+): string {
+ const days = Math.floor(ms / MS_IN_DAY);
+ const hours = Math.floor((ms % MS_IN_DAY) / MS_IN_HOUR);
+ const minutes = Math.floor((ms % MS_IN_HOUR) / MS_IN_MINUTE);
+ const seconds = Math.floor((ms % MS_IN_MINUTE) / MS_IN_SECOND);
- if (days > 0) {
- parts.push(`${days}d`);
- }
- if (hours > 0) {
- parts.push(`${hours}h`);
- }
- if (minutes > 0) {
- parts.push(`${minutes}m`);
+ const parts: string[] = [];
+ if (days > 0) parts.push(`${days}d`);
+ if (hours > 0) parts.push(`${hours}h`);
+ if (minutes > 0) parts.push(`${minutes}m`);
+ if (!dropZeroSeconds || seconds > 0 || parts.length === 0) {
+ parts.push(`${seconds}s`);
}
- parts.push(`${seconds}s`);
-
return parts.join(' ');
}
+function trimTrailingZeros(value: string): string {
+ if (!value.includes('.')) return value;
+ return value.replace(/\.?0+$/, '');
+}
+
+/** Truncate (floor) a number to a fixed number of decimal places, without rounding up. */
+function truncateToFixed(value: number, decimals: number): string {
+ const factor = 10 ** decimals;
+ return (Math.floor(value * factor) / factor).toFixed(decimals);
+}
+
/**
* Returns a formatted pagination display string
* @param currentPage - The current page number
diff --git a/packages/web-shared/test/format-duration.test.ts b/packages/web-shared/test/format-duration.test.ts
new file mode 100644
index 0000000000..0ae83cf3a9
--- /dev/null
+++ b/packages/web-shared/test/format-duration.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest';
+import { formatDuration } from '../src/lib/utils.js';
+
+/**
+ * The `precise` option on `formatDuration` is used in the new trace viewer
+ * for span durations, timeline hover labels, and detail-pane offsets. It
+ * must never round up to the next-larger unit — a 1500ms span should never
+ * render as `2s`.
+ */
+describe('formatDuration with { precise: true }', () => {
+ const precise = (ms: number) => formatDuration(ms, { precise: true });
+
+ it('renders zero', () => {
+ expect(precise(0)).toBe('0s');
+ });
+
+ it('renders sub-second values as integer milliseconds', () => {
+ expect(precise(1)).toBe('1ms');
+ expect(precise(380)).toBe('380ms');
+ expect(precise(999)).toBe('999ms');
+ expect(precise(999.4)).toBe('999ms');
+ });
+
+ it('renders sub-minute values with up to two decimals of seconds', () => {
+ expect(precise(1000)).toBe('1s');
+ expect(precise(1500)).toBe('1.5s');
+ expect(precise(1530)).toBe('1.53s');
+ expect(precise(8500)).toBe('8.5s');
+ expect(precise(12340)).toBe('12.34s');
+ expect(precise(59990)).toBe('59.99s');
+ });
+
+ it('never rounds up to the next-larger unit', () => {
+ expect(precise(1500)).not.toBe('2s');
+ expect(precise(8500)).not.toBe('9s');
+ expect(precise(59999)).not.toBe('1m');
+ });
+
+ it('truncates at unit boundaries instead of overflowing', () => {
+ // 999.5ms must stay in the ms bucket — `Math.round` would emit "1000ms".
+ expect(precise(999.5)).toBe('999ms');
+ // 59.999s must stay in the seconds bucket — `toFixed(2)` would emit "60s".
+ expect(precise(59_999)).toBe('59.99s');
+ // 1m 59.95s must not roll over to "1m 60s" via `toFixed(1)`.
+ expect(precise(119_950)).toBe('1m 59.9s');
+ });
+
+ it('renders sub-hour values as minutes + decimal seconds', () => {
+ expect(precise(60_000)).toBe('1m');
+ expect(precise(65_000)).toBe('1m 5s');
+ expect(precise(65_200)).toBe('1m 5.2s');
+ expect(precise(125_500)).toBe('2m 5.5s');
+ });
+
+ it('renders longer durations as hour/day decomposition', () => {
+ expect(precise(3_600_000)).toBe('1h');
+ expect(precise(3_660_000)).toBe('1h 1m');
+ expect(precise(3_665_000)).toBe('1h 1m 5s');
+ expect(precise(90_061_000)).toBe('1d 1h 1m 1s');
+ });
+});
+
+/**
+ * Guard the pre-existing default and compact behaviors of `formatDuration`
+ * — the precise refactor must not change them, and the legacy two-arg
+ * `formatDuration(ms, true)` form is still in use across the codebase.
+ */
+describe('formatDuration default + compact (unchanged behavior)', () => {
+ it('preserves the legacy boolean compact shorthand', () => {
+ expect(formatDuration(125_000, true)).toBe('2m 5s');
+ expect(formatDuration(125_000, { compact: true })).toBe('2m 5s');
+ });
+
+ it('still rounds seconds in the default and compact modes', () => {
+ expect(formatDuration(1500)).toBe('2s');
+ expect(formatDuration(1500, true)).toBe('2s');
+ });
+
+ it('renders the long form when no options are passed', () => {
+ expect(formatDuration(0)).toBe('0s');
+ expect(formatDuration(380)).toBe('380ms');
+ expect(formatDuration(73_000)).toBe('1m 13s');
+ });
+});