@workflow/web-shared 5.0.0-beta.12 → 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 (75) hide show
  1. package/dist/components/event-list-view.d.ts.map +1 -1
  2. package/dist/components/event-list-view.js +9 -9
  3. package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
  4. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  5. package/dist/components/new-trace-viewer/components/event-list.js +6 -4
  6. package/dist/components/new-trace-viewer/components/timeline.d.ts +1 -1
  7. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  8. package/dist/components/new-trace-viewer/components/timeline.js +7 -7
  9. package/dist/components/new-trace-viewer/context.d.ts +1 -1
  10. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  11. package/dist/components/new-trace-viewer/context.js +1 -1
  12. package/dist/components/new-trace-viewer/icons.js +3 -3
  13. package/dist/components/new-trace-viewer/search.d.ts +1 -1
  14. package/dist/components/new-trace-viewer/search.d.ts.map +1 -1
  15. package/dist/components/new-trace-viewer/search.js +1 -1
  16. package/dist/components/new-trace-viewer/trace-viewer.d.ts +2 -2
  17. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  18. package/dist/components/new-trace-viewer/trace-viewer.js +6 -7
  19. package/dist/components/new-trace-viewer/types.d.ts +2 -0
  20. package/dist/components/new-trace-viewer/types.d.ts.map +1 -1
  21. package/dist/components/new-trace-viewer/types.js +2 -2
  22. package/dist/components/new-trace-viewer/utils.d.ts +1 -1
  23. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  24. package/dist/components/new-trace-viewer/utils.js +1 -1
  25. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  26. package/dist/components/sidebar/attribute-panel.js +14 -10
  27. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  28. package/dist/components/sidebar/entity-detail-panel.js +12 -11
  29. package/dist/components/sidebar/events-list.d.ts +3 -1
  30. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  31. package/dist/components/sidebar/events-list.js +10 -9
  32. package/dist/components/sidebar/span-detail-merge.d.ts +16 -0
  33. package/dist/components/sidebar/span-detail-merge.d.ts.map +1 -0
  34. package/dist/components/sidebar/span-detail-merge.js +53 -0
  35. package/dist/components/trace-viewer/util/timing.d.ts +2 -2
  36. package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
  37. package/dist/components/trace-viewer/util/timing.js +3 -3
  38. package/dist/components/trace-viewer-new.d.ts.map +1 -1
  39. package/dist/components/trace-viewer-new.js +2 -3
  40. package/dist/components/ui/error-stack-block.d.ts +17 -10
  41. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  42. package/dist/components/ui/error-stack-block.js +30 -19
  43. package/dist/index.d.ts +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -2
  46. package/dist/lib/utils.d.ts +11 -0
  47. package/dist/lib/utils.d.ts.map +1 -1
  48. package/dist/lib/utils.js +49 -1
  49. package/package.json +3 -3
  50. package/src/components/event-list-view.tsx +12 -11
  51. package/src/components/new-trace-viewer/components/event-list.tsx +6 -5
  52. package/src/components/new-trace-viewer/components/timeline.tsx +11 -7
  53. package/src/components/new-trace-viewer/context.tsx +1 -1
  54. package/src/components/new-trace-viewer/icons.tsx +2 -2
  55. package/src/components/new-trace-viewer/search.ts +1 -1
  56. package/src/components/new-trace-viewer/trace-viewer.tsx +7 -14
  57. package/src/components/new-trace-viewer/types.ts +2 -0
  58. package/src/components/new-trace-viewer/utils.ts +1 -1
  59. package/src/components/sidebar/attribute-panel.tsx +13 -12
  60. package/src/components/sidebar/entity-detail-panel.tsx +28 -13
  61. package/src/components/sidebar/events-list.tsx +40 -34
  62. package/src/components/sidebar/span-detail-merge.ts +65 -0
  63. package/src/components/trace-viewer/util/timing.ts +2 -2
  64. package/src/components/trace-viewer-new.tsx +2 -4
  65. package/src/components/ui/error-stack-block.tsx +51 -31
  66. package/src/index.ts +1 -0
  67. package/src/lib/utils.ts +57 -0
  68. package/dist/components/new-trace-viewer/detail-panel.d.ts +0 -10
  69. package/dist/components/new-trace-viewer/detail-panel.d.ts.map +0 -1
  70. package/dist/components/new-trace-viewer/detail-panel.js +0 -13
  71. package/dist/components/new-trace-viewer/util/constants.d.ts +0 -1
  72. package/dist/components/new-trace-viewer/util/constants.d.ts.map +0 -1
  73. package/dist/components/new-trace-viewer/util/constants.js +0 -2
  74. package/src/components/new-trace-viewer/detail-panel.tsx +0 -55
  75. package/src/components/new-trace-viewer/util/constants.ts +0 -0
@@ -8,20 +8,17 @@ import { useCallback, useContext, useMemo, useState } from 'react';
8
8
  import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration';
9
9
  import { useToast } from '../../lib/toast';
10
10
  import { extractConversation, isDoStreamStep } from '../../lib/utils';
11
+ import { CopyButton } from '../new-trace-viewer/components/copy-button';
12
+ import { MiddleTruncate } from '../new-trace-viewer/components/middle-truncate/middle-truncate';
11
13
  import {
12
14
  DecryptClickContext,
13
15
  RunClickContext,
14
16
  StreamClickContext,
15
17
  } from '../ui/data-inspector';
16
18
  import { ErrorCard } from '../ui/error-card';
17
- import {
18
- ErrorStackBlock,
19
- isStructuredErrorWithStack,
20
- } from '../ui/error-stack-block';
19
+ import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
21
20
  import { Skeleton } from '../ui/skeleton';
22
21
  import { TimestampTooltip } from '../ui/timestamp-tooltip';
23
- import { CopyButton } from '../new-trace-viewer/components/copy-button';
24
- import { MiddleTruncate } from '../new-trace-viewer/components/middle-truncate/middle-truncate';
25
22
  import { ConversationView } from './conversation-view';
26
23
  import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block';
27
24
  import { DetailCard } from './detail-card';
@@ -264,6 +261,7 @@ const attributeOrder: AttributeKey[] = [
264
261
  'eventData',
265
262
  'input',
266
263
  'output',
264
+ 'attributes',
267
265
  'resumeAt',
268
266
  ];
269
267
 
@@ -287,6 +285,7 @@ const attributeDisplayNames: Partial<Record<AttributeKey, string>> = {
287
285
  runId: 'Run ID',
288
286
  token: 'Token',
289
287
  eventType: 'Event Type',
288
+ errorCode: 'Error Code',
290
289
  correlationId: 'Correlation ID',
291
290
  deploymentId: 'Deployment ID',
292
291
  specVersion: 'Spec Version',
@@ -418,11 +417,11 @@ const attributeToDisplayFn: Record<
418
417
  environment: (_value: unknown) => null,
419
418
  executionContext: (_value: unknown) => null,
420
419
  // Attributes MVP — string-string metadata attached to the run.
421
- // Rendered as a JSON block; if empty/missing, hidden by the
422
- // hasDisplayContent gate above.
420
+ // Rendered in its own collapsible DetailCard; if empty/missing, hidden
421
+ // by the hasDisplayContent gate.
423
422
  attributes: (value: unknown) => {
424
423
  if (!hasDisplayContent(value)) return null;
425
- return JsonBlock(value);
424
+ return <DetailCard summary="Attributes">{JsonBlock(value)}</DetailCard>;
426
425
  },
427
426
  // Dates — wrapped with TimestampTooltip showing UTC/local + relative time
428
427
  createdAt: timestampWithTooltipOrNull,
@@ -548,9 +547,9 @@ const attributeToDisplayFn: Record<
548
547
  if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
549
548
  if (!hasDisplayContent(value)) return null;
550
549
 
551
- // If the error object has a `stack` field, render it as readable
552
- // pre-formatted text. Otherwise fall back to the raw JSON viewer.
553
- if (isStructuredErrorWithStack(value)) {
550
+ // Structured workflow errors may be persisted as either `{ message }` or
551
+ // `{ message, stack }`. Render both with the dedicated error block.
552
+ if (isStructuredError(value)) {
554
553
  return (
555
554
  <DetailCard summary="Error" defaultOpen>
556
555
  <ErrorStackBlock value={value} />
@@ -591,6 +590,7 @@ const resolvableAttributes = [
591
590
  'output',
592
591
  'error',
593
592
  'metadata',
593
+ 'attributes',
594
594
  'eventData',
595
595
  ];
596
596
 
@@ -600,6 +600,7 @@ const selfHeaderedAttributes = new Set([
600
600
  'input',
601
601
  'output',
602
602
  'error',
603
+ 'attributes',
603
604
  'eventData',
604
605
  ]);
605
606
 
@@ -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,11 +3,9 @@
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
- import {
8
- ErrorStackBlock,
9
- isStructuredErrorWithStack,
10
- } from '../ui/error-stack-block';
8
+ import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
11
9
  import { Skeleton } from '../ui/skeleton';
12
10
  import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block';
13
11
  import { DetailCard } from './detail-card';
@@ -196,8 +194,8 @@ function hasOnlyExpiredFields(data: unknown, eventType: string): boolean {
196
194
  }
197
195
 
198
196
  /**
199
- * Renders event data, using ErrorStackBlock for error events that contain
200
- * a structured error with a stack trace, and CopyableDataBlock otherwise.
197
+ * Renders event data, using ErrorStackBlock for error events that contain a
198
+ * structured error, and CopyableDataBlock otherwise.
201
199
  */
202
200
  function EventDataBlock({
203
201
  eventType,
@@ -239,12 +237,12 @@ function EventDataBlock({
239
237
  const record = data as Record<string, unknown>;
240
238
 
241
239
  // Check the nested `error` field first (the StructuredError)
242
- if (isStructuredErrorWithStack(record.error)) {
240
+ if (isStructuredError(record.error)) {
243
241
  return <ErrorStackBlock value={record.error} />;
244
242
  }
245
243
 
246
- // Some error formats put the stack at the top level of eventData
247
- if (isStructuredErrorWithStack(record)) {
244
+ // Some error formats put the message/stack at the top level of eventData.
245
+ if (isStructuredError(record)) {
248
246
  return <ErrorStackBlock value={record} />;
249
247
  }
250
248
  }
@@ -259,6 +257,8 @@ export function EventsList({
259
257
  isLoading = false,
260
258
  error,
261
259
  onLoadEventData,
260
+ onStreamClick,
261
+ onRunClick,
262
262
  encryptionKey,
263
263
  }: {
264
264
  events: Event[];
@@ -268,6 +268,8 @@ export function EventsList({
268
268
  correlationId: string,
269
269
  eventId: string
270
270
  ) => Promise<unknown | null>;
271
+ onStreamClick?: (streamId: string) => void;
272
+ onRunClick?: (runId: string) => void;
271
273
  /** When provided, signals that decryption is active (triggers re-load of expanded events) */
272
274
  encryptionKey?: Uint8Array;
273
275
  }) {
@@ -288,31 +290,35 @@ export function EventsList({
288
290
  }
289
291
 
290
292
  return (
291
- <DetailCard summary="Events" contentClassName="mb-0" defaultOpen>
292
- {isLoading ? (
293
- <div className="flex flex-col -mx-4">
294
- {[0, 1, 2].map((i) => (
295
- <div
296
- key={i}
297
- className="flex items-center justify-between gap-3 bg-background-200 px-4 py-2"
298
- >
299
- <Skeleton className="h-4 w-32 rounded" />
300
- <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
+ ))}
301
307
  </div>
302
- ))}
303
- </div>
304
- ) : (
305
- <div className="flex flex-col -mx-4">
306
- {sortedEvents.map((event) => (
307
- <EventItem
308
- key={event.eventId}
309
- event={event}
310
- onLoadEventData={onLoadEventData}
311
- encryptionKey={encryptionKey}
312
- />
313
- ))}
314
- </div>
315
- )}
316
- </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>
317
323
  );
318
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,
@@ -8,7 +8,6 @@ import {
8
8
  type SidebarDataContextValue,
9
9
  SidebarDataProvider,
10
10
  } from './sidebar/sidebar-data-context';
11
- import type { Trace } from './trace-viewer/types';
12
11
 
13
12
  const NewTraceViewer = ({
14
13
  run,
@@ -27,14 +26,13 @@ const NewTraceViewer = ({
27
26
  isLoadingMore?: boolean;
28
27
  loading?: boolean;
29
28
  }) => {
30
- const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
29
+ const trace: TraceWithMeta | undefined = useMemo(() => {
31
30
  if (!run?.runId) {
32
31
  return undefined;
33
32
  }
34
33
  return buildTrace(run, events, new Date());
35
34
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep
36
35
  }, [run, events]);
37
- const trace = traceWithMeta;
38
36
 
39
37
  if (!trace || (loading && events.length === 0)) {
40
38
  return <TraceViewerSkeleton />;
@@ -44,7 +42,7 @@ const NewTraceViewer = ({
44
42
  <SidebarDataProvider value={sidebarData}>
45
43
  <div className="relative w-full h-full flex">
46
44
  <NewTraceViewerComponent
47
- trace={trace as Trace}
45
+ trace={trace}
48
46
  onLoadMore={onLoadMore}
49
47
  hasMore={hasMore}
50
48
  isLoadingMore={isLoadingMore}
@@ -3,18 +3,36 @@
3
3
  import { AlertCircle } from 'lucide-react';
4
4
  import { CopyButton } from '../new-trace-viewer/components/copy-button';
5
5
 
6
+ export type StructuredErrorRecord = Record<string, unknown> & {
7
+ message?: string;
8
+ stack?: string;
9
+ };
10
+
6
11
  /**
7
- * Check whether `value` looks like a structured error object with a `stack`
8
- * field that we can render as pre-formatted text.
12
+ * Check whether `value` looks like a structured error object we can render
13
+ * with the error block. Some persisted workflow errors only include a
14
+ * `message`, while runtime errors usually also include `stack`.
9
15
  */
10
- export function isStructuredErrorWithStack(
16
+ export function isStructuredError(
11
17
  value: unknown
12
- ): value is Record<string, unknown> & { stack: string } {
18
+ ): value is StructuredErrorRecord {
13
19
  return (
14
20
  value != null &&
15
21
  typeof value === 'object' &&
16
- 'stack' in value &&
17
- typeof (value as Record<string, unknown>).stack === 'string'
22
+ (typeof (value as Record<string, unknown>).message === 'string' ||
23
+ typeof (value as Record<string, unknown>).stack === 'string')
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Narrower guard kept for callers that specifically need a stack trace.
29
+ */
30
+ export function isStructuredErrorWithStack(
31
+ value: unknown
32
+ ): value is StructuredErrorRecord & { stack: string } {
33
+ return (
34
+ isStructuredError(value) &&
35
+ typeof (value as StructuredErrorRecord).stack === 'string'
18
36
  );
19
37
  }
20
38
 
@@ -34,22 +52,22 @@ function deriveTitle(message: string): string {
34
52
  }
35
53
 
36
54
  /**
37
- * Renders an error with a `stack` field as a visually distinct error block.
38
- * Shows the error message with an alert icon at the top, separated from
39
- * the stack trace below.
55
+ * Renders a structured error as a visually distinct error block. Shows the
56
+ * error message with an alert icon at the top, separated from the stack trace
57
+ * or full message below.
40
58
  */
41
- export function ErrorStackBlock({
42
- value,
43
- }: {
44
- value: Record<string, unknown> & { stack: string };
45
- }) {
46
- const stack = value.stack;
59
+ export function ErrorStackBlock({ value }: { value: StructuredErrorRecord }) {
60
+ const stack = typeof value.stack === 'string' ? value.stack : undefined;
47
61
  const message = typeof value.message === 'string' ? value.message : undefined;
48
- // V8's `Error.stack` already starts with `Name: message`, so when the
49
- // body shows the stack it includes the full multi-line message anyway.
50
- // The header just needs the first line.
51
- const title = message ? deriveTitle(message) : undefined;
52
- const copyText = message ? `${message}\n\n${stack}` : stack;
62
+ const body = stack ?? message ?? '';
63
+ // V8's `Error.stack` already starts with `Name: message`; message-only
64
+ // errors use the message as the body so long single-line failures remain
65
+ // readable even when the header truncates.
66
+ const title = message ? deriveTitle(message) : deriveTitle(body);
67
+ const copyText =
68
+ message && stack && !stack.includes(message)
69
+ ? `${message}\n\n${stack}`
70
+ : body;
53
71
 
54
72
  return (
55
73
  <div
@@ -76,8 +94,8 @@ export function ErrorStackBlock({
76
94
  <AlertCircle className="h-4 w-4 shrink-0" />
77
95
  <p
78
96
  className="text-xs font-semibold m-0 truncate"
79
- // The full multi-line message is in the stack body below; the
80
- // header just shows the first line, single-line, with overflow
97
+ // The full message or stack is in the body below; the header just
98
+ // shows the first line, single-line, with overflow
81
99
  // ellipsised so a long title doesn't push the copy button or
82
100
  // wrap into the framed hint/docs lines.
83
101
  title={message}
@@ -86,15 +104,17 @@ export function ErrorStackBlock({
86
104
  </p>
87
105
  </div>
88
106
  )}
89
- <pre
90
- className="px-3 py-2.5 text-xs font-mono whitespace-pre-wrap break-words overflow-auto m-0"
91
- style={{
92
- color: 'var(--ds-red-900)',
93
- background: 'var(--ds-red-200)',
94
- }}
95
- >
96
- {stack}
97
- </pre>
107
+ {body && (
108
+ <pre
109
+ className="px-3 py-2.5 text-xs font-mono whitespace-pre-wrap break-words overflow-auto m-0"
110
+ style={{
111
+ color: 'var(--ds-red-900)',
112
+ background: 'var(--ds-red-200)',
113
+ }}
114
+ >
115
+ {body}
116
+ </pre>
117
+ )}
98
118
  </div>
99
119
  );
100
120
  }
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
@@ -1,10 +0,0 @@
1
- import type { ReactNode } from 'react';
2
- import type { Span } from '../trace-viewer/types';
3
- interface DetailPanelProps {
4
- span: Span;
5
- rootStart: number;
6
- onClose: () => void;
7
- }
8
- export declare function DetailPanel({ span, rootStart, onClose, }: DetailPanelProps): ReactNode;
9
- export {};
10
- //# sourceMappingURL=detail-panel.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"detail-panel.d.ts","sourceRoot":"","sources":["../../../src/components/new-trace-viewer/detail-panel.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAKlD,UAAU,gBAAgB;IACxB,IAAI,EAAE,IAAI,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,EAC1B,IAAI,EACJ,SAAS,EACT,OAAO,GACR,EAAE,gBAAgB,GAAG,SAAS,CAmC9B"}
@@ -1,13 +0,0 @@
1
- 'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { X } from 'lucide-react';
4
- import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
5
- import { IconButton } from '../ui/icon-button';
6
- import { getSpanDurationMs } from './utils';
7
- export function DetailPanel({ span, rootStart, onClose, }) {
8
- const startMs = getHighResInMs(span.startTime);
9
- const durationMs = getSpanDurationMs(span);
10
- const offsetMs = startMs - rootStart;
11
- return (_jsxs("aside", { className: "grid h-full max-h-full grid-rows-[2.5rem_1fr] bg-background-200", children: [_jsxs("div", { className: "flex items-center justify-between px-3 border-b border-gray-alpha-400", children: [_jsx("span", { className: "text-sm font-medium text-gray-1000 truncate", children: span.name }), _jsx(IconButton, { "aria-label": "Close span details", onClick: onClose, children: _jsx(X, { className: "w-4 h-4" }) })] }), _jsx("div", { className: "overflow-y-auto p-3 space-y-3", children: _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm", children: [_jsx("dt", { className: "text-gray-900", children: "Resource" }), _jsx("dd", { className: "text-gray-1000 font-mono", children: span.resource }), _jsx("dt", { className: "text-gray-900", children: "Duration" }), _jsx("dd", { className: "text-gray-1000 tabular-nums font-mono", children: formatDuration(durationMs) }), _jsx("dt", { className: "text-gray-900", children: "Offset" }), _jsxs("dd", { className: "text-gray-1000 tabular-nums font-mono", children: ["+", formatDuration(offsetMs)] }), _jsx("dt", { className: "text-gray-900", children: "Status" }), _jsx("dd", { className: "text-gray-1000 font-mono", children: span.status.code === 2 ? 'Error' : 'OK' })] }) })] }));
12
- }
13
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGV0YWlsLXBhbmVsLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vc3JjL2NvbXBvbmVudHMvbmV3LXRyYWNlLXZpZXdlci9kZXRhaWwtcGFuZWwudHN4Il0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLFlBQVksQ0FBQzs7QUFFYixPQUFPLEVBQUUsQ0FBQyxFQUFFLE1BQU0sY0FBYyxDQUFDO0FBR2pDLE9BQU8sRUFBRSxjQUFjLEVBQUUsY0FBYyxFQUFFLE1BQU0sNkJBQTZCLENBQUM7QUFDN0UsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLG1CQUFtQixDQUFDO0FBQy9DLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSxNQUFNLFNBQVMsQ0FBQztBQVE1QyxNQUFNLFVBQVUsV0FBVyxDQUFDLEVBQzFCLElBQUksRUFDSixTQUFTLEVBQ1QsT0FBTyxHQUNVO0lBQ2pCLE1BQU0sT0FBTyxHQUFHLGNBQWMsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLENBQUM7SUFDL0MsTUFBTSxVQUFVLEdBQUcsaUJBQWlCLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDM0MsTUFBTSxRQUFRLEdBQUcsT0FBTyxHQUFHLFNBQVMsQ0FBQztJQUVyQyxPQUFPLENBQ0wsaUJBQU8sU0FBUyxFQUFDLGlFQUFpRSxhQUNoRixlQUFLLFNBQVMsRUFBQyx1RUFBdUUsYUFDcEYsZUFBTSxTQUFTLEVBQUMsNkNBQTZDLFlBQzFELElBQUksQ0FBQyxJQUFJLEdBQ0wsRUFDUCxLQUFDLFVBQVUsa0JBQVksb0JBQW9CLEVBQUMsT0FBTyxFQUFFLE9BQU8sWUFDMUQsS0FBQyxDQUFDLElBQUMsU0FBUyxFQUFDLFNBQVMsR0FBRyxHQUNkLElBQ1QsRUFDTixjQUFLLFNBQVMsRUFBQywrQkFBK0IsWUFDNUMsY0FBSSxTQUFTLEVBQUMscURBQXFELGFBQ2pFLGFBQUksU0FBUyxFQUFDLGVBQWUseUJBQWMsRUFDM0MsYUFBSSxTQUFTLEVBQUMsMEJBQTBCLFlBQUUsSUFBSSxDQUFDLFFBQVEsR0FBTSxFQUM3RCxhQUFJLFNBQVMsRUFBQyxlQUFlLHlCQUFjLEVBQzNDLGFBQUksU0FBUyxFQUFDLHVDQUF1QyxZQUNsRCxjQUFjLENBQUMsVUFBVSxDQUFDLEdBQ3hCLEVBQ0wsYUFBSSxTQUFTLEVBQUMsZUFBZSx1QkFBWSxFQUN6QyxjQUFJLFNBQVMsRUFBQyx1Q0FBdUMsa0JBQ2pELGNBQWMsQ0FBQyxRQUFRLENBQUMsSUFDdkIsRUFDTCxhQUFJLFNBQVMsRUFBQyxlQUFlLHVCQUFZLEVBQ3pDLGFBQUksU0FBUyxFQUFDLDBCQUEwQixZQUNyQyxJQUFJLENBQUMsTUFBTSxDQUFDLElBQUksS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsSUFBSSxHQUNyQyxJQUNGLEdBQ0QsSUFDQSxDQUNULENBQUM7QUFDSixDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiJ3VzZSBjbGllbnQnO1xuXG5pbXBvcnQgeyBYIH0gZnJvbSAnbHVjaWRlLXJlYWN0JztcbmltcG9ydCB0eXBlIHsgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnO1xuaW1wb3J0IHR5cGUgeyBTcGFuIH0gZnJvbSAnLi4vdHJhY2Utdmlld2VyL3R5cGVzJztcbmltcG9ydCB7IGZvcm1hdER1cmF0aW9uLCBnZXRIaWdoUmVzSW5NcyB9IGZyb20gJy4uL3RyYWNlLXZpZXdlci91dGlsL3RpbWluZyc7XG5pbXBvcnQgeyBJY29uQnV0dG9uIH0gZnJvbSAnLi4vdWkvaWNvbi1idXR0b24nO1xuaW1wb3J0IHsgZ2V0U3BhbkR1cmF0aW9uTXMgfSBmcm9tICcuL3V0aWxzJztcblxuaW50ZXJmYWNlIERldGFpbFBhbmVsUHJvcHMge1xuICBzcGFuOiBTcGFuO1xuICByb290U3RhcnQ6IG51bWJlcjtcbiAgb25DbG9zZTogKCkgPT4gdm9pZDtcbn1cblxuZXhwb3J0IGZ1bmN0aW9uIERldGFpbFBhbmVsKHtcbiAgc3BhbixcbiAgcm9vdFN0YXJ0LFxuICBvbkNsb3NlLFxufTogRGV0YWlsUGFuZWxQcm9wcyk6IFJlYWN0Tm9kZSB7XG4gIGNvbnN0IHN0YXJ0TXMgPSBnZXRIaWdoUmVzSW5NcyhzcGFuLnN0YXJ0VGltZSk7XG4gIGNvbnN0IGR1cmF0aW9uTXMgPSBnZXRTcGFuRHVyYXRpb25NcyhzcGFuKTtcbiAgY29uc3Qgb2Zmc2V0TXMgPSBzdGFydE1zIC0gcm9vdFN0YXJ0O1xuXG4gIHJldHVybiAoXG4gICAgPGFzaWRlIGNsYXNzTmFtZT1cImdyaWQgaC1mdWxsIG1heC1oLWZ1bGwgZ3JpZC1yb3dzLVsyLjVyZW1fMWZyXSBiZy1iYWNrZ3JvdW5kLTIwMFwiPlxuICAgICAgPGRpdiBjbGFzc05hbWU9XCJmbGV4IGl0ZW1zLWNlbnRlciBqdXN0aWZ5LWJldHdlZW4gcHgtMyBib3JkZXItYiBib3JkZXItZ3JheS1hbHBoYS00MDBcIj5cbiAgICAgICAgPHNwYW4gY2xhc3NOYW1lPVwidGV4dC1zbSBmb250LW1lZGl1bSB0ZXh0LWdyYXktMTAwMCB0cnVuY2F0ZVwiPlxuICAgICAgICAgIHtzcGFuLm5hbWV9XG4gICAgICAgIDwvc3Bhbj5cbiAgICAgICAgPEljb25CdXR0b24gYXJpYS1sYWJlbD1cIkNsb3NlIHNwYW4gZGV0YWlsc1wiIG9uQ2xpY2s9e29uQ2xvc2V9PlxuICAgICAgICAgIDxYIGNsYXNzTmFtZT1cInctNCBoLTRcIiAvPlxuICAgICAgICA8L0ljb25CdXR0b24+XG4gICAgICA8L2Rpdj5cbiAgICAgIDxkaXYgY2xhc3NOYW1lPVwib3ZlcmZsb3cteS1hdXRvIHAtMyBzcGFjZS15LTNcIj5cbiAgICAgICAgPGRsIGNsYXNzTmFtZT1cImdyaWQgZ3JpZC1jb2xzLVthdXRvXzFmcl0gZ2FwLXgtMyBnYXAteS0xLjUgdGV4dC1zbVwiPlxuICAgICAgICAgIDxkdCBjbGFzc05hbWU9XCJ0ZXh0LWdyYXktOTAwXCI+UmVzb3VyY2U8L2R0PlxuICAgICAgICAgIDxkZCBjbGFzc05hbWU9XCJ0ZXh0LWdyYXktMTAwMCBmb250LW1vbm9cIj57c3Bhbi5yZXNvdXJjZX08L2RkPlxuICAgICAgICAgIDxkdCBjbGFzc05hbWU9XCJ0ZXh0LWdyYXktOTAwXCI+RHVyYXRpb248L2R0PlxuICAgICAgICAgIDxkZCBjbGFzc05hbWU9XCJ0ZXh0LWdyYXktMTAwMCB0YWJ1bGFyLW51bXMgZm9udC1tb25vXCI+XG4gICAgICAgICAgICB7Zm9ybWF0RHVyYXRpb24oZHVyYXRpb25Ncyl9XG4gICAgICAgICAgPC9kZD5cbiAgICAgICAgICA8ZHQgY2xhc3NOYW1lPVwidGV4dC1ncmF5LTkwMFwiPk9mZnNldDwvZHQ+XG4gICAgICAgICAgPGRkIGNsYXNzTmFtZT1cInRleHQtZ3JheS0xMDAwIHRhYnVsYXItbnVtcyBmb250LW1vbm9cIj5cbiAgICAgICAgICAgICt7Zm9ybWF0RHVyYXRpb24ob2Zmc2V0TXMpfVxuICAgICAgICAgIDwvZGQ+XG4gICAgICAgICAgPGR0IGNsYXNzTmFtZT1cInRleHQtZ3JheS05MDBcIj5TdGF0dXM8L2R0PlxuICAgICAgICAgIDxkZCBjbGFzc05hbWU9XCJ0ZXh0LWdyYXktMTAwMCBmb250LW1vbm9cIj5cbiAgICAgICAgICAgIHtzcGFuLnN0YXR1cy5jb2RlID09PSAyID8gJ0Vycm9yJyA6ICdPSyd9XG4gICAgICAgICAgPC9kZD5cbiAgICAgICAgPC9kbD5cbiAgICAgIDwvZGl2PlxuICAgIDwvYXNpZGU+XG4gICk7XG59XG4iXX0=
@@ -1 +0,0 @@
1
- //# sourceMappingURL=constants.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/components/new-trace-viewer/util/constants.ts"],"names":[],"mappings":""}
@@ -1,2 +0,0 @@
1
- "use strict";
2
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29uc3RhbnRzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vc3JjL2NvbXBvbmVudHMvbmV3LXRyYWNlLXZpZXdlci91dGlsL2NvbnN0YW50cy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiIiwic291cmNlc0NvbnRlbnQiOlsiIl19