@workflow/web-shared 5.0.0-beta.13 → 5.0.0-beta.14

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 (32) hide show
  1. package/dist/components/new-trace-viewer/components/event-list.js +3 -3
  2. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  3. package/dist/components/new-trace-viewer/components/timeline.js +4 -4
  4. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  5. package/dist/components/sidebar/attribute-panel.js +8 -4
  6. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  7. package/dist/components/sidebar/entity-detail-panel.js +12 -11
  8. package/dist/components/sidebar/events-list.d.ts +3 -1
  9. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  10. package/dist/components/sidebar/events-list.js +4 -3
  11. package/dist/components/sidebar/span-detail-merge.d.ts +16 -0
  12. package/dist/components/sidebar/span-detail-merge.d.ts.map +1 -0
  13. package/dist/components/sidebar/span-detail-merge.js +53 -0
  14. package/dist/components/trace-viewer/util/timing.d.ts +2 -2
  15. package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
  16. package/dist/components/trace-viewer/util/timing.js +3 -3
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/lib/utils.d.ts +11 -0
  21. package/dist/lib/utils.d.ts.map +1 -1
  22. package/dist/lib/utils.js +49 -1
  23. package/package.json +3 -3
  24. package/src/components/new-trace-viewer/components/event-list.tsx +2 -2
  25. package/src/components/new-trace-viewer/components/timeline.tsx +7 -3
  26. package/src/components/sidebar/attribute-panel.tsx +7 -3
  27. package/src/components/sidebar/entity-detail-panel.tsx +28 -13
  28. package/src/components/sidebar/events-list.tsx +34 -25
  29. package/src/components/sidebar/span-detail-merge.ts +65 -0
  30. package/src/components/trace-viewer/util/timing.ts +2 -2
  31. package/src/index.ts +1 -0
  32. package/src/lib/utils.ts +57 -0
@@ -10,6 +10,10 @@ import { AttributePanel } from './attribute-panel';
10
10
  import { EventsList } from './events-list';
11
11
  import { ResolveHookModal } from './resolve-hook-modal';
12
12
  import { useSidebarDataOptional } from './sidebar-data-context';
13
+ import {
14
+ mergeSpanDetail,
15
+ spanDetailMatchesSelection,
16
+ } from './span-detail-merge';
13
17
 
14
18
  // Type guards for runtime validation of span attribute data
15
19
  function isStep(data: unknown): data is Step {
@@ -215,19 +219,27 @@ export function EntityDetailPanel({
215
219
  const error = spanDetailError ?? undefined;
216
220
  const loading = spanDetailLoading ?? false;
217
221
 
222
+ const matchedSpanDetailData = useMemo(
223
+ () =>
224
+ spanDetailMatchesSelection(spanDetailData, resource, resourceId)
225
+ ? spanDetailData
226
+ : null,
227
+ [spanDetailData, resource, resourceId]
228
+ );
229
+
218
230
  // Get the hook token for resolving (prefer fetched data, then hooks array fallback)
219
231
  const hookToken = useMemo(() => {
220
232
  if (resource !== 'hook' || !resourceId) return undefined;
221
233
  // 1. Try the externally-fetched detail data first
222
- if (isHook(spanDetailData) && spanDetailData.token) {
223
- return spanDetailData.token;
234
+ if (isHook(matchedSpanDetailData) && matchedSpanDetailData.token) {
235
+ return matchedSpanDetailData.token;
224
236
  }
225
237
  // 2. Try the span's inline data (reconstructed from hook_created event)
226
238
  if (isHook(data) && (data as Hook).token) {
227
239
  return (data as Hook).token;
228
240
  }
229
241
  return undefined;
230
- }, [resource, resourceId, spanDetailData, data]);
242
+ }, [resource, resourceId, matchedSpanDetailData, data]);
231
243
 
232
244
  useEffect(() => {
233
245
  if (error && selectedSpan && resource) {
@@ -289,7 +301,7 @@ export function EntityDetailPanel({
289
301
 
290
302
  try {
291
303
  setResolvingHook(true);
292
- const candidate = spanDetailData ?? data;
304
+ const candidate = matchedSpanDetailData ?? data;
293
305
  const hook = isHook(candidate) ? candidate : undefined;
294
306
  await onResolveHook(hookToken, payload, hook);
295
307
  toast.success('Hook resolved', {
@@ -310,17 +322,18 @@ export function EntityDetailPanel({
310
322
  setResolvingHook(false);
311
323
  }
312
324
  },
313
- [onResolveHook, hookToken, resolvingHook, spanDetailData, data]
325
+ [onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
314
326
  );
315
327
 
316
- // Prefer externally-fetched details when available. For sleep spans, the
317
- // host fetches full correlated events (withData=true) and materializes a wait
318
- // entity, so this includes resumeAt/completedAt without bloating trace payloads.
319
- const displayData = (spanDetailData ?? data) as
320
- | WorkflowRun
321
- | Step
322
- | Hook
323
- | Event;
328
+ const displayData = useMemo(
329
+ () =>
330
+ mergeSpanDetail(data, matchedSpanDetailData) as
331
+ | WorkflowRun
332
+ | Step
333
+ | Hook
334
+ | Event,
335
+ [data, matchedSpanDetailData]
336
+ );
324
337
 
325
338
  const moduleSpecifier = useMemo(() => {
326
339
  const displayRecord = displayData as Record<string, unknown>;
@@ -435,6 +448,8 @@ export function EntityDetailPanel({
435
448
  <EventsList
436
449
  events={rawEvents}
437
450
  onLoadEventData={onLoadEventData}
451
+ onStreamClick={onStreamClick}
452
+ onRunClick={onRunClick}
438
453
  encryptionKey={encryptionKey}
439
454
  />
440
455
  )}
@@ -3,6 +3,7 @@
3
3
  import { EVENT_DATA_REF_FIELDS, type Event } from '@workflow/world';
4
4
  import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
5
5
  import { hasEncryptedFields, isExpiredMarker } from '../../lib/hydration';
6
+ import { RunClickContext, StreamClickContext } from '../ui/data-inspector';
6
7
  import { ErrorCard } from '../ui/error-card';
7
8
  import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
8
9
  import { Skeleton } from '../ui/skeleton';
@@ -256,6 +257,8 @@ export function EventsList({
256
257
  isLoading = false,
257
258
  error,
258
259
  onLoadEventData,
260
+ onStreamClick,
261
+ onRunClick,
259
262
  encryptionKey,
260
263
  }: {
261
264
  events: Event[];
@@ -265,6 +268,8 @@ export function EventsList({
265
268
  correlationId: string,
266
269
  eventId: string
267
270
  ) => Promise<unknown | null>;
271
+ onStreamClick?: (streamId: string) => void;
272
+ onRunClick?: (runId: string) => void;
268
273
  /** When provided, signals that decryption is active (triggers re-load of expanded events) */
269
274
  encryptionKey?: Uint8Array;
270
275
  }) {
@@ -285,31 +290,35 @@ export function EventsList({
285
290
  }
286
291
 
287
292
  return (
288
- <DetailCard summary="Events" contentClassName="mb-0" defaultOpen>
289
- {isLoading ? (
290
- <div className="flex flex-col -mx-4">
291
- {[0, 1, 2].map((i) => (
292
- <div
293
- key={i}
294
- className="flex items-center justify-between gap-3 bg-background-200 px-4 py-2"
295
- >
296
- <Skeleton className="h-4 w-32 rounded" />
297
- <Skeleton className="h-3 w-16 rounded" />
293
+ <RunClickContext.Provider value={onRunClick}>
294
+ <StreamClickContext.Provider value={onStreamClick}>
295
+ <DetailCard summary="Events" contentClassName="mb-0" defaultOpen>
296
+ {isLoading ? (
297
+ <div className="flex flex-col -mx-4">
298
+ {[0, 1, 2].map((i) => (
299
+ <div
300
+ key={i}
301
+ className="flex items-center justify-between gap-3 bg-background-200 px-4 py-2"
302
+ >
303
+ <Skeleton className="h-4 w-32 rounded" />
304
+ <Skeleton className="h-3 w-16 rounded" />
305
+ </div>
306
+ ))}
298
307
  </div>
299
- ))}
300
- </div>
301
- ) : (
302
- <div className="flex flex-col -mx-4">
303
- {sortedEvents.map((event) => (
304
- <EventItem
305
- key={event.eventId}
306
- event={event}
307
- onLoadEventData={onLoadEventData}
308
- encryptionKey={encryptionKey}
309
- />
310
- ))}
311
- </div>
312
- )}
313
- </DetailCard>
308
+ ) : (
309
+ <div className="flex flex-col -mx-4">
310
+ {sortedEvents.map((event) => (
311
+ <EventItem
312
+ key={event.eventId}
313
+ event={event}
314
+ onLoadEventData={onLoadEventData}
315
+ encryptionKey={encryptionKey}
316
+ />
317
+ ))}
318
+ </div>
319
+ )}
320
+ </DetailCard>
321
+ </StreamClickContext.Provider>
322
+ </RunClickContext.Provider>
314
323
  );
315
324
  }
@@ -0,0 +1,65 @@
1
+ const hasField = (
2
+ value: object,
3
+ key: string
4
+ ): value is Record<string, unknown> => key in value;
5
+
6
+ /**
7
+ * Returns true when the fetched `detail` belongs to the current selection.
8
+ * The fetch lags selection, so it can briefly hold a previously selected span
9
+ * (even a different resource type); reject it before merging or its fields
10
+ * union into the wrong panel. Steps/hooks/waits carry their parent `runId`, so
11
+ * a `run` selection excludes objects identifiable as a child resource.
12
+ */
13
+ export function spanDetailMatchesSelection(
14
+ detail: unknown,
15
+ resource: string | undefined,
16
+ resourceId: string | undefined
17
+ ): boolean {
18
+ if (!detail || typeof detail !== 'object' || !resource || !resourceId) {
19
+ return false;
20
+ }
21
+ switch (resource) {
22
+ case 'step':
23
+ return hasField(detail, 'stepId') && detail.stepId === resourceId;
24
+ case 'hook':
25
+ return hasField(detail, 'hookId') && detail.hookId === resourceId;
26
+ case 'sleep':
27
+ return hasField(detail, 'waitId') && detail.waitId === resourceId;
28
+ case 'run':
29
+ return (
30
+ hasField(detail, 'runId') &&
31
+ !('stepId' in detail) &&
32
+ !('hookId' in detail) &&
33
+ !('waitId' in detail) &&
34
+ detail.runId === resourceId
35
+ );
36
+ default:
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Merges fetched `detail` over the span's own data. The detail supplies the
43
+ * heavy fields the trace strips (input/output/error/metadata, sleep resumeAt);
44
+ * the span's identity, status, and event-derived timestamps stay authoritative
45
+ * so they don't flicker to the backend row's millisecond-different values.
46
+ */
47
+ export function mergeSpanDetail(spanData: unknown, detail: unknown): unknown {
48
+ if (!detail || typeof detail !== 'object') {
49
+ return spanData;
50
+ }
51
+ if (!spanData || typeof spanData !== 'object') {
52
+ return detail;
53
+ }
54
+ // Skip `undefined` span fields so they don't clobber a value the detail
55
+ // legitimately provides (e.g. a step's optional startedAt).
56
+ const merged: Record<string, unknown> = { ...detail };
57
+ for (const [key, value] of Object.entries(
58
+ spanData as Record<string, unknown>
59
+ )) {
60
+ if (value !== undefined) {
61
+ merged[key] = value;
62
+ }
63
+ }
64
+ return merged;
65
+ }
@@ -1,6 +1,6 @@
1
- import { formatDuration } from '../../../lib/utils';
1
+ import { formatDuration, formatDurationPrecise } from '../../../lib/utils';
2
2
 
3
- export { formatDuration };
3
+ export { formatDuration, formatDurationPrecise };
4
4
 
5
5
  export function getHighResInMs([seconds, nanoseconds]: [
6
6
  number,
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ export type { StreamStep } from './lib/utils';
64
64
  export {
65
65
  extractConversation,
66
66
  formatDuration,
67
+ formatDurationPrecise,
67
68
  identifyStreamSteps,
68
69
  isDoStreamStep,
69
70
  } from './lib/utils';
package/src/lib/utils.ts CHANGED
@@ -80,6 +80,63 @@ export function formatDuration(ms: number, compact = false): string {
80
80
  return parts.join(' ');
81
81
  }
82
82
 
83
+ // Locale-aware formatter that always renders exactly two fraction digits.
84
+ // Used for the seconds component of precise durations so values are never
85
+ // snapped to a whole second (and thousands separators stay correct).
86
+ const preciseSecondsFormatter = new Intl.NumberFormat(undefined, {
87
+ minimumFractionDigits: 2,
88
+ maximumFractionDigits: 2,
89
+ });
90
+
91
+ /**
92
+ * Formats a duration in milliseconds without snapping to whole seconds.
93
+ *
94
+ * Unlike {@link formatDuration}, this never rounds a sub-minute value up to
95
+ * the next whole second (e.g. 1626ms renders as "1.63s", not "2s").
96
+ *
97
+ * - < 1s: shows whole milliseconds (e.g. "626ms")
98
+ * - 1s – 1m: shows seconds with two decimals (e.g. "1.63s", "45.20s")
99
+ * - >= 1m: decomposes into d/h/m with two-decimal seconds (e.g. "1m 13.45s")
100
+ */
101
+ export function formatDurationPrecise(ms: number): string {
102
+ if (ms === 0) {
103
+ return '0s';
104
+ }
105
+
106
+ if (ms < MS_IN_SECOND) {
107
+ const roundedMs = Math.round(ms);
108
+ if (roundedMs < MS_IN_SECOND) {
109
+ return `${roundedMs}ms`;
110
+ }
111
+ }
112
+
113
+ const normalizedMs = Math.round(ms / 10) * 10;
114
+
115
+ if (normalizedMs < MS_IN_MINUTE) {
116
+ return `${preciseSecondsFormatter.format(normalizedMs / MS_IN_SECOND)}s`;
117
+ }
118
+
119
+ const days = Math.floor(normalizedMs / MS_IN_DAY);
120
+ const hours = Math.floor((normalizedMs % MS_IN_DAY) / MS_IN_HOUR);
121
+ const minutes = Math.floor((normalizedMs % MS_IN_HOUR) / MS_IN_MINUTE);
122
+ const seconds = (normalizedMs % MS_IN_MINUTE) / MS_IN_SECOND;
123
+
124
+ const parts: string[] = [];
125
+
126
+ if (days > 0) {
127
+ parts.push(`${days}d`);
128
+ }
129
+ if (hours > 0) {
130
+ parts.push(`${hours}h`);
131
+ }
132
+ if (minutes > 0) {
133
+ parts.push(`${minutes}m`);
134
+ }
135
+ parts.push(`${preciseSecondsFormatter.format(seconds)}s`);
136
+
137
+ return parts.join(' ');
138
+ }
139
+
83
140
  /**
84
141
  * Returns a formatted pagination display string
85
142
  * @param currentPage - The current page number