@workflow/web-shared 4.1.0-beta.52 → 4.1.0-beta.53

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 (145) hide show
  1. package/dist/components/error-boundary.d.ts +15 -20
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +17 -31
  4. package/dist/components/error-boundary.js.map +1 -1
  5. package/dist/components/event-list-view.d.ts +7 -6
  6. package/dist/components/event-list-view.d.ts.map +1 -1
  7. package/dist/components/event-list-view.js +492 -109
  8. package/dist/components/event-list-view.js.map +1 -1
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/components/index.d.ts.map +1 -1
  11. package/dist/components/index.js +1 -0
  12. package/dist/components/index.js.map +1 -1
  13. package/dist/components/run-trace-view.d.ts +2 -1
  14. package/dist/components/run-trace-view.d.ts.map +1 -1
  15. package/dist/components/run-trace-view.js +2 -2
  16. package/dist/components/run-trace-view.js.map +1 -1
  17. package/dist/components/sidebar/attribute-panel.d.ts +2 -1
  18. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  19. package/dist/components/sidebar/attribute-panel.js +53 -142
  20. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  21. package/dist/components/sidebar/conversation-view.d.ts.map +1 -1
  22. package/dist/components/sidebar/conversation-view.js +3 -17
  23. package/dist/components/sidebar/conversation-view.js.map +1 -1
  24. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
  25. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  26. package/dist/components/sidebar/entity-detail-panel.js +63 -10
  27. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  28. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  29. package/dist/components/sidebar/events-list.js +4 -8
  30. package/dist/components/sidebar/events-list.js.map +1 -1
  31. package/dist/components/sidebar/resolve-hook-modal.d.ts +3 -0
  32. package/dist/components/sidebar/resolve-hook-modal.d.ts.map +1 -1
  33. package/dist/components/sidebar/resolve-hook-modal.js +152 -3
  34. package/dist/components/sidebar/resolve-hook-modal.js.map +1 -1
  35. package/dist/components/stream-viewer.d.ts +7 -5
  36. package/dist/components/stream-viewer.d.ts.map +1 -1
  37. package/dist/components/stream-viewer.js +54 -22
  38. package/dist/components/stream-viewer.js.map +1 -1
  39. package/dist/components/trace-viewer/components/markers.d.ts +2 -1
  40. package/dist/components/trace-viewer/components/markers.d.ts.map +1 -1
  41. package/dist/components/trace-viewer/components/markers.js +59 -20
  42. package/dist/components/trace-viewer/components/markers.js.map +1 -1
  43. package/dist/components/trace-viewer/components/node.d.ts +5 -1
  44. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  45. package/dist/components/trace-viewer/components/node.js +250 -68
  46. package/dist/components/trace-viewer/components/node.js.map +1 -1
  47. package/dist/components/trace-viewer/components/span-content.d.ts +19 -0
  48. package/dist/components/trace-viewer/components/span-content.d.ts.map +1 -0
  49. package/dist/components/trace-viewer/components/span-content.js +137 -0
  50. package/dist/components/trace-viewer/components/span-content.js.map +1 -0
  51. package/dist/components/trace-viewer/components/span-detail-panel.d.ts.map +1 -1
  52. package/dist/components/trace-viewer/components/span-detail-panel.js +3 -2
  53. package/dist/components/trace-viewer/components/span-detail-panel.js.map +1 -1
  54. package/dist/components/trace-viewer/components/span-segments.d.ts +50 -0
  55. package/dist/components/trace-viewer/components/span-segments.d.ts.map +1 -0
  56. package/dist/components/trace-viewer/components/span-segments.js +392 -0
  57. package/dist/components/trace-viewer/components/span-segments.js.map +1 -0
  58. package/dist/components/trace-viewer/components/span-strategies.d.ts +46 -0
  59. package/dist/components/trace-viewer/components/span-strategies.d.ts.map +1 -0
  60. package/dist/components/trace-viewer/components/span-strategies.js +108 -0
  61. package/dist/components/trace-viewer/components/span-strategies.js.map +1 -0
  62. package/dist/components/trace-viewer/context.d.ts +7 -6
  63. package/dist/components/trace-viewer/context.d.ts.map +1 -1
  64. package/dist/components/trace-viewer/context.js +47 -18
  65. package/dist/components/trace-viewer/context.js.map +1 -1
  66. package/dist/components/trace-viewer/trace-viewer.d.ts +5 -1
  67. package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
  68. package/dist/components/trace-viewer/trace-viewer.js +87 -11
  69. package/dist/components/trace-viewer/trace-viewer.js.map +1 -1
  70. package/dist/components/trace-viewer/trace-viewer.module.css +179 -6
  71. package/dist/components/trace-viewer/util/timing.d.ts +5 -0
  72. package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
  73. package/dist/components/trace-viewer/util/timing.js +12 -0
  74. package/dist/components/trace-viewer/util/timing.js.map +1 -1
  75. package/dist/components/trace-viewer/util/use-streaming-spans.d.ts +1 -1
  76. package/dist/components/trace-viewer/util/use-streaming-spans.d.ts.map +1 -1
  77. package/dist/components/trace-viewer/util/use-streaming-spans.js +29 -17
  78. package/dist/components/trace-viewer/util/use-streaming-spans.js.map +1 -1
  79. package/dist/components/trace-viewer/worker.js +3 -1
  80. package/dist/components/trace-viewer/worker.js.map +1 -1
  81. package/dist/components/ui/alert.js +3 -3
  82. package/dist/components/ui/alert.js.map +1 -1
  83. package/dist/components/ui/card.d.ts.map +1 -1
  84. package/dist/components/ui/card.js +2 -2
  85. package/dist/components/ui/card.js.map +1 -1
  86. package/dist/components/ui/data-inspector.d.ts +17 -0
  87. package/dist/components/ui/data-inspector.d.ts.map +1 -0
  88. package/dist/components/ui/data-inspector.js +184 -0
  89. package/dist/components/ui/data-inspector.js.map +1 -0
  90. package/dist/components/ui/error-card.d.ts.map +1 -1
  91. package/dist/components/ui/error-card.js +4 -1
  92. package/dist/components/ui/error-card.js.map +1 -1
  93. package/dist/components/ui/inspector-theme.d.ts +39 -24
  94. package/dist/components/ui/inspector-theme.d.ts.map +1 -1
  95. package/dist/components/ui/inspector-theme.js +90 -38
  96. package/dist/components/ui/inspector-theme.js.map +1 -1
  97. package/dist/components/ui/skeleton.d.ts +1 -1
  98. package/dist/components/ui/skeleton.d.ts.map +1 -1
  99. package/dist/components/ui/skeleton.js +2 -2
  100. package/dist/components/ui/skeleton.js.map +1 -1
  101. package/dist/components/workflow-trace-view.d.ts +3 -1
  102. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  103. package/dist/components/workflow-trace-view.js +435 -21
  104. package/dist/components/workflow-trace-view.js.map +1 -1
  105. package/dist/components/workflow-traces/trace-span-construction.d.ts +1 -1
  106. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  107. package/dist/components/workflow-traces/trace-span-construction.js +2 -2
  108. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  109. package/dist/lib/hydration.d.ts.map +1 -1
  110. package/dist/lib/hydration.js +17 -3
  111. package/dist/lib/hydration.js.map +1 -1
  112. package/dist/styles.css +186 -0
  113. package/package.json +8 -7
  114. package/src/components/error-boundary.tsx +29 -40
  115. package/src/components/event-list-view.tsx +1000 -287
  116. package/src/components/index.ts +1 -0
  117. package/src/components/run-trace-view.tsx +3 -0
  118. package/src/components/sidebar/attribute-panel.tsx +58 -258
  119. package/src/components/sidebar/conversation-view.tsx +30 -27
  120. package/src/components/sidebar/entity-detail-panel.tsx +86 -20
  121. package/src/components/sidebar/events-list.tsx +4 -11
  122. package/src/components/sidebar/resolve-hook-modal.tsx +206 -47
  123. package/src/components/stream-viewer.tsx +138 -61
  124. package/src/components/trace-viewer/components/markers.tsx +69 -21
  125. package/src/components/trace-viewer/components/node.tsx +346 -100
  126. package/src/components/trace-viewer/components/span-content.tsx +247 -0
  127. package/src/components/trace-viewer/components/span-detail-panel.tsx +7 -2
  128. package/src/components/trace-viewer/components/span-segments.ts +516 -0
  129. package/src/components/trace-viewer/components/span-strategies.ts +205 -0
  130. package/src/components/trace-viewer/context.tsx +92 -40
  131. package/src/components/trace-viewer/trace-viewer.module.css +179 -6
  132. package/src/components/trace-viewer/trace-viewer.tsx +115 -11
  133. package/src/components/trace-viewer/util/timing.ts +13 -0
  134. package/src/components/trace-viewer/util/use-streaming-spans.ts +28 -17
  135. package/src/components/trace-viewer/worker.ts +4 -1
  136. package/src/components/ui/alert.tsx +3 -3
  137. package/src/components/ui/card.tsx +3 -5
  138. package/src/components/ui/data-inspector.tsx +318 -0
  139. package/src/components/ui/error-card.tsx +17 -6
  140. package/src/components/ui/inspector-theme.ts +127 -39
  141. package/src/components/ui/skeleton.tsx +3 -1
  142. package/src/components/workflow-trace-view.tsx +625 -26
  143. package/src/components/workflow-traces/trace-span-construction.ts +3 -2
  144. package/src/lib/hydration.ts +17 -8
  145. package/src/styles.css +186 -0
@@ -33,6 +33,10 @@ interface TraceViewerProps {
33
33
  withPanel?: boolean;
34
34
  getQuickLinks?: GetQuickLinks;
35
35
  highlightedSpans?: string[];
36
+ /** Render all spans immediately (no progressive streaming). */
37
+ eagerRender?: boolean;
38
+ /** Whether the trace is live (for continuous tick updates). */
39
+ isLive?: boolean;
36
40
  }
37
41
 
38
42
  export function TraceViewerProvider({
@@ -97,25 +101,107 @@ export function TraceViewerTimeline({
97
101
  height,
98
102
  withPanel = false,
99
103
  highlightedSpans,
104
+ eagerRender = false,
105
+ isLive = false,
100
106
  }: Omit<TraceViewerProps, 'getQuickLinks'>): ReactNode {
101
107
  const isSkeleton = trace === skeletonTrace;
102
108
  const { state, dispatch } = useTraceViewer();
103
109
  const { timelineRef, scrollSnapshotRef } = state;
104
110
  const memoCache = state.memoCacheRef.current;
105
- const hideSearchBar =
106
- (highlightedSpans?.length ?? 0) > 0 || trace.spans.length <= 10;
111
+ const hideSearchBar = (highlightedSpans?.length ?? 0) > 0;
112
+
113
+ const hasInitializedRef = useRef(false);
114
+ const prevSpanKeyRef = useRef('');
115
+ const prevRootRef = useRef<ReturnType<typeof parseTrace>['root'] | null>(
116
+ null
117
+ );
118
+ const prevSpanMapRef = useRef<ReturnType<typeof parseTrace>['map']>({});
119
+ const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
107
120
 
108
121
  useEffect(() => {
109
- const { root, map: spanMap } = parseTrace(trace);
122
+ const { root: newRoot, map: newSpanMap } = parseTrace(trace);
123
+ const isInitial = !hasInitializedRef.current;
124
+ hasInitializedRef.current = true;
125
+
126
+ // Build a structural key from span IDs + lightweight event signatures.
127
+ // When only timing changes (same spans and boundary events), we can skip the
128
+ // worker restart. When events change (step completed, etc.) we need a
129
+ // full update because the VisibleSpan objects rendered by React hold
130
+ // copies of the events from the worker — in-place mutation won't reach them.
131
+ const spanKey = Object.keys(newSpanMap)
132
+ .sort()
133
+ .map((id) => {
134
+ const events = newSpanMap[id].events;
135
+ const count = events?.length ?? 0;
136
+ const first = count ? events?.[0] : undefined;
137
+ const last = count ? events?.[count - 1] : undefined;
138
+ const eventSignature = `${first?.event.name ?? 'none'}@${first?.timestamp ?? 0}|${last?.event.name ?? 'none'}@${last?.timestamp ?? 0}`;
139
+ return `${id}:${count}:${eventSignature}`;
140
+ })
141
+ .join(',');
142
+
143
+ if (
144
+ !isInitial &&
145
+ spanKey === prevSpanKeyRef.current &&
146
+ prevRootRef.current
147
+ ) {
148
+ // Same span set, same event counts — only timing changed.
149
+ // Mutate in-place so the worker effect (which depends on root/spanMap
150
+ // references) does NOT restart.
151
+ const oldRoot = prevRootRef.current;
152
+ const oldSpanMap = prevSpanMapRef.current;
153
+ let didChange = false;
154
+
155
+ if (
156
+ oldRoot.endTime !== newRoot.endTime ||
157
+ oldRoot.duration !== newRoot.duration
158
+ ) {
159
+ oldRoot.endTime = newRoot.endTime;
160
+ oldRoot.duration = newRoot.duration;
161
+ didChange = true;
162
+ }
163
+
164
+ for (const [id, newNode] of Object.entries(newSpanMap)) {
165
+ const oldNode = oldSpanMap[id];
166
+ if (oldNode) {
167
+ if (
168
+ oldNode.endTime !== newNode.endTime ||
169
+ oldNode.duration !== newNode.duration
170
+ ) {
171
+ oldNode.endTime = newNode.endTime;
172
+ oldNode.duration = newNode.duration;
173
+ didChange = true;
174
+ }
175
+ Object.assign(oldNode.span, newNode.span);
176
+ }
177
+ }
178
+
179
+ if (!didChange) {
180
+ return;
181
+ }
182
+
183
+ // Trigger re-render (scale/markers) without restarting the worker.
184
+ // useLiveTick handles continuous scale recalculation for live runs.
185
+ dispatch({ type: 'forceRender' });
186
+ return;
187
+ }
188
+
189
+ // Structure changed or initial load — full dispatch (worker computes new rows)
190
+ prevSpanKeyRef.current = spanKey;
191
+ prevRootRef.current = newRoot;
192
+ prevSpanMapRef.current = newSpanMap;
110
193
  dispatch({
111
- type: 'setRoot',
112
- root,
113
- spanMap,
194
+ type: isInitial ? 'setRoot' : 'updateRoot',
195
+ root: newRoot,
196
+ spanMap: newSpanMap,
114
197
  resources: trace.resources || [],
115
198
  });
116
199
  }, [dispatch, trace]);
117
200
 
118
- const { rows, spans, events, scale } = useStreamingSpans(highlightedSpans);
201
+ const { rows, spans, events, scale } = useStreamingSpans(
202
+ highlightedSpans,
203
+ eagerRender
204
+ );
119
205
 
120
206
  const ref = useRef<HTMLDivElement>(null);
121
207
  useLayoutEffect(() => {
@@ -125,11 +211,23 @@ export function TraceViewerTimeline({
125
211
  const onResize = (): void => {
126
212
  const padding = 2 * TIMELINE_PADDING;
127
213
  const rect = $el.getBoundingClientRect();
214
+ const nextWidth = rect.width - padding;
215
+ const nextHeight = rect.height;
216
+ const prevSize = prevSizeRef.current;
217
+
218
+ if (
219
+ prevSize &&
220
+ prevSize.width === nextWidth &&
221
+ prevSize.height === nextHeight
222
+ ) {
223
+ return;
224
+ }
225
+ prevSizeRef.current = { width: nextWidth, height: nextHeight };
128
226
 
129
227
  dispatch({
130
228
  type: 'setSize',
131
- width: rect.width - padding,
132
- height: rect.height,
229
+ width: nextWidth,
230
+ height: nextHeight,
133
231
  });
134
232
  };
135
233
 
@@ -202,7 +300,11 @@ export function TraceViewerTimeline({
202
300
  [dispatch]
203
301
  );
204
302
 
205
- // Zoom helper
303
+ // Zoom helper — apply the scroll snapshot once after a zoom operation so
304
+ // the anchor point stays at the same screen position, then clear it.
305
+ // Without clearing, live runs (where scale changes every frame via
306
+ // detectBaseScale) would continuously reset scrollLeft and prevent the
307
+ // user from scrolling horizontally.
206
308
  useLayoutEffect(() => {
207
309
  const $timeline = timelineRef.current;
208
310
  if (!$timeline) return;
@@ -210,6 +312,7 @@ export function TraceViewerTimeline({
210
312
  const snapshot = scrollSnapshotRef.current;
211
313
  if (snapshot) {
212
314
  $timeline.scrollLeft = snapshot.anchorT * scale - snapshot.anchorX;
315
+ scrollSnapshotRef.current = undefined;
213
316
  }
214
317
  }, [scrollSnapshotRef, timelineRef, scale]);
215
318
 
@@ -359,7 +462,7 @@ export function TraceViewerTimeline({
359
462
  height: timelineHeight - TIMELINE_PADDING * 2,
360
463
  }}
361
464
  >
362
- <Markers scale={scale} />
465
+ <Markers scale={scale} isLive={isLive} />
363
466
  <EventMarkers events={events} root={state.root} scale={scale} />
364
467
  <CursorMarker
365
468
  dispatch={dispatch}
@@ -378,6 +481,7 @@ export function TraceViewerTimeline({
378
481
  customSpanEventClassNameFunc={
379
482
  state.customSpanEventClassNameFunc
380
483
  }
484
+ isLive={isLive}
381
485
  root={state.root}
382
486
  scale={scale}
383
487
  scrollSnapshotRef={scrollSnapshotRef}
@@ -31,3 +31,16 @@ export function formatTimeSelection(ms: number): string {
31
31
  }
32
32
  return `${timeSelectionFormatter.format(ms)}ms`;
33
33
  }
34
+
35
+ /**
36
+ * Format an epoch-millisecond timestamp as a local wall-clock time.
37
+ * Returns a compact HH:MM:SS.mmm string (24-hour format).
38
+ */
39
+ export function formatWallClockTime(epochMs: number): string {
40
+ const d = new Date(epochMs);
41
+ const h = String(d.getHours()).padStart(2, '0');
42
+ const m = String(d.getMinutes()).padStart(2, '0');
43
+ const s = String(d.getSeconds()).padStart(2, '0');
44
+ const ms = String(d.getMilliseconds()).padStart(3, '0');
45
+ return `${h}:${m}:${s}.${ms}`;
46
+ }
@@ -1,8 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { clsx } from 'clsx';
4
3
  import { useEffect, useRef, useState } from 'react';
5
- import { getSpanClassName } from '../components/node';
6
4
  import { useTraceViewer } from '../context';
7
5
  import type {
8
6
  VisibleSpan,
@@ -32,7 +30,8 @@ function emptyArrayInit<T>(): T[] {
32
30
  }
33
31
 
34
32
  export const useStreamingSpans = (
35
- highlightedSpans?: string[]
33
+ highlightedSpans?: string[],
34
+ eagerRender = false
36
35
  ): {
37
36
  rows: VisibleSpan[][];
38
37
  spans: VisibleSpan[];
@@ -50,7 +49,6 @@ export const useStreamingSpans = (
50
49
  scrollSnapshotRef,
51
50
  selected,
52
51
  memoCacheRef,
53
- customSpanClassNameFunc,
54
52
  } = state;
55
53
  const timelineHeight = useStableValue(state.timelineHeight);
56
54
  const counterRef = useRef(0);
@@ -83,13 +81,16 @@ export const useStreamingSpans = (
83
81
  useEffect(() => {
84
82
  if (!root.startTime) return;
85
83
 
86
- const worker = new Worker(new URL('../worker', import.meta.url));
84
+ const worker = new Worker(new URL('../worker', import.meta.url), {
85
+ type: 'module',
86
+ });
87
87
  let requestId = ++counterRef.current;
88
88
  const onMessage = (event: MessageEvent): void => {
89
89
  const data = event.data as WorkerResponse;
90
90
 
91
91
  switch (data.type) {
92
92
  case 'setRowsResult': {
93
+ if (data?.requestId !== requestId) return;
93
94
  if (data.isEnd) {
94
95
  for (const row of data.rows) {
95
96
  for (const node of row) {
@@ -168,6 +169,24 @@ export const useStreamingSpans = (
168
169
  useEffect(() => {
169
170
  if (!rows.length) return;
170
171
 
172
+ if (eagerRender) {
173
+ const visible: VisibleSpan[] = [];
174
+ const events: VisibleSpanEvent[] = [];
175
+ for (const row of rows) {
176
+ for (const span of row) {
177
+ if (!adjustSpanVisibility(span, scale)) continue;
178
+ visible.push(span);
179
+ if (span.events) {
180
+ events.push(...span.events);
181
+ }
182
+ }
183
+ }
184
+ setVisibleSpans(visible);
185
+ setVisibleEvents(events);
186
+ setResultScale(scale);
187
+ return;
188
+ }
189
+
171
190
  const $timeline = timelineRef.current;
172
191
  let snapshot = scrollSnapshotRef.current;
173
192
  let hasScrolled = false;
@@ -342,6 +361,7 @@ export const useStreamingSpans = (
342
361
  timelineRef,
343
362
  timelineWidth,
344
363
  timelineHeight,
364
+ eagerRender,
345
365
  ]);
346
366
 
347
367
  useEffect(() => {
@@ -359,22 +379,13 @@ export const useStreamingSpans = (
359
379
  if (!span || !$span) return;
360
380
 
361
381
  span.isSelected = true;
362
- // Get both the base span className and custom class name
363
- const baseClassName = getSpanClassName(span, scale);
364
- const customClassName = customSpanClassNameFunc
365
- ? customSpanClassNameFunc(span)
366
- : '';
367
- $span.className = clsx(baseClassName, customClassName);
382
+ $span.setAttribute('data-selected', '');
368
383
 
369
384
  return () => {
370
385
  span.isSelected = false;
371
- const baseClassName = getSpanClassName(span, scale);
372
- const customClassName = customSpanClassNameFunc
373
- ? customSpanClassNameFunc(span)
374
- : '';
375
- $span.className = clsx(baseClassName, customClassName);
386
+ $span.removeAttribute('data-selected');
376
387
  };
377
- }, [visibleSpans, scale, selected, customSpanClassNameFunc]);
388
+ }, [visibleSpans, selected]);
378
389
 
379
390
  return {
380
391
  rows,
@@ -100,7 +100,10 @@ const filterSpans = (root: RootNode, filter: string): void => {
100
100
  }
101
101
 
102
102
  const match = (span: Span): boolean => {
103
- if (!span.name.toLocaleLowerCase().includes(name)) return false;
103
+ const nameMatch =
104
+ span.name.toLocaleLowerCase().includes(name) ||
105
+ span.spanId.toLocaleLowerCase().includes(name);
106
+ if (!nameMatch) return false;
104
107
  // TODO: support resource attribute filtering
105
108
  return attrs.every(([key, value]) => {
106
109
  const v = span.attributes[key];
@@ -4,13 +4,13 @@ import * as React from 'react';
4
4
  import { cn } from '../../lib/utils';
5
5
 
6
6
  const alertVariants = cva(
7
- 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
7
+ 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4',
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
- default: 'bg-background text-foreground',
11
+ default: '',
12
12
  destructive:
13
- 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
13
+ 'border-red-400 text-red-900 dark:border-red-700 [&>svg]:text-red-900',
14
14
  },
15
15
  },
16
16
  defaultVariants: {
@@ -10,10 +10,7 @@ const Card = React.forwardRef<
10
10
  >(({ className, ...props }, ref) => (
11
11
  <div
12
12
  ref={ref}
13
- className={cn(
14
- 'rounded-lg border bg-card text-card-foreground shadow-sm',
15
- className
16
- )}
13
+ className={cn('rounded-lg border shadow-sm', className)}
17
14
  {...props}
18
15
  />
19
16
  ));
@@ -52,7 +49,8 @@ const CardDescription = React.forwardRef<
52
49
  >(({ className, ...props }, ref) => (
53
50
  <div
54
51
  ref={ref}
55
- className={cn('text-sm text-muted-foreground', className)}
52
+ className={cn('text-sm', className)}
53
+ style={{ color: 'var(--ds-gray-900)' }}
56
54
  {...props}
57
55
  />
58
56
  ));
@@ -0,0 +1,318 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Reusable data inspector component built on react-inspector.
5
+ *
6
+ * All data rendering in the o11y UI should use this component to ensure
7
+ * consistent theming, custom type handling (StreamRef, ClassInstanceRef),
8
+ * and expand behavior.
9
+ */
10
+
11
+ import { createContext, useContext, useEffect, useRef, useState } from 'react';
12
+ import {
13
+ ObjectInspector,
14
+ ObjectLabel,
15
+ ObjectName,
16
+ ObjectRootLabel,
17
+ ObjectValue,
18
+ } from 'react-inspector';
19
+ import { useDarkMode } from '../../hooks/use-dark-mode';
20
+ import {
21
+ type InspectorThemeExtended,
22
+ inspectorThemeDark,
23
+ inspectorThemeExtendedDark,
24
+ inspectorThemeExtendedLight,
25
+ inspectorThemeLight,
26
+ } from './inspector-theme';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // StreamRef / ClassInstanceRef type detection
30
+ // (inline to avoid circular deps with hydration module)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const STREAM_REF_TYPE = '__workflow_stream_ref__';
34
+ const CLASS_INSTANCE_REF_TYPE = '__workflow_class_instance_ref__';
35
+
36
+ interface StreamRef {
37
+ __type: typeof STREAM_REF_TYPE;
38
+ streamId: string;
39
+ }
40
+
41
+ function isStreamRef(value: unknown): value is StreamRef {
42
+ return (
43
+ value !== null &&
44
+ typeof value === 'object' &&
45
+ '__type' in value &&
46
+ (value as Record<string, unknown>).__type === STREAM_REF_TYPE
47
+ );
48
+ }
49
+
50
+ function isClassInstanceRef(value: unknown): value is {
51
+ __type: string;
52
+ className: string;
53
+ classId: string;
54
+ data: unknown;
55
+ } {
56
+ return (
57
+ value !== null &&
58
+ typeof value === 'object' &&
59
+ '__type' in value &&
60
+ (value as Record<string, unknown>).__type === CLASS_INSTANCE_REF_TYPE
61
+ );
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Stream click context (passed through from the panel)
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Context for passing stream click handlers down to DataInspector instances.
70
+ * Exported so that parent components (e.g., AttributePanel) can provide the handler.
71
+ */
72
+ export const StreamClickContext = createContext<
73
+ ((streamId: string) => void) | undefined
74
+ >(undefined);
75
+
76
+ function StreamRefInline({ streamRef }: { streamRef: StreamRef }) {
77
+ const onStreamClick = useContext(StreamClickContext);
78
+ return (
79
+ <button
80
+ type="button"
81
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-mono cursor-pointer"
82
+ style={{
83
+ backgroundColor: 'var(--ds-blue-100)',
84
+ color: 'var(--ds-blue-800)',
85
+ border: '1px solid var(--ds-blue-300)',
86
+ }}
87
+ onClick={() => onStreamClick?.(streamRef.streamId)}
88
+ title={`View stream: ${streamRef.streamId}`}
89
+ >
90
+ <span>📡</span>
91
+ <span>{streamRef.streamId}</span>
92
+ </button>
93
+ );
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Extended theme context (for colors react-inspector doesn't support natively)
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const ExtendedThemeContext = createContext<InspectorThemeExtended>(
101
+ inspectorThemeExtendedLight
102
+ );
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Custom nodeRenderer
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Extends the default react-inspector nodeRenderer with special handling
110
+ * for ClassInstanceRef, StreamRef, and Date types.
111
+ *
112
+ * react-inspector renders Date instances as unstyled plain text (no theme
113
+ * key exists for them), so we intercept here and apply the magenta color
114
+ * from our extended theme — matching Node.js util.inspect()'s date style.
115
+ *
116
+ * Default nodeRenderer reference:
117
+ * https://github.com/storybookjs/react-inspector/blob/main/README.md#noderenderer
118
+ */
119
+ function NodeRenderer({
120
+ depth,
121
+ name,
122
+ data,
123
+ isNonenumerable,
124
+ }: {
125
+ depth: number;
126
+ name?: string;
127
+ data: unknown;
128
+ isNonenumerable?: boolean;
129
+ expanded?: boolean;
130
+ }) {
131
+ const extendedTheme = useContext(ExtendedThemeContext);
132
+
133
+ // StreamRef → inline clickable badge
134
+ if (isStreamRef(data)) {
135
+ return (
136
+ <span>
137
+ {name != null && <ObjectName name={name} />}
138
+ {name != null && <span>: </span>}
139
+ <StreamRefInline streamRef={data} />
140
+ </span>
141
+ );
142
+ }
143
+
144
+ // ClassInstanceRef → show className as type, data as the inspectable value
145
+ if (isClassInstanceRef(data)) {
146
+ if (depth === 0) {
147
+ return <ObjectRootLabel name={data.className} data={data.data} />;
148
+ }
149
+ return (
150
+ <span>
151
+ {name != null && <ObjectName name={name} />}
152
+ {name != null && <span>: </span>}
153
+ <span style={{ fontStyle: 'italic' }}>{data.className} </span>
154
+ <ObjectValue object={data.data} />
155
+ </span>
156
+ );
157
+ }
158
+
159
+ // Date → magenta color (Node.js: 'date' → 'magenta')
160
+ // react-inspector has no OBJECT_VALUE_DATE_COLOR theme key, so we handle it here.
161
+ if (data instanceof Date) {
162
+ const dateStr = data.toISOString();
163
+ if (depth === 0) {
164
+ return (
165
+ <span style={{ color: extendedTheme.OBJECT_VALUE_DATE_COLOR }}>
166
+ {dateStr}
167
+ </span>
168
+ );
169
+ }
170
+ return (
171
+ <span>
172
+ {name != null && <ObjectName name={name} />}
173
+ {name != null && <span>: </span>}
174
+ <span style={{ color: extendedTheme.OBJECT_VALUE_DATE_COLOR }}>
175
+ {dateStr}
176
+ </span>
177
+ </span>
178
+ );
179
+ }
180
+
181
+ // Default rendering (same as react-inspector's built-in)
182
+ if (depth === 0) {
183
+ return <ObjectRootLabel name={name} data={data} />;
184
+ }
185
+ return (
186
+ <ObjectLabel name={name} data={data} isNonenumerable={isNonenumerable} />
187
+ );
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Public component
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export interface DataInspectorProps {
195
+ /** The data to inspect */
196
+ data: unknown;
197
+ /** Initial expand depth (default: 2) */
198
+ expandLevel?: number;
199
+ /** Optional name for the root node */
200
+ name?: string;
201
+ /** Callback when a stream reference is clicked */
202
+ onStreamClick?: (streamId: string) => void;
203
+ }
204
+
205
+ export function DataInspector({
206
+ data,
207
+ expandLevel = 2,
208
+ name,
209
+ onStreamClick,
210
+ }: DataInspectorProps) {
211
+ const stableData = useStableInspectorData(data);
212
+ const [initialExpandLevel, setInitialExpandLevel] = useState(expandLevel);
213
+ const isDark = useDarkMode();
214
+ const extendedTheme = isDark
215
+ ? inspectorThemeExtendedDark
216
+ : inspectorThemeExtendedLight;
217
+
218
+ useEffect(() => {
219
+ // react-inspector reapplies expandLevel on every data change, which can
220
+ // reopen paths the user manually collapsed. Apply it only on mount.
221
+ setInitialExpandLevel(0);
222
+ }, []);
223
+
224
+ const content = (
225
+ <ExtendedThemeContext.Provider value={extendedTheme}>
226
+ <ObjectInspector
227
+ data={stableData}
228
+ name={name}
229
+ // @ts-expect-error react-inspector accepts theme objects at runtime despite
230
+ // types declaring string only — see https://github.com/storybookjs/react-inspector/blob/main/README.md#theme
231
+ theme={isDark ? inspectorThemeDark : inspectorThemeLight}
232
+ expandLevel={initialExpandLevel}
233
+ nodeRenderer={NodeRenderer}
234
+ />
235
+ </ExtendedThemeContext.Provider>
236
+ );
237
+
238
+ // Wrap in StreamClickContext if a handler is provided
239
+ if (onStreamClick) {
240
+ return (
241
+ <StreamClickContext.Provider value={onStreamClick}>
242
+ {content}
243
+ </StreamClickContext.Provider>
244
+ );
245
+ }
246
+
247
+ return content;
248
+ }
249
+
250
+ function useStableInspectorData<T>(next: T): T {
251
+ const previousRef = useRef<T>(next);
252
+ if (!isDeepEqual(previousRef.current, next)) {
253
+ previousRef.current = next;
254
+ }
255
+ return previousRef.current;
256
+ }
257
+
258
+ function isObjectLike(value: unknown): value is Record<string, unknown> {
259
+ return typeof value === 'object' && value !== null;
260
+ }
261
+
262
+ function isDeepEqual(a: unknown, b: unknown, seen = new WeakMap()): boolean {
263
+ if (Object.is(a, b)) return true;
264
+
265
+ if (a instanceof Date && b instanceof Date) {
266
+ return a.getTime() === b.getTime();
267
+ }
268
+
269
+ if (a instanceof RegExp && b instanceof RegExp) {
270
+ return a.source === b.source && a.flags === b.flags;
271
+ }
272
+
273
+ if (a instanceof Map && b instanceof Map) {
274
+ if (a.size !== b.size) return false;
275
+ for (const [key, value] of a.entries()) {
276
+ if (!b.has(key) || !isDeepEqual(value, b.get(key), seen)) return false;
277
+ }
278
+ return true;
279
+ }
280
+
281
+ if (a instanceof Set && b instanceof Set) {
282
+ if (a.size !== b.size) return false;
283
+ for (const value of a.values()) {
284
+ if (!b.has(value)) return false;
285
+ }
286
+ return true;
287
+ }
288
+
289
+ if (!isObjectLike(a) || !isObjectLike(b)) {
290
+ return false;
291
+ }
292
+
293
+ if (seen.get(a) === b) return true;
294
+ seen.set(a, b);
295
+
296
+ const aIsArray = Array.isArray(a);
297
+ const bIsArray = Array.isArray(b);
298
+ if (aIsArray !== bIsArray) return false;
299
+
300
+ if (aIsArray && bIsArray) {
301
+ if (a.length !== b.length) return false;
302
+ for (let i = 0; i < a.length; i += 1) {
303
+ if (!isDeepEqual(a[i], b[i], seen)) return false;
304
+ }
305
+ return true;
306
+ }
307
+
308
+ const aKeys = Object.keys(a);
309
+ const bKeys = Object.keys(b);
310
+ if (aKeys.length !== bKeys.length) return false;
311
+
312
+ for (const key of aKeys) {
313
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
314
+ if (!isDeepEqual(a[key], b[key], seen)) return false;
315
+ }
316
+
317
+ return true;
318
+ }