@workflow/web-shared 5.0.0-beta.20 → 5.0.0-beta.22
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.
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -1
- package/dist/components/new-trace-viewer/components/trace-shortcut-helper.d.ts +5 -0
- package/dist/components/new-trace-viewer/components/trace-shortcut-helper.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/trace-shortcut-helper.js +55 -0
- package/dist/components/new-trace-viewer/components/trace-shortcut-helper.module.css +20 -0
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +4 -4
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +4 -1
- package/dist/components/sidebar/copyable-data-block.d.ts +1 -0
- package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
- package/dist/components/sidebar/copyable-data-block.js +2 -2
- package/dist/components/ui/data-inspector.d.ts +6 -6
- package/dist/components/ui/data-inspector.d.ts.map +1 -1
- package/dist/components/ui/data-inspector.js +234 -91
- package/dist/components/ui/data-inspector.styles.d.ts +29 -0
- package/dist/components/ui/data-inspector.styles.d.ts.map +1 -0
- package/dist/components/ui/data-inspector.styles.js +95 -0
- package/dist/components/ui/kbd.d.ts +9 -5
- package/dist/components/ui/kbd.d.ts.map +1 -1
- package/dist/components/ui/kbd.js +22 -3
- package/dist/lib/hydration.js +3 -3
- package/package.json +3 -4
- package/src/components/index.ts +1 -0
- package/src/components/new-trace-viewer/components/trace-shortcut-helper.module.css +20 -0
- package/src/components/new-trace-viewer/components/trace-shortcut-helper.tsx +116 -0
- package/src/components/new-trace-viewer/trace-viewer.tsx +9 -4
- package/src/components/sidebar/attribute-panel.tsx +4 -0
- package/src/components/sidebar/copyable-data-block.tsx +1 -1
- package/src/components/ui/data-inspector.styles.ts +96 -0
- package/src/components/ui/data-inspector.tsx +492 -186
- package/src/components/ui/kbd.tsx +33 -11
- package/src/lib/hydration.ts +2 -2
- package/dist/components/ui/inspector-theme.d.ts +0 -95
- package/dist/components/ui/inspector-theme.d.ts.map +0 -1
- package/dist/components/ui/inspector-theme.js +0 -82
- 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
|
|
4
|
+
* Reusable data inspector for the o11y UI.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
<
|
|
373
|
-
|
|
374
|
-
{
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
398
|
-
*
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
isNonenumerable?: boolean;
|
|
417
|
-
expanded?: boolean;
|
|
487
|
+
field?: string;
|
|
488
|
+
isLast: boolean;
|
|
489
|
+
children: ReactNode;
|
|
418
490
|
}) {
|
|
419
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
<
|
|
437
|
-
|
|
438
|
-
{
|
|
439
|
-
{
|
|
440
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
<
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
<
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
<
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
<
|
|
494
|
-
|
|
495
|
-
{
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
<
|
|
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
|
-
//
|
|
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
|
|
519
|
-
*
|
|
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
|
|
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
|
|
551
|
-
*
|
|
552
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
945
|
+
content = (
|
|
644
946
|
<StreamClickContext.Provider value={onStreamClick}>
|
|
645
|
-
{
|
|
947
|
+
{content}
|
|
646
948
|
</StreamClickContext.Provider>
|
|
647
949
|
);
|
|
648
950
|
}
|
|
649
951
|
if (onRunClick) {
|
|
650
|
-
|
|
952
|
+
content = (
|
|
651
953
|
<RunClickContext.Provider value={onRunClick}>
|
|
652
|
-
{
|
|
954
|
+
{content}
|
|
653
955
|
</RunClickContext.Provider>
|
|
654
956
|
);
|
|
655
957
|
}
|
|
656
958
|
if (onDecrypt) {
|
|
657
|
-
|
|
959
|
+
content = (
|
|
658
960
|
<DecryptClickContext.Provider value={{ onDecrypt, isDecrypting }}>
|
|
659
|
-
{
|
|
961
|
+
{content}
|
|
660
962
|
</DecryptClickContext.Provider>
|
|
661
963
|
);
|
|
662
964
|
}
|
|
663
965
|
|
|
664
|
-
return
|
|
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)) {
|