@workflow/web-shared 5.0.0-beta.21 → 5.0.0-beta.23

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 (39) hide show
  1. package/dist/components/index.d.ts +1 -0
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +2 -1
  4. package/dist/components/new-trace-viewer/components/trace-shortcut-helper.d.ts +5 -0
  5. package/dist/components/new-trace-viewer/components/trace-shortcut-helper.d.ts.map +1 -0
  6. package/dist/components/new-trace-viewer/components/trace-shortcut-helper.js +55 -0
  7. package/dist/components/new-trace-viewer/components/trace-shortcut-helper.module.css +20 -0
  8. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  9. package/dist/components/new-trace-viewer/trace-viewer.js +4 -4
  10. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  11. package/dist/components/sidebar/attribute-panel.js +4 -1
  12. package/dist/components/sidebar/copyable-data-block.d.ts +1 -0
  13. package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
  14. package/dist/components/sidebar/copyable-data-block.js +2 -2
  15. package/dist/components/ui/data-inspector.d.ts +6 -6
  16. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  17. package/dist/components/ui/data-inspector.js +234 -91
  18. package/dist/components/ui/data-inspector.styles.d.ts +29 -0
  19. package/dist/components/ui/data-inspector.styles.d.ts.map +1 -0
  20. package/dist/components/ui/data-inspector.styles.js +95 -0
  21. package/dist/components/ui/kbd.d.ts +9 -5
  22. package/dist/components/ui/kbd.d.ts.map +1 -1
  23. package/dist/components/ui/kbd.js +22 -3
  24. package/dist/lib/hydration.js +3 -3
  25. package/package.json +3 -4
  26. package/src/components/index.ts +1 -0
  27. package/src/components/new-trace-viewer/components/trace-shortcut-helper.module.css +20 -0
  28. package/src/components/new-trace-viewer/components/trace-shortcut-helper.tsx +116 -0
  29. package/src/components/new-trace-viewer/trace-viewer.tsx +9 -4
  30. package/src/components/sidebar/attribute-panel.tsx +4 -0
  31. package/src/components/sidebar/copyable-data-block.tsx +1 -1
  32. package/src/components/ui/data-inspector.styles.ts +96 -0
  33. package/src/components/ui/data-inspector.tsx +492 -186
  34. package/src/components/ui/kbd.tsx +33 -11
  35. package/src/lib/hydration.ts +2 -2
  36. package/dist/components/ui/inspector-theme.d.ts +0 -95
  37. package/dist/components/ui/inspector-theme.d.ts.map +0 -1
  38. package/dist/components/ui/inspector-theme.js +0 -82
  39. package/src/components/ui/inspector-theme.ts +0 -110
@@ -1,44 +1,38 @@
1
1
  'use client';
2
2
 
3
3
  /**
4
- * Reusable data inspector component built on react-inspector.
4
+ * Reusable data inspector for the o11y UI.
5
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.
6
+ * Renders JSON as a collapsible tree: bracket notation (`{ }` / `[ … ]`),
7
+ * colored keys, typed value colors, `▸`/`▾` disclosure icons, and a `...`
8
+ * collapsed indicator.
9
+ *
10
+ * On top of plain JSON it handles the workflow-specific value types: StreamRef /
11
+ * RunRef badges, encrypted markers, decoded byte streams, Dates, and named
12
+ * class instances.
9
13
  */
10
14
 
11
15
  import { Lock } from 'lucide-react';
12
16
  import {
13
17
  createContext,
18
+ type KeyboardEvent as ReactKeyboardEvent,
19
+ type MouseEvent as ReactMouseEvent,
20
+ type ReactNode,
21
+ type RefObject,
14
22
  useContext,
15
- useEffect,
23
+ useId,
16
24
  useMemo,
17
25
  useRef,
18
26
  useState,
19
27
  } from 'react';
20
- import {
21
- ObjectInspector,
22
- ObjectLabel,
23
- ObjectName,
24
- ObjectRootLabel,
25
- ObjectValue,
26
- } from 'react-inspector';
27
- import { useDarkMode } from '../../hooks/use-dark-mode';
28
28
  import { ENCRYPTED_DISPLAY_NAME } from '../../lib/hydration';
29
29
  import {
30
30
  type DecodedStreamChunkSource,
31
31
  type FormattedStreamChunkDisplay,
32
32
  formatArrayBufferViewForDisplay,
33
33
  } from '../../lib/stream-display';
34
- import {
35
- type InspectorThemeExtended,
36
- inspectorThemeDark,
37
- inspectorThemeExtendedDark,
38
- inspectorThemeExtendedLight,
39
- inspectorThemeLight,
40
- } from './inspector-theme';
41
34
  import { Button } from './button';
35
+ import { CLS, JSON_VIEW_STYLES } from './data-inspector.styles';
42
36
  import { Spinner } from './spinner';
43
37
 
44
38
  // ---------------------------------------------------------------------------
@@ -67,6 +61,13 @@ interface BytesDisplay {
67
61
  decodedFrom?: DecodedStreamChunkSource;
68
62
  }
69
63
 
64
+ interface ClassInstanceRef {
65
+ __type: typeof CLASS_INSTANCE_REF_TYPE;
66
+ className: string;
67
+ classId: string;
68
+ data: unknown;
69
+ }
70
+
70
71
  function deserializeChunkText(text: string): string {
71
72
  try {
72
73
  const parsed = JSON.parse(text);
@@ -106,12 +107,7 @@ export function isBytesDisplay(value: unknown): value is BytesDisplay {
106
107
  return desc?.value === BYTES_DISPLAY_TYPE;
107
108
  }
108
109
 
109
- function isClassInstanceRef(value: unknown): value is {
110
- __type: string;
111
- className: string;
112
- classId: string;
113
- data: unknown;
114
- } {
110
+ function isClassInstanceRef(value: unknown): value is ClassInstanceRef {
115
111
  return (
116
112
  value !== null &&
117
113
  typeof value === 'object' &&
@@ -120,6 +116,15 @@ function isClassInstanceRef(value: unknown): value is {
120
116
  );
121
117
  }
122
118
 
119
+ function isEncryptedMarker(value: unknown): boolean {
120
+ return (
121
+ value !== null &&
122
+ typeof value === 'object' &&
123
+ (value as { constructor?: { name?: string } }).constructor?.name ===
124
+ ENCRYPTED_DISPLAY_NAME
125
+ );
126
+ }
127
+
123
128
  // ---------------------------------------------------------------------------
124
129
  // Stream click context (passed through from the panel)
125
130
  // ---------------------------------------------------------------------------
@@ -150,6 +155,10 @@ export const RunClickContext = createContext<
150
155
  ((runId: string) => void) | undefined
151
156
  >(undefined);
152
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // Workflow-specific value renderers (badges, encrypted markers, byte streams)
160
+ // ---------------------------------------------------------------------------
161
+
153
162
  function EncryptedInlineLabel() {
154
163
  const ctx = useContext(DecryptClickContext);
155
164
  if (ctx) {
@@ -187,6 +196,7 @@ function EncryptedInlineLabel() {
187
196
  </span>
188
197
  );
189
198
  }
199
+
190
200
  function StreamRefInline({ streamRef }: { streamRef: StreamRef }) {
191
201
  const onStreamClick = useContext(StreamClickContext);
192
202
  const [hovered, setHovered] = useState(false);
@@ -240,14 +250,6 @@ function RunRefInline({ runRef }: { runRef: RunRef }) {
240
250
  );
241
251
  }
242
252
 
243
- // ---------------------------------------------------------------------------
244
- // Extended theme context (for colors react-inspector doesn't support natively)
245
- // ---------------------------------------------------------------------------
246
-
247
- const ExtendedThemeContext = createContext<InspectorThemeExtended>(
248
- inspectorThemeExtendedLight
249
- );
250
-
251
253
  function DecodedBytesChunk({
252
254
  decodedText,
253
255
  source,
@@ -361,162 +363,474 @@ function DecodedBytesInspector({
361
363
  );
362
364
  }
363
365
 
364
- function BytesDisplayLabel({
365
- name,
366
- display,
367
- }: {
368
- name?: string;
369
- display: BytesDisplay;
370
- }) {
366
+ function BytesDisplayValue({ display }: { display: BytesDisplay }) {
367
+ if (display.decodedFrom) {
368
+ return (
369
+ <DecodedBytesChunk
370
+ decodedText={display.text}
371
+ source={display.decodedFrom}
372
+ />
373
+ );
374
+ }
371
375
  return (
372
- <div className="inline-block min-w-0 align-top">
373
- {name != null && <ObjectName name={name} />}
374
- {name != null && <span>: </span>}
375
- {display.decodedFrom ? (
376
- <DecodedBytesChunk
377
- decodedText={display.text}
378
- source={display.decodedFrom}
379
- />
380
- ) : (
381
- <span
382
- className="whitespace-pre-wrap break-words"
383
- style={{ color: 'var(--ds-gray-1000)' }}
384
- >
385
- {display.text}
386
- </span>
387
- )}
388
- </div>
376
+ <span
377
+ className="whitespace-pre-wrap break-words"
378
+ style={{ color: 'var(--ds-gray-1000)' }}
379
+ >
380
+ {display.text}
381
+ </span>
389
382
  );
390
383
  }
391
384
 
392
385
  // ---------------------------------------------------------------------------
393
- // Custom nodeRenderer
386
+ // Tree renderer
394
387
  // ---------------------------------------------------------------------------
395
388
 
389
+ type Entry = [field: string | undefined, value: unknown];
390
+
391
+ interface NodeContext {
392
+ level: number;
393
+ shouldExpand: (level: number) => boolean;
394
+ outerRef: RefObject<HTMLDivElement | null>;
395
+ }
396
+
397
+ /** Field names are rendered unquoted (empty string shows as `""`). */
398
+ function formatField(field: string): string {
399
+ return field === '' ? '""' : field;
400
+ }
401
+
396
402
  /**
397
- * Extends the default react-inspector nodeRenderer with special handling
398
- * for ClassInstanceRef, StreamRef, and Date types.
399
- *
400
- * react-inspector renders Date instances as unstyled plain text (no theme
401
- * key exists for them), so we intercept here and apply the magenta color
402
- * from our extended theme — matching Node.js util.inspect()'s date style.
403
- *
404
- * Default nodeRenderer reference:
405
- * https://github.com/storybookjs/react-inspector/blob/main/README.md#noderenderer
403
+ * Describe an object/array/map/set as an expandable container. Returns null for
404
+ * values that should render as a primitive. `prefix` carries a class name shown
405
+ * before the opening bracket (Map/Set and named class instances).
406
406
  */
407
- function NodeRenderer({
408
- depth,
409
- name,
410
- data,
411
- isNonenumerable,
407
+ function describeContainer(
408
+ value: unknown
409
+ ): { entries: Entry[]; open: string; close: string; prefix?: string } | null {
410
+ if (Array.isArray(value)) {
411
+ return {
412
+ entries: value.map((item) => [undefined, item] as Entry),
413
+ open: '[',
414
+ close: ']',
415
+ };
416
+ }
417
+ if (value instanceof Map) {
418
+ return {
419
+ entries: Array.from(value.entries(), ([key, val]) => [
420
+ String(key),
421
+ val,
422
+ ]) as Entry[],
423
+ open: '{',
424
+ close: '}',
425
+ prefix: 'Map',
426
+ };
427
+ }
428
+ if (value instanceof Set) {
429
+ return {
430
+ entries: Array.from(value.values(), (item) => [undefined, item] as Entry),
431
+ open: '[',
432
+ close: ']',
433
+ prefix: 'Set',
434
+ };
435
+ }
436
+ if (value !== null && typeof value === 'object') {
437
+ const name = (value as { constructor?: { name?: string } }).constructor
438
+ ?.name;
439
+ return {
440
+ entries: Object.entries(value) as Entry[],
441
+ open: '{',
442
+ close: '}',
443
+ prefix: name && name !== 'Object' ? name : undefined,
444
+ };
445
+ }
446
+ return null;
447
+ }
448
+
449
+ function describePrimitive(value: unknown): { text: string; cls: string } {
450
+ if (value === null) return { text: 'null', cls: CLS.null };
451
+ if (value === undefined) return { text: 'undefined', cls: CLS.undefined };
452
+ if (typeof value === 'string' || value instanceof String) {
453
+ return { text: `"${String(value)}"`, cls: CLS.string };
454
+ }
455
+ if (typeof value === 'boolean' || value instanceof Boolean) {
456
+ return { text: String(value), cls: CLS.boolean };
457
+ }
458
+ if (typeof value === 'number' || value instanceof Number) {
459
+ return { text: String(value), cls: CLS.number };
460
+ }
461
+ if (typeof value === 'bigint') {
462
+ return { text: `${value.toString()}n`, cls: CLS.number };
463
+ }
464
+ return { text: String(value), cls: CLS.punctuation };
465
+ }
466
+
467
+ function Label({ field, clickable }: { field?: string; clickable?: boolean }) {
468
+ if (field === undefined) return null;
469
+ return (
470
+ <span className={clickable ? CLS.clickableLabel : CLS.label}>
471
+ {`${formatField(field)}:`}
472
+ </span>
473
+ );
474
+ }
475
+
476
+ function Comma({ isLast }: { isLast: boolean }) {
477
+ if (isLast) return null;
478
+ return <span className={CLS.punctuation}>,</span>;
479
+ }
480
+
481
+ /** A non-expandable row: `field: <value>`. */
482
+ function LeafRow({
483
+ field,
484
+ isLast,
485
+ children,
412
486
  }: {
413
- depth: number;
414
- name?: string;
415
- data: unknown;
416
- isNonenumerable?: boolean;
417
- expanded?: boolean;
487
+ field?: string;
488
+ isLast: boolean;
489
+ children: ReactNode;
418
490
  }) {
419
- const extendedTheme = useContext(ExtendedThemeContext);
491
+ return (
492
+ <div className={CLS.child} role="treeitem" tabIndex={-1}>
493
+ <Label field={field} />
494
+ {children}
495
+ <Comma isLast={isLast} />
496
+ </div>
497
+ );
498
+ }
420
499
 
421
- if (isBytesDisplay(data)) {
422
- return <BytesDisplayLabel name={name} display={data} />;
423
- }
500
+ function PrimitiveValue({ field, value, isLast }: NodeProps) {
501
+ const { text, cls } = describePrimitive(value);
502
+ return (
503
+ <LeafRow field={field} isLast={isLast}>
504
+ <span className={cls}>{text}</span>
505
+ </LeafRow>
506
+ );
507
+ }
424
508
 
425
- // Encrypted marker → flat label with Lock icon, clickable when onDecrypt is available
426
- if (
427
- data !== null &&
428
- typeof data === 'object' &&
429
- data.constructor?.name === ENCRYPTED_DISPLAY_NAME
430
- ) {
431
- const label = <EncryptedInlineLabel />;
432
- if (depth === 0) {
433
- return label;
509
+ function EmptyContainer({
510
+ field,
511
+ prefix,
512
+ open,
513
+ close,
514
+ isLast,
515
+ }: {
516
+ field?: string;
517
+ prefix?: string;
518
+ open: string;
519
+ close: string;
520
+ isLast: boolean;
521
+ }) {
522
+ return (
523
+ <div className={CLS.child} role="treeitem" tabIndex={-1}>
524
+ <Label field={field} />
525
+ {prefix ? <span className={CLS.className}>{prefix}</span> : null}
526
+ <span className={CLS.punctuation}>{open}</span>
527
+ <span className={CLS.punctuation}>{close}</span>
528
+ <Comma isLast={isLast} />
529
+ </div>
530
+ );
531
+ }
532
+
533
+ function focusExpander(
534
+ button: HTMLElement,
535
+ outerRef: RefObject<HTMLDivElement | null>
536
+ ) {
537
+ const previous = outerRef.current?.querySelector<HTMLElement>(
538
+ '[data-json-expander][tabindex="0"]'
539
+ );
540
+ if (previous) previous.tabIndex = -1;
541
+ button.tabIndex = 0;
542
+ button.focus();
543
+ }
544
+
545
+ function moveExpanderFocus(
546
+ outerRef: RefObject<HTMLDivElement | null>,
547
+ direction: 1 | -1
548
+ ) {
549
+ const root = outerRef.current;
550
+ if (!root) return;
551
+ const buttons = root.querySelectorAll<HTMLElement>('[data-json-expander]');
552
+ let current = -1;
553
+ for (let i = 0; i < buttons.length; i += 1) {
554
+ if (buttons[i].tabIndex === 0) {
555
+ current = i;
556
+ break;
434
557
  }
558
+ }
559
+ if (current < 0) return;
560
+ const next = (current + direction + buttons.length) % buttons.length;
561
+ buttons[current].tabIndex = -1;
562
+ buttons[next].tabIndex = 0;
563
+ buttons[next].focus();
564
+ }
565
+
566
+ function ExpandableContainer({
567
+ field,
568
+ entries,
569
+ open,
570
+ close,
571
+ prefix,
572
+ ctx,
573
+ isLast,
574
+ }: {
575
+ field?: string;
576
+ entries: Entry[];
577
+ open: string;
578
+ close: string;
579
+ prefix?: string;
580
+ ctx: NodeContext;
581
+ isLast: boolean;
582
+ }) {
583
+ const { level, shouldExpand, outerRef } = ctx;
584
+ const [expanded, setExpanded] = useState(() => shouldExpand(level));
585
+ const rowRef = useRef<HTMLDivElement>(null);
586
+ const contentsId = useId();
587
+
588
+ if (entries.length === 0) {
435
589
  return (
436
- <span>
437
- {name != null && <ObjectName name={name} />}
438
- {name != null && <span>: </span>}
439
- {label}
440
- </span>
590
+ <EmptyContainer
591
+ field={field}
592
+ prefix={prefix}
593
+ open={open}
594
+ close={close}
595
+ isLast={isLast}
596
+ />
441
597
  );
442
598
  }
443
599
 
444
- // StreamRef inline clickable badge
445
- if (isStreamRef(data)) {
600
+ const toggle = () => {
601
+ setExpanded((value) => !value);
602
+ if (rowRef.current) focusExpander(rowRef.current, outerRef);
603
+ };
604
+
605
+ // Toggle only for clicks that land on this row itself; clicks bubbling up out
606
+ // of a nested treeitem are handled by that descendant's own row.
607
+ const onClick = (event: ReactMouseEvent) => {
608
+ if (
609
+ (event.target as HTMLElement).closest('[role="treeitem"]') !==
610
+ event.currentTarget
611
+ ) {
612
+ return;
613
+ }
614
+ toggle();
615
+ };
616
+
617
+ const onKeyDown = (event: ReactKeyboardEvent) => {
618
+ if (event.key === 'ArrowRight') {
619
+ event.preventDefault();
620
+ setExpanded(true);
621
+ } else if (event.key === 'ArrowLeft') {
622
+ event.preventDefault();
623
+ setExpanded(false);
624
+ } else if (event.key === 'ArrowUp') {
625
+ event.preventDefault();
626
+ moveExpanderFocus(outerRef, -1);
627
+ } else if (event.key === 'ArrowDown') {
628
+ event.preventDefault();
629
+ moveExpanderFocus(outerRef, 1);
630
+ }
631
+ };
632
+
633
+ const lastIndex = entries.length - 1;
634
+
635
+ return (
636
+ // The treeitem row is the focusable, keyboard-operable control (roving
637
+ // tabindex + arrow keys); the disclosure marker below is purely decorative.
638
+ <div
639
+ className={CLS.child}
640
+ role="treeitem"
641
+ aria-expanded={expanded}
642
+ aria-controls={expanded ? contentsId : undefined}
643
+ data-json-expander
644
+ ref={rowRef}
645
+ tabIndex={level === 0 ? 0 : -1}
646
+ onClick={onClick}
647
+ onKeyDown={onKeyDown}
648
+ >
649
+ <span
650
+ className={expanded ? CLS.collapseIcon : CLS.expandIcon}
651
+ aria-hidden="true"
652
+ />
653
+ {field !== undefined && (
654
+ <span className={CLS.clickableLabel}>{`${formatField(field)}:`}</span>
655
+ )}
656
+ {prefix ? <span className={CLS.className}>{prefix}</span> : null}
657
+ <span className={CLS.punctuation}>{open}</span>
658
+ {expanded ? (
659
+ // biome-ignore lint/a11y/useSemanticElements: ARIA tree group is the correct role here
660
+ <ul id={contentsId} className={CLS.childFields} role="group">
661
+ {entries.map(([childField, childValue], index) => (
662
+ <DataRender
663
+ key={childField ?? index}
664
+ field={childField}
665
+ value={childValue}
666
+ isLast={index === lastIndex}
667
+ ctx={{ ...ctx, level: level + 1 }}
668
+ />
669
+ ))}
670
+ </ul>
671
+ ) : (
672
+ <span className={CLS.collapsedContent} aria-hidden="true" />
673
+ )}
674
+ <span className={CLS.punctuation}>{close}</span>
675
+ <Comma isLast={isLast} />
676
+ </div>
677
+ );
678
+ }
679
+
680
+ interface NodeProps {
681
+ field?: string;
682
+ value: unknown;
683
+ isLast: boolean;
684
+ ctx: NodeContext;
685
+ }
686
+
687
+ function DataRender({ field, value, isLast, ctx }: NodeProps) {
688
+ if (isBytesDisplay(value)) {
446
689
  return (
447
- <span>
448
- {name != null && <ObjectName name={name} />}
449
- {name != null && <span>: </span>}
450
- <StreamRefInline streamRef={data} />
451
- </span>
690
+ <LeafRow field={field} isLast={isLast}>
691
+ <BytesDisplayValue display={value} />
692
+ </LeafRow>
452
693
  );
453
694
  }
454
-
455
- // RunRef → inline clickable badge linking to the target run
456
- if (isRunRef(data)) {
695
+ if (isEncryptedMarker(value)) {
457
696
  return (
458
- <span>
459
- {name != null && <ObjectName name={name} />}
460
- {name != null && <span>: </span>}
461
- <RunRefInline runRef={data} />
462
- </span>
697
+ <LeafRow field={field} isLast={isLast}>
698
+ <EncryptedInlineLabel />
699
+ </LeafRow>
463
700
  );
464
701
  }
465
-
466
- // ClassInstanceRef → show className as type, data as the inspectable value
467
- if (isClassInstanceRef(data)) {
468
- if (depth === 0) {
469
- return <ObjectRootLabel name={data.className} data={data.data} />;
470
- }
702
+ if (isStreamRef(value)) {
471
703
  return (
472
- <span>
473
- {name != null && <ObjectName name={name} />}
474
- {name != null && <span>: </span>}
475
- <span style={{ fontStyle: 'italic' }}>{data.className} </span>
476
- <ObjectValue object={data.data} />
477
- </span>
704
+ <LeafRow field={field} isLast={isLast}>
705
+ <StreamRefInline streamRef={value} />
706
+ </LeafRow>
707
+ );
708
+ }
709
+ if (isRunRef(value)) {
710
+ return (
711
+ <LeafRow field={field} isLast={isLast}>
712
+ <RunRefInline runRef={value} />
713
+ </LeafRow>
714
+ );
715
+ }
716
+ if (value instanceof Date) {
717
+ return (
718
+ <LeafRow field={field} isLast={isLast}>
719
+ <span className={CLS.date}>{value.toISOString()}</span>
720
+ </LeafRow>
721
+ );
722
+ }
723
+ if (value instanceof RegExp) {
724
+ return (
725
+ <LeafRow field={field} isLast={isLast}>
726
+ <span className={CLS.regexp}>{value.toString()}</span>
727
+ </LeafRow>
728
+ );
729
+ }
730
+ if (isClassInstanceRef(value)) {
731
+ return (
732
+ <ClassInstanceNode
733
+ field={field}
734
+ instance={value}
735
+ isLast={isLast}
736
+ ctx={ctx}
737
+ />
478
738
  );
479
739
  }
480
740
 
481
- // Date magenta color (Node.js: 'date' → 'magenta')
482
- // react-inspector has no OBJECT_VALUE_DATE_COLOR theme key, so we handle it here.
483
- if (data instanceof Date) {
484
- const dateStr = data.toISOString();
485
- if (depth === 0) {
486
- return (
487
- <span style={{ color: extendedTheme.OBJECT_VALUE_DATE_COLOR }}>
488
- {dateStr}
489
- </span>
490
- );
491
- }
741
+ const container = describeContainer(value);
742
+ if (container) {
492
743
  return (
493
- <span>
494
- {name != null && <ObjectName name={name} />}
495
- {name != null && <span>: </span>}
496
- <span style={{ color: extendedTheme.OBJECT_VALUE_DATE_COLOR }}>
497
- {dateStr}
498
- </span>
499
- </span>
744
+ <ExpandableContainer
745
+ field={field}
746
+ entries={container.entries}
747
+ open={container.open}
748
+ close={container.close}
749
+ prefix={container.prefix}
750
+ ctx={ctx}
751
+ isLast={isLast}
752
+ />
500
753
  );
501
754
  }
502
755
 
503
- // Default rendering (same as react-inspector's built-in)
504
- if (depth === 0) {
505
- return <ObjectRootLabel name={name} data={data} />;
756
+ return (
757
+ <PrimitiveValue field={field} value={value} isLast={isLast} ctx={ctx} />
758
+ );
759
+ }
760
+
761
+ function ClassInstanceNode({
762
+ field,
763
+ instance,
764
+ isLast,
765
+ ctx,
766
+ }: {
767
+ field?: string;
768
+ instance: ClassInstanceRef;
769
+ isLast: boolean;
770
+ ctx: NodeContext;
771
+ }) {
772
+ const container = describeContainer(instance.data);
773
+ if (container) {
774
+ return (
775
+ <ExpandableContainer
776
+ field={field}
777
+ entries={container.entries}
778
+ open={container.open}
779
+ close={container.close}
780
+ prefix={instance.className}
781
+ ctx={ctx}
782
+ isLast={isLast}
783
+ />
784
+ );
506
785
  }
786
+ const { text, cls } = describePrimitive(instance.data);
507
787
  return (
508
- <ObjectLabel name={name} data={data} isNonenumerable={isNonenumerable} />
788
+ <LeafRow field={field} isLast={isLast}>
789
+ <span className={CLS.className}>{instance.className}</span>
790
+ <span className={cls}>{text}</span>
791
+ </LeafRow>
792
+ );
793
+ }
794
+
795
+ function JsonTree({
796
+ data,
797
+ name,
798
+ expandLevel,
799
+ }: {
800
+ data: unknown;
801
+ name?: string;
802
+ expandLevel: number;
803
+ }) {
804
+ const outerRef = useRef<HTMLDivElement>(null);
805
+ const shouldExpand = useMemo(
806
+ () => (level: number) => level < expandLevel,
807
+ [expandLevel]
808
+ );
809
+ return (
810
+ <div
811
+ ref={outerRef}
812
+ className={CLS.container}
813
+ role="tree"
814
+ aria-label="JSON view"
815
+ >
816
+ <DataRender
817
+ field={name}
818
+ value={data}
819
+ isLast
820
+ ctx={{ level: 0, shouldExpand, outerRef }}
821
+ />
822
+ </div>
509
823
  );
510
824
  }
511
825
 
512
826
  // ---------------------------------------------------------------------------
513
- // Public component
827
+ // Ref / typed-array collapsing (preprocessing before render)
514
828
  // ---------------------------------------------------------------------------
515
829
 
516
830
  /**
517
831
  * Create a non-expandable wrapper that carries ref data as non-enumerable
518
- * properties. ObjectInspector won't render children for objects with no
519
- * enumerable keys, but our NodeRenderer can still detect them.
832
+ * properties so the renderer can detect StreamRef/RunRef without exposing
833
+ * their internals as object fields.
520
834
  */
521
835
  function makeOpaqueRef(ref: Record<string, unknown>): unknown {
522
836
  const opaque = Object.create(null);
@@ -545,11 +859,11 @@ function makeBytesDisplay(display: FormattedStreamChunkDisplay): unknown {
545
859
 
546
860
  /**
547
861
  * Recursively walk data and replace RunRef/StreamRef/typed array objects with
548
- * non-expandable versions so ObjectInspector doesn't show their internals.
862
+ * non-expandable versions so the renderer doesn't show their internals.
549
863
  * Only recurses into plain objects and arrays to avoid stripping class
550
- * instances (Date, Error, URL, Headers, etc.) that have their own rendering in
551
- * NodeRenderer. Map and Set containers are preserved while their contents are
552
- * prepared for display.
864
+ * instances (Date, Error, URL, Headers, etc.) that have their own rendering.
865
+ * Map and Set containers are preserved while their contents are prepared for
866
+ * display.
553
867
  *
554
868
  * Exported for testing the typed-array detection path used by hydrated
555
869
  * AI agent stream chunks (e.g. `{ delta: new Uint8Array(...) }`).
@@ -583,10 +897,14 @@ export function collapseRefs(data: unknown): unknown {
583
897
  return result;
584
898
  }
585
899
 
900
+ // ---------------------------------------------------------------------------
901
+ // Public component
902
+ // ---------------------------------------------------------------------------
903
+
586
904
  export interface DataInspectorProps {
587
905
  /** The data to inspect */
588
906
  data: unknown;
589
- /** Initial expand depth (default: 2) */
907
+ /** Levels strictly below this number auto-expand (default: 2) */
590
908
  expandLevel?: number;
591
909
  /** Optional name for the root node */
592
910
  name?: string;
@@ -611,59 +929,47 @@ export function DataInspector({
611
929
  }: DataInspectorProps) {
612
930
  const collapsedData = useMemo(() => collapseRefs(data), [data]);
613
931
  const stableData = useStableInspectorData(collapsedData);
614
- const [initialExpandLevel, setInitialExpandLevel] = useState(expandLevel);
615
- const isDark = useDarkMode();
616
- const extendedTheme = isDark
617
- ? inspectorThemeExtendedDark
618
- : inspectorThemeExtendedLight;
619
-
620
- useEffect(() => {
621
- // react-inspector reapplies expandLevel on every data change, which can
622
- // reopen paths the user manually collapsed. Apply it only on mount.
623
- setInitialExpandLevel(0);
624
- }, []);
625
-
626
- const content = (
627
- <ExtendedThemeContext.Provider value={extendedTheme}>
628
- <ObjectInspector
629
- data={stableData}
630
- name={name}
631
- // @ts-expect-error react-inspector accepts theme objects at runtime despite
632
- // types declaring string only — see https://github.com/storybookjs/react-inspector/blob/main/README.md#theme
633
- theme={isDark ? inspectorThemeDark : inspectorThemeLight}
634
- expandLevel={initialExpandLevel}
635
- nodeRenderer={NodeRenderer}
636
- />
637
- </ExtendedThemeContext.Provider>
638
- );
639
932
 
640
- let wrapped = content;
933
+ let content: ReactNode = (
934
+ <>
935
+ {/* React 19 hoists & dedupes this <style> by href, so it is emitted once
936
+ regardless of how many inspectors are mounted. */}
937
+ <style href="wf-json-view" precedence="default">
938
+ {JSON_VIEW_STYLES}
939
+ </style>
940
+ <JsonTree data={stableData} name={name} expandLevel={expandLevel} />
941
+ </>
942
+ );
641
943
 
642
944
  if (onStreamClick) {
643
- wrapped = (
945
+ content = (
644
946
  <StreamClickContext.Provider value={onStreamClick}>
645
- {wrapped}
947
+ {content}
646
948
  </StreamClickContext.Provider>
647
949
  );
648
950
  }
649
951
  if (onRunClick) {
650
- wrapped = (
952
+ content = (
651
953
  <RunClickContext.Provider value={onRunClick}>
652
- {wrapped}
954
+ {content}
653
955
  </RunClickContext.Provider>
654
956
  );
655
957
  }
656
958
  if (onDecrypt) {
657
- wrapped = (
959
+ content = (
658
960
  <DecryptClickContext.Provider value={{ onDecrypt, isDecrypting }}>
659
- {wrapped}
961
+ {content}
660
962
  </DecryptClickContext.Provider>
661
963
  );
662
964
  }
663
965
 
664
- return wrapped;
966
+ return content;
665
967
  }
666
968
 
969
+ // ---------------------------------------------------------------------------
970
+ // Render stabilization (avoid re-renders when data is deeply equal)
971
+ // ---------------------------------------------------------------------------
972
+
667
973
  function useStableInspectorData<T>(next: T): T {
668
974
  const previousRef = useRef<T>(next);
669
975
  if (!isDeepEqual(previousRef.current, next)) {