@workflow/web-shared 5.0.0-beta.27 → 5.0.0-beta.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/components/event-list-view.d.ts.map +1 -1
  2. package/dist/components/event-list-view.js +13 -1
  3. package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -1
  4. package/dist/components/new-trace-viewer/components/detail-panel.js +2 -2
  5. package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
  6. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  7. package/dist/components/new-trace-viewer/components/event-list.js +4 -6
  8. package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
  9. package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
  10. package/dist/components/new-trace-viewer/components/minimap.js +337 -0
  11. package/dist/components/new-trace-viewer/components/split-pane.d.ts +0 -2
  12. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  13. package/dist/components/new-trace-viewer/components/split-pane.js +9 -75
  14. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  15. package/dist/components/new-trace-viewer/components/timeline.js +5 -7
  16. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  17. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +6 -2
  18. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  19. package/dist/components/new-trace-viewer/trace-viewer.js +48 -96
  20. package/dist/components/new-trace-viewer/utils.d.ts +21 -0
  21. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  22. package/dist/components/new-trace-viewer/utils.js +44 -3
  23. package/dist/components/sidebar/attribute-panel.d.ts +4 -1
  24. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  25. package/dist/components/sidebar/attribute-panel.js +32 -24
  26. package/dist/components/sidebar/attributes-block.d.ts +1 -0
  27. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  28. package/dist/components/sidebar/attributes-block.js +6 -5
  29. package/dist/components/sidebar/entity-detail-panel.d.ts +5 -1
  30. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  31. package/dist/components/sidebar/entity-detail-panel.js +18 -3
  32. package/dist/components/sidebar/sidebar-data-context.d.ts +4 -0
  33. package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
  34. package/dist/components/sidebar/sidebar-data-context.js +1 -1
  35. package/dist/lib/utils.d.ts +8 -5
  36. package/dist/lib/utils.d.ts.map +1 -1
  37. package/dist/lib/utils.js +13 -16
  38. package/package.json +5 -5
  39. package/src/components/event-list-view.tsx +21 -0
  40. package/src/components/new-trace-viewer/components/detail-panel.tsx +1 -0
  41. package/src/components/new-trace-viewer/components/event-list.tsx +4 -6
  42. package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
  43. package/src/components/new-trace-viewer/components/split-pane.tsx +39 -95
  44. package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
  45. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +17 -0
  46. package/src/components/new-trace-viewer/trace-viewer.tsx +79 -117
  47. package/src/components/new-trace-viewer/utils.test.ts +65 -0
  48. package/src/components/new-trace-viewer/utils.ts +76 -2
  49. package/src/components/sidebar/attribute-panel.tsx +77 -22
  50. package/src/components/sidebar/attributes-block.tsx +43 -10
  51. package/src/components/sidebar/entity-detail-panel.tsx +22 -0
  52. package/src/components/sidebar/sidebar-data-context.tsx +4 -0
  53. package/src/lib/utils.test.ts +33 -0
  54. package/src/lib/utils.ts +12 -16
@@ -1,4 +1,8 @@
1
- import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
1
+ import {
2
+ formatDuration,
3
+ formatDurationPrecise,
4
+ getHighResInMs,
5
+ } from '../trace-viewer/util/timing';
2
6
  import type { Span, SpanEvent } from './types';
3
7
 
4
8
  // ---------------------------------------------------------------------------
@@ -37,6 +41,64 @@ export function getSpanDurationMs(span: Span): number {
37
41
  );
38
42
  }
39
43
 
44
+ export function isSpanErrored(span: Span): boolean {
45
+ const workflowStatus = (span.attributes.data as Record<string, unknown>)
46
+ ?.status as string | undefined;
47
+ return span.status.code === 2 || workflowStatus === 'failed';
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Viewport
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface ViewportRange {
55
+ start: number;
56
+ end: number;
57
+ }
58
+
59
+ /**
60
+ * Clamp a candidate viewport to the root extent. The requested duration is
61
+ * preserved where possible (clamped to [minDurationMs, root duration]), then
62
+ * the window is shifted back inside the root bounds.
63
+ */
64
+ export function clampViewportToRoot(
65
+ next: ViewportRange,
66
+ rootStart: number,
67
+ rootEnd: number,
68
+ minDurationMs: number
69
+ ): ViewportRange {
70
+ const rootDuration = Math.max(rootEnd - rootStart, minDurationMs);
71
+ const duration = Math.min(
72
+ rootDuration,
73
+ Math.max(minDurationMs, next.end - next.start)
74
+ );
75
+ const maxStart = rootEnd - duration;
76
+ const start = Math.min(Math.max(next.start, rootStart), maxStart);
77
+ return { start, end: start + duration };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Wheel gestures — shared between the timeline and the minimap
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Convert a wheel delta to pixel units (line-mode deltas arrive in lines). */
85
+ export function wheelDeltaToPixels(delta: number, deltaMode: number): number {
86
+ return deltaMode === 1 ? delta * 16 : delta;
87
+ }
88
+
89
+ /**
90
+ * Exponential zoom factor for a wheel gesture. Coarse mouse-wheel steps are
91
+ * damped harder than trackpad pinches so both feel similar.
92
+ */
93
+ export function wheelZoomScaleFactor(event: {
94
+ deltaY: number;
95
+ deltaMode: number;
96
+ }): number {
97
+ const dy = wheelDeltaToPixels(event.deltaY, event.deltaMode);
98
+ const isMouseWheel = event.deltaMode === 1 || Math.abs(event.deltaY) >= 50;
99
+ return 2 ** (dy / (isMouseWheel ? 200 : 60));
100
+ }
101
+
40
102
  // ---------------------------------------------------------------------------
41
103
  // Time markers
42
104
  // ---------------------------------------------------------------------------
@@ -56,6 +118,8 @@ const NICE_INTERVALS = [
56
118
 
57
119
  const MAX_MARKERS = 8;
58
120
 
121
+ const MS_IN_SECOND = 1000;
122
+
59
123
  function pickInterval(viewDuration: number, maxTicks: number): number {
60
124
  for (const interval of NICE_INTERVALS) {
61
125
  if (viewDuration / interval <= maxTicks) return interval;
@@ -72,6 +136,13 @@ export function computeTimeMarkers(
72
136
  const maxTicks = 6;
73
137
  const interval = pickInterval(viewDuration, maxTicks);
74
138
 
139
+ // Sub-second steps need fractional labels, or ticks past 1s collide as
140
+ // duplicate whole seconds ("…1s, 2s, 2s, 3s"). Scale decimals to the step.
141
+ const fractionDigits =
142
+ interval >= MS_IN_SECOND
143
+ ? 0
144
+ : Math.ceil(-Math.log10(interval / MS_IN_SECOND));
145
+
75
146
  const firstTick = Math.ceil(offset / interval) * interval;
76
147
  const markers: TimeMarker[] = [];
77
148
 
@@ -80,7 +151,10 @@ export function computeTimeMarkers(
80
151
  if (position < -0.01 || position > 1.01) continue;
81
152
  markers.push({
82
153
  position: Math.min(Math.max(position, 0), 1),
83
- label: formatDuration(Math.abs(t), true),
154
+ label:
155
+ fractionDigits === 0
156
+ ? formatDuration(Math.abs(t), true)
157
+ : formatDurationPrecise(Math.abs(t), fractionDigits),
84
158
  value: t,
85
159
  });
86
160
  if (markers.length >= MAX_MARKERS) break;
@@ -5,7 +5,7 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
5
5
  import type { ModelMessage } from 'ai';
6
6
  import { format } from 'date-fns';
7
7
  import type { KeyboardEvent, ReactNode } from 'react';
8
- import { useCallback, useContext, useMemo, useState } from 'react';
8
+ import { useCallback, useContext, useMemo, useRef, useState } from 'react';
9
9
  import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration';
10
10
  import { extractConversation, isDoStreamStep } from '../../lib/utils';
11
11
  import {
@@ -148,16 +148,24 @@ const conversationTabs = [
148
148
  function ConversationWithTabs({
149
149
  conversation,
150
150
  args,
151
+ defaultOpen,
152
+ onOpenChange,
151
153
  }: {
152
154
  conversation: ModelMessage[];
153
155
  args: unknown[];
156
+ defaultOpen?: boolean;
157
+ onOpenChange?: (open: boolean) => void;
154
158
  }) {
155
159
  const [activeTab, setActiveTab] = useState<'conversation' | 'json'>(
156
160
  'conversation'
157
161
  );
158
162
 
159
163
  return (
160
- <Collapsible label="Input">
164
+ <Collapsible
165
+ label="Input"
166
+ defaultOpen={defaultOpen}
167
+ onOpenChange={onOpenChange}
168
+ >
161
169
  <TabbedContainer
162
170
  tabs={conversationTabs}
163
171
  activeTab={activeTab}
@@ -382,6 +390,8 @@ const timestampWithTooltipOrNull = (value: unknown): ReactNode | null => {
382
390
 
383
391
  interface DisplayContext {
384
392
  stepName?: string;
393
+ sectionOpen?: boolean;
394
+ onSectionOpenChange?: (open: boolean) => void;
385
395
  }
386
396
 
387
397
  const attributeToDisplayFn: Record<
@@ -457,7 +467,11 @@ const attributeToDisplayFn: Record<
457
467
  input: (value: unknown, context?: DisplayContext) => {
458
468
  if (isEncryptedMarker(value)) {
459
469
  return (
460
- <Collapsible label="Input">
470
+ <Collapsible
471
+ label="Input"
472
+ defaultOpen={context?.sectionOpen}
473
+ onOpenChange={context?.onSectionOpenChange}
474
+ >
461
475
  <EncryptedFieldBlock />
462
476
  </Collapsible>
463
477
  );
@@ -480,7 +494,12 @@ const attributeToDisplayFn: Record<
480
494
  if (conversation && conversation.length > 0) {
481
495
  return (
482
496
  <>
483
- <ConversationWithTabs conversation={conversation} args={args} />
497
+ <ConversationWithTabs
498
+ conversation={conversation}
499
+ args={args}
500
+ defaultOpen={context?.sectionOpen}
501
+ onOpenChange={context?.onSectionOpenChange}
502
+ />
484
503
  {hasClosureVars && (
485
504
  <Collapsible label="Closure Variables">
486
505
  {JsonBlock(closureVars)}
@@ -503,7 +522,11 @@ const attributeToDisplayFn: Record<
503
522
 
504
523
  return (
505
524
  <>
506
- <Collapsible label="Input">
525
+ <Collapsible
526
+ label="Input"
527
+ defaultOpen={context?.sectionOpen}
528
+ onOpenChange={context?.onSectionOpenChange}
529
+ >
507
530
  {Array.isArray(args)
508
531
  ? args.map((v, i) => (
509
532
  <div className="mt-2 first:mt-0" key={i}>
@@ -529,7 +552,11 @@ const attributeToDisplayFn: Record<
529
552
  return <Collapsible label="Input (no data)" disabled />;
530
553
  }
531
554
  return (
532
- <Collapsible label="Input">
555
+ <Collapsible
556
+ label="Input"
557
+ defaultOpen={context?.sectionOpen}
558
+ onOpenChange={context?.onSectionOpenChange}
559
+ >
533
560
  {Array.isArray(value)
534
561
  ? value.map((v, i) => (
535
562
  <div className="mt-2 first:mt-0" key={i}>
@@ -540,17 +567,29 @@ const attributeToDisplayFn: Record<
540
567
  </Collapsible>
541
568
  );
542
569
  },
543
- output: (value: unknown) => {
570
+ output: (value: unknown, context?: DisplayContext) => {
544
571
  if (isEncryptedMarker(value)) {
545
572
  return (
546
- <Collapsible label="Output">
573
+ <Collapsible
574
+ label="Output"
575
+ defaultOpen={context?.sectionOpen}
576
+ onOpenChange={context?.onSectionOpenChange}
577
+ >
547
578
  <EncryptedFieldBlock />
548
579
  </Collapsible>
549
580
  );
550
581
  }
551
582
  if (!hasDisplayContent(value)) return null;
552
583
  if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
553
- return <Collapsible label="Output">{JsonBlock(value)}</Collapsible>;
584
+ return (
585
+ <Collapsible
586
+ label="Output"
587
+ defaultOpen={context?.sectionOpen}
588
+ onOpenChange={context?.onSectionOpenChange}
589
+ >
590
+ {JsonBlock(value)}
591
+ </Collapsible>
592
+ );
554
593
  },
555
594
  error: (value: unknown) => {
556
595
  if (isEncryptedMarker(value)) {
@@ -642,6 +681,12 @@ const copyableBasicAttributes = new Set<AttributeKey>([
642
681
  'moduleSpecifier',
643
682
  ]);
644
683
 
684
+ const loadingSectionLabels: Partial<Record<AttributeKey, string>> = {
685
+ input: 'Input',
686
+ output: 'Output',
687
+ eventData: 'Event Data',
688
+ };
689
+
645
690
  export const AttributeBlock = ({
646
691
  attribute,
647
692
  value,
@@ -656,20 +701,19 @@ export const AttributeBlock = ({
656
701
  context?: DisplayContext;
657
702
  }) => {
658
703
  const decryptCtx = useContext(DecryptClickContext);
659
- const isExpandableLoadingTarget =
660
- attribute === 'input' ||
661
- attribute === 'output' ||
662
- attribute === 'eventData';
663
- if (isLoading && isExpandableLoadingTarget && !hasDisplayContent(value)) {
664
- const label =
665
- attribute === 'eventData'
666
- ? 'Event Data'
667
- : attribute === 'output'
668
- ? 'Output'
669
- : 'Input';
704
+ const sectionOpenRef = useRef(false);
705
+ const handleSectionOpenChange = useCallback((open: boolean) => {
706
+ sectionOpenRef.current = open;
707
+ }, []);
708
+ const label = loadingSectionLabels[attribute as AttributeKey];
709
+ if (isLoading && label && !hasDisplayContent(value)) {
670
710
  if (decryptCtx?.hasEncryptedData) {
671
711
  return (
672
- <Collapsible label={label} defaultOpen={attribute === 'eventData'}>
712
+ <Collapsible
713
+ label={label}
714
+ defaultOpen={attribute === 'eventData' || sectionOpenRef.current}
715
+ onOpenChange={handleSectionOpenChange}
716
+ >
673
717
  <EncryptedFieldBlock />
674
718
  </Collapsible>
675
719
  );
@@ -682,7 +726,11 @@ export const AttributeBlock = ({
682
726
  if (!displayFn) {
683
727
  return null;
684
728
  }
685
- const displayValue = displayFn(value, context);
729
+ const displayValue = displayFn(value, {
730
+ ...context,
731
+ sectionOpen: sectionOpenRef.current,
732
+ onSectionOpenChange: handleSectionOpenChange,
733
+ });
686
734
  if (!displayValue) {
687
735
  return null;
688
736
  }
@@ -732,6 +780,7 @@ export const AttributeBlock = ({
732
780
  export const AttributePanel = ({
733
781
  data,
734
782
  moduleSpecifier,
783
+ moduleSourceUrl,
735
784
  isLoading,
736
785
  error,
737
786
  expiredAt,
@@ -743,6 +792,7 @@ export const AttributePanel = ({
743
792
  }: {
744
793
  data: Record<string, unknown>;
745
794
  moduleSpecifier?: string;
795
+ moduleSourceUrl?: string;
746
796
  isLoading?: boolean;
747
797
  error?: Error;
748
798
  expiredAt?: string | Date;
@@ -877,6 +927,11 @@ export const AttributePanel = ({
877
927
  copyText={
878
928
  isCopyableBasicAttribute ? displayValue : undefined
879
929
  }
930
+ href={
931
+ attribute === 'moduleSpecifier'
932
+ ? moduleSourceUrl
933
+ : undefined
934
+ }
880
935
  />
881
936
  );
882
937
  })}
@@ -5,6 +5,7 @@ import {
5
5
  RESERVED_ATTRIBUTE_KEY_PREFIX,
6
6
  } from '@workflow/world';
7
7
  import { cva, type VariantProps } from 'class-variance-authority';
8
+ import { ArrowUpRight } from 'lucide-react';
8
9
  import type { ReactNode } from 'react';
9
10
  import { cn } from '../../lib/cn';
10
11
  import { CopyButton } from '../new-trace-viewer/components/copy-button';
@@ -21,7 +22,7 @@ function isReservedAttributeKey(key: string): boolean {
21
22
  }
22
23
 
23
24
  const rowValueVariants = cva(
24
- 'max-w-[60%] truncate text-right text-copy-13 text-gray-1000',
25
+ 'min-w-0 flex-1 truncate text-right text-copy-13 text-gray-1000',
25
26
  {
26
27
  variants: {
27
28
  variant: {
@@ -36,7 +37,7 @@ const rowValueVariants = cva(
36
37
  );
37
38
 
38
39
  const rowCopyValueVariants = cva(
39
- 'flex min-w-0 max-w-[60%] items-center justify-end gap-1 text-copy-13 text-gray-1000',
40
+ 'flex min-w-0 flex-1 items-center justify-end gap-1 text-copy-13 text-gray-1000',
40
41
  {
41
42
  variants: {
42
43
  variant: {
@@ -54,6 +55,7 @@ type DetailKeyValueRowProps = {
54
55
  label: string;
55
56
  value?: ReactNode;
56
57
  copyText?: string;
58
+ href?: string;
57
59
  removed?: boolean;
58
60
  };
59
61
 
@@ -61,6 +63,7 @@ function DetailKeyValueRowBase({
61
63
  label,
62
64
  value,
63
65
  copyText,
66
+ href,
64
67
  removed = false,
65
68
  variant,
66
69
  }: DetailKeyValueRowProps & VariantProps<typeof rowValueVariants>) {
@@ -77,13 +80,32 @@ function DetailKeyValueRowBase({
77
80
  </span>
78
81
  ) : copyText ? (
79
82
  <div className={cn(rowCopyValueVariants({ variant }))} title={copyText}>
80
- <MiddleTruncate
81
- value={copyText}
82
- className={cn(
83
- rowValueVariants({ variant, className: 'text-right' })
84
- )}
85
- style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}
86
- />
83
+ {href ? (
84
+ <a
85
+ href={href}
86
+ target="_blank"
87
+ rel="noreferrer"
88
+ className={cn(
89
+ rowValueVariants({ variant }),
90
+ 'flex min-w-0 items-center gap-0.5 hover:underline'
91
+ )}
92
+ >
93
+ <MiddleTruncate
94
+ value={copyText}
95
+ className="min-w-0 text-right"
96
+ style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}
97
+ />
98
+ <ArrowUpRight aria-hidden className="h-3 w-3 shrink-0" />
99
+ </a>
100
+ ) : (
101
+ <MiddleTruncate
102
+ value={copyText}
103
+ className={cn(
104
+ rowValueVariants({ variant, className: 'text-right' })
105
+ )}
106
+ style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}
107
+ />
108
+ )}
87
109
  <CopyButton
88
110
  copyText={copyText}
89
111
  ariaLabel={`Copy ${label}`}
@@ -92,7 +114,18 @@ function DetailKeyValueRowBase({
92
114
  </div>
93
115
  ) : (
94
116
  <span className={cn(rowValueVariants({ variant }))} title={stringValue}>
95
- {value}
117
+ {href ? (
118
+ <a
119
+ href={href}
120
+ target="_blank"
121
+ rel="noreferrer"
122
+ className="hover:underline"
123
+ >
124
+ {value}
125
+ </a>
126
+ ) : (
127
+ value
128
+ )}
96
129
  </span>
97
130
  )}
98
131
  </div>
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
3
4
  import type { Event, Hook, WorkflowRun } from '@workflow/world';
4
5
  import clsx from 'clsx';
5
6
  import { Send, Zap } from 'lucide-react';
@@ -62,6 +63,7 @@ export function EntityDetailPanel({
62
63
  isDecrypting = false,
63
64
  selectedSpan,
64
65
  showSeparateEventOccurrenceTimestamps = false,
66
+ getModuleSourceUrl,
65
67
  }: {
66
68
  run: WorkflowRun;
67
69
  /** Callback when a stream reference is clicked */
@@ -95,6 +97,10 @@ export function EntityDetailPanel({
95
97
  selectedSpan: SelectedSpanInfo | null;
96
98
  /** Show occurredAt separately instead of folding it into the Created timestamp. */
97
99
  showSeparateEventOccurrenceTimestamps?: boolean;
100
+ getModuleSourceUrl?: (info: {
101
+ moduleSpecifier: string;
102
+ deploymentId: string;
103
+ }) => string | undefined;
98
104
  }): React.JSX.Element | null {
99
105
  const toast = useToast();
100
106
  const [stoppingSleep, setStoppingSleep] = useState(false);
@@ -277,6 +283,21 @@ export function EntityDetailPanel({
277
283
  return undefined;
278
284
  }, [displayData, run.workflowName]);
279
285
 
286
+ const moduleSourceUrl = useMemo(() => {
287
+ if (!getModuleSourceUrl || !moduleSpecifier) return undefined;
288
+ const parsed =
289
+ parseStepName(moduleSpecifier) ?? parseWorkflowName(moduleSpecifier);
290
+ if (!parsed) return undefined;
291
+ const dataDeploymentId = displayData.deploymentId;
292
+ return getModuleSourceUrl({
293
+ moduleSpecifier: parsed.moduleSpecifier,
294
+ deploymentId:
295
+ typeof dataDeploymentId === 'string'
296
+ ? dataDeploymentId
297
+ : run.deploymentId,
298
+ });
299
+ }, [getModuleSourceUrl, moduleSpecifier, displayData, run.deploymentId]);
300
+
280
301
  if (!selectedSpan || !resource || !resourceId) {
281
302
  return null;
282
303
  }
@@ -360,6 +381,7 @@ export function EntityDetailPanel({
360
381
  <AttributePanel
361
382
  data={displayData}
362
383
  moduleSpecifier={moduleSpecifier}
384
+ moduleSourceUrl={moduleSourceUrl}
363
385
  expiredAt={run.expiredAt}
364
386
  isLoading={loading}
365
387
  error={error ?? undefined}
@@ -29,6 +29,10 @@ export interface SidebarDataContextValue {
29
29
  hasEncryptedData?: boolean;
30
30
  /** Show occurredAt separately instead of folding it into the Created timestamp. */
31
31
  showSeparateEventOccurrenceTimestamps?: boolean;
32
+ getModuleSourceUrl?: (info: {
33
+ moduleSpecifier: string;
34
+ deploymentId: string;
35
+ }) => string | undefined;
32
36
  }
33
37
 
34
38
  const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatDurationPrecise } from './utils';
3
+
4
+ describe('formatDurationPrecise', () => {
5
+ it('shows whole milliseconds below 1s', () => {
6
+ expect(formatDurationPrecise(0)).toBe('0s');
7
+ expect(formatDurationPrecise(626)).toBe('626ms');
8
+ });
9
+
10
+ it('keeps sub-second precision instead of rounding up to a whole second', () => {
11
+ // The bug this guards: 1626ms must not read as "2s".
12
+ expect(formatDurationPrecise(1626)).toBe('1.63s');
13
+ });
14
+
15
+ it('trims trailing zeros so whole/half seconds read cleanly', () => {
16
+ expect(formatDurationPrecise(2000)).toBe('2s');
17
+ expect(formatDurationPrecise(1500)).toBe('1.5s');
18
+ expect(formatDurationPrecise(1600)).toBe('1.6s');
19
+ expect(formatDurationPrecise(45_000)).toBe('45s');
20
+ });
21
+
22
+ it('honors a custom fraction-digit count (used by the timeline ruler)', () => {
23
+ // Fewer digits also coarsen the rounding: 1 digit rounds to 100ms.
24
+ expect(formatDurationPrecise(1500, 1)).toBe('1.5s');
25
+ expect(formatDurationPrecise(1620, 1)).toBe('1.6s');
26
+ expect(formatDurationPrecise(2000, 1)).toBe('2s');
27
+ });
28
+
29
+ it('decomposes durations of a minute or more', () => {
30
+ expect(formatDurationPrecise(65_000)).toBe('1m 5s');
31
+ expect(formatDurationPrecise(63_450)).toBe('1m 3.45s');
32
+ });
33
+ });
package/src/lib/utils.ts CHANGED
@@ -74,25 +74,20 @@ export function formatDuration(ms: number, compact = false): string {
74
74
  return parts.join(' ');
75
75
  }
76
76
 
77
- // Locale-aware formatter that always renders exactly two fraction digits.
78
- // Used for the seconds component of precise durations so values are never
79
- // snapped to a whole second (and thousands separators stay correct).
80
- const preciseSecondsFormatter = new Intl.NumberFormat(undefined, {
81
- minimumFractionDigits: 2,
82
- maximumFractionDigits: 2,
83
- });
84
-
85
77
  /**
86
78
  * Formats a duration in milliseconds without snapping to whole seconds.
87
79
  *
88
- * Unlike {@link formatDuration}, this never rounds a sub-minute value up to
89
- * the next whole second (e.g. 1626ms renders as "1.63s", not "2s").
80
+ * Unlike {@link formatDuration}, this keeps sub-second detail, so 1626ms
81
+ * renders as "1.63s" rather than "2s". `fractionDigits` sets both the number of
82
+ * decimals on the seconds component (trailing zeros trimmed, so 2000ms is "2s"
83
+ * and 1500ms is "1.5s") and the rounding granularity — the default of 2 rounds
84
+ * to 10ms; the timeline ruler passes fewer digits to match its tick step.
90
85
  *
91
86
  * - < 1s: shows whole milliseconds (e.g. "626ms")
92
- * - 1s – 1m: shows seconds with two decimals (e.g. "1.63s", "45.20s")
93
- * - >= 1m: decomposes into d/h/m with two-decimal seconds (e.g. "1m 13.45s")
87
+ * - 1s – 1m: shows seconds with trimmed decimals (e.g. "1.63s", "1.5s", "45s")
88
+ * - >= 1m: decomposes into d/h/m with trimmed-decimal seconds (e.g. "1m 13.45s")
94
89
  */
95
- export function formatDurationPrecise(ms: number): string {
90
+ export function formatDurationPrecise(ms: number, fractionDigits = 2): string {
96
91
  if (ms === 0) {
97
92
  return '0s';
98
93
  }
@@ -104,10 +99,11 @@ export function formatDurationPrecise(ms: number): string {
104
99
  }
105
100
  }
106
101
 
107
- const normalizedMs = Math.round(ms / 10) * 10;
102
+ const granularityMs = MS_IN_SECOND / 10 ** fractionDigits;
103
+ const normalizedMs = Math.round(ms / granularityMs) * granularityMs;
108
104
 
109
105
  if (normalizedMs < MS_IN_MINUTE) {
110
- return `${preciseSecondsFormatter.format(normalizedMs / MS_IN_SECOND)}s`;
106
+ return `${Number((normalizedMs / MS_IN_SECOND).toFixed(fractionDigits))}s`;
111
107
  }
112
108
 
113
109
  const days = Math.floor(normalizedMs / MS_IN_DAY);
@@ -126,7 +122,7 @@ export function formatDurationPrecise(ms: number): string {
126
122
  if (minutes > 0) {
127
123
  parts.push(`${minutes}m`);
128
124
  }
129
- parts.push(`${preciseSecondsFormatter.format(seconds)}s`);
125
+ parts.push(`${Number(seconds.toFixed(fractionDigits))}s`);
130
126
 
131
127
  return parts.join(' ');
132
128
  }