@workflow/web-shared 5.0.0-beta.25 → 5.0.0-beta.27

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 (74) hide show
  1. package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts +34 -0
  2. package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts.map +1 -0
  3. package/dist/components/new-trace-viewer/components/detail-panel-width.js +67 -0
  4. package/dist/components/new-trace-viewer/components/detail-panel.d.ts +14 -0
  5. package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -0
  6. package/dist/components/new-trace-viewer/components/detail-panel.js +122 -0
  7. package/dist/components/new-trace-viewer/components/draggable-border.d.ts +31 -0
  8. package/dist/components/new-trace-viewer/components/draggable-border.d.ts.map +1 -0
  9. package/dist/components/new-trace-viewer/components/draggable-border.js +140 -0
  10. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +1 -1
  11. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -1
  12. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +2 -2
  13. package/dist/components/new-trace-viewer/components/pane-constants.d.ts +14 -0
  14. package/dist/components/new-trace-viewer/components/pane-constants.d.ts.map +1 -0
  15. package/dist/components/new-trace-viewer/components/pane-constants.js +16 -0
  16. package/dist/components/new-trace-viewer/components/split-pane.d.ts +1 -4
  17. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  18. package/dist/components/new-trace-viewer/components/split-pane.js +18 -16
  19. package/dist/components/new-trace-viewer/components/timeline.d.ts +2 -2
  20. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  21. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  22. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +5 -2
  23. package/dist/components/new-trace-viewer/components/use-element-width.d.ts +8 -0
  24. package/dist/components/new-trace-viewer/components/use-element-width.d.ts.map +1 -0
  25. package/dist/components/new-trace-viewer/components/use-element-width.js +25 -0
  26. package/dist/components/new-trace-viewer/context.d.ts +2 -0
  27. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  28. package/dist/components/new-trace-viewer/context.js +3 -2
  29. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  30. package/dist/components/new-trace-viewer/trace-viewer.js +10 -90
  31. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  32. package/dist/components/sidebar/attribute-panel.js +38 -35
  33. package/dist/components/sidebar/attributes-block.d.ts +18 -7
  34. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  35. package/dist/components/sidebar/attributes-block.js +76 -18
  36. package/dist/components/sidebar/events-list.d.ts +1 -1
  37. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  38. package/dist/components/sidebar/events-list.js +9 -10
  39. package/dist/components/trace-viewer/components/map.d.ts +2 -2
  40. package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
  41. package/dist/components/trace-viewer/components/node.d.ts +7 -7
  42. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  43. package/dist/components/ui/collapsible.d.ts +46 -0
  44. package/dist/components/ui/collapsible.d.ts.map +1 -0
  45. package/dist/components/ui/collapsible.js +66 -0
  46. package/dist/components/ui/data-inspector.js +2 -7
  47. package/dist/index.d.ts +2 -2
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -3
  50. package/dist/lib/hydration.d.ts +12 -2
  51. package/dist/lib/hydration.d.ts.map +1 -1
  52. package/dist/lib/hydration.js +41 -18
  53. package/package.json +6 -6
  54. package/src/components/new-trace-viewer/components/detail-panel-width.test.ts +123 -0
  55. package/src/components/new-trace-viewer/components/detail-panel-width.ts +76 -0
  56. package/src/components/new-trace-viewer/components/detail-panel.tsx +272 -0
  57. package/src/components/new-trace-viewer/components/draggable-border.tsx +216 -0
  58. package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +1 -1
  59. package/src/components/new-trace-viewer/components/pane-constants.ts +19 -0
  60. package/src/components/new-trace-viewer/components/split-pane.tsx +44 -55
  61. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +4 -1
  62. package/src/components/new-trace-viewer/components/use-element-width.ts +29 -0
  63. package/src/components/new-trace-viewer/context.tsx +4 -1
  64. package/src/components/new-trace-viewer/trace-viewer.tsx +18 -199
  65. package/src/components/sidebar/attribute-panel.tsx +106 -105
  66. package/src/components/sidebar/attributes-block.tsx +170 -50
  67. package/src/components/sidebar/events-list.tsx +107 -92
  68. package/src/components/ui/collapsible.tsx +219 -0
  69. package/src/index.ts +8 -7
  70. package/src/lib/hydration.ts +48 -25
  71. package/dist/components/sidebar/detail-card.d.ts +0 -13
  72. package/dist/components/sidebar/detail-card.d.ts.map +0 -1
  73. package/dist/components/sidebar/detail-card.js +0 -36
  74. package/src/components/sidebar/detail-card.tsx +0 -143
@@ -8,8 +8,12 @@ import type { KeyboardEvent, ReactNode } from 'react';
8
8
  import { useCallback, useContext, useMemo, useState } from 'react';
9
9
  import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration';
10
10
  import { extractConversation, isDoStreamStep } from '../../lib/utils';
11
- import { CopyButton } from '../new-trace-viewer/components/copy-button';
12
- import { MiddleTruncate } from '../new-trace-viewer/components/middle-truncate/middle-truncate';
11
+ import {
12
+ Collapsible,
13
+ CollapsibleContent,
14
+ CollapsibleRoot,
15
+ CollapsibleTrigger,
16
+ } from '../ui/collapsible';
13
17
  import { ContextCardProvider } from '../ui/context-card';
14
18
  import {
15
19
  DecryptClickContext,
@@ -20,10 +24,13 @@ import { ErrorCard } from '../ui/error-card';
20
24
  import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
21
25
  import { Skeleton } from '../ui/skeleton';
22
26
  import { TimestampTooltip } from '../ui/timestamp-tooltip';
23
- import { RunAttributesCard } from './attributes-block';
27
+ import {
28
+ DetailMonoKeyValueRow,
29
+ RunAttributesCard,
30
+ RunMetadataCard,
31
+ } from './attributes-block';
24
32
  import { ConversationView } from './conversation-view';
25
33
  import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block';
26
- import { DetailCard } from './detail-card';
27
34
 
28
35
  /**
29
36
  * Tab button for conversation/JSON toggle
@@ -150,7 +157,7 @@ function ConversationWithTabs({
150
157
  );
151
158
 
152
159
  return (
153
- <DetailCard summary="Input">
160
+ <Collapsible label="Input">
154
161
  <TabbedContainer
155
162
  tabs={conversationTabs}
156
163
  activeTab={activeTab}
@@ -171,7 +178,7 @@ function ConversationWithTabs({
171
178
  </div>
172
179
  )}
173
180
  </TabbedContainer>
174
- </DetailCard>
181
+ </Collapsible>
175
182
  );
176
183
  }
177
184
 
@@ -413,7 +420,7 @@ const attributeToDisplayFn: Record<
413
420
  environment: (_value: unknown) => null,
414
421
  executionContext: (_value: unknown) => null,
415
422
  // Attributes — string-string metadata attached to the run.
416
- // Rendered as key-value rows in its own collapsible DetailCard;
423
+ // Rendered as key-value rows in its own collapsible section;
417
424
  // if empty/missing, hidden by the hasDisplayContent gate.
418
425
  attributes: (value: unknown) => {
419
426
  if (!hasDisplayContent(value)) return null;
@@ -431,16 +438,28 @@ const attributeToDisplayFn: Record<
431
438
  // Resolved attributes, won't actually use this function
432
439
  metadata: (value: unknown) => {
433
440
  if (!hasDisplayContent(value)) return null;
434
- if (isEncryptedMarker(value)) return <EncryptedDataBlock />;
435
- if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
436
- return JsonBlock(value);
441
+ if (isEncryptedMarker(value)) {
442
+ return (
443
+ <Collapsible label="Metadata">
444
+ <EncryptedDataBlock />
445
+ </Collapsible>
446
+ );
447
+ }
448
+ if (isExpiredMarker(value)) {
449
+ return (
450
+ <Collapsible label="Metadata">
451
+ <ExpiredFieldBlock />
452
+ </Collapsible>
453
+ );
454
+ }
455
+ return <RunMetadataCard metadata={value} />;
437
456
  },
438
457
  input: (value: unknown, context?: DisplayContext) => {
439
458
  if (isEncryptedMarker(value)) {
440
459
  return (
441
- <DetailCard summary="Input">
460
+ <Collapsible label="Input">
442
461
  <EncryptedFieldBlock />
443
- </DetailCard>
462
+ </Collapsible>
444
463
  );
445
464
  }
446
465
  if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
@@ -463,14 +482,14 @@ const attributeToDisplayFn: Record<
463
482
  <>
464
483
  <ConversationWithTabs conversation={conversation} args={args} />
465
484
  {hasClosureVars && (
466
- <DetailCard summary="Closure Variables">
485
+ <Collapsible label="Closure Variables">
467
486
  {JsonBlock(closureVars)}
468
- </DetailCard>
487
+ </Collapsible>
469
488
  )}
470
489
  {hasThisVal && (
471
- <DetailCard summary="This Value">
490
+ <Collapsible label="This Value">
472
491
  {JsonBlock(thisVal)}
473
- </DetailCard>
492
+ </Collapsible>
474
493
  )}
475
494
  </>
476
495
  );
@@ -479,12 +498,12 @@ const attributeToDisplayFn: Record<
479
498
 
480
499
  // Don't render an empty "Input (0 arguments)" card when no input exists.
481
500
  if (!hasArgs && !hasClosureVars && !hasThisVal) {
482
- return <DetailCard summary="Input (no data)" disabled />;
501
+ return <Collapsible label="Input (no data)" disabled />;
483
502
  }
484
503
 
485
504
  return (
486
505
  <>
487
- <DetailCard summary="Input">
506
+ <Collapsible label="Input">
488
507
  {Array.isArray(args)
489
508
  ? args.map((v, i) => (
490
509
  <div className="mt-2 first:mt-0" key={i}>
@@ -492,14 +511,14 @@ const attributeToDisplayFn: Record<
492
511
  </div>
493
512
  ))
494
513
  : JsonBlock(args)}
495
- </DetailCard>
514
+ </Collapsible>
496
515
  {hasClosureVars && (
497
- <DetailCard summary="Closure Variables">
516
+ <Collapsible label="Closure Variables">
498
517
  {JsonBlock(closureVars)}
499
- </DetailCard>
518
+ </Collapsible>
500
519
  )}
501
520
  {hasThisVal && (
502
- <DetailCard summary="Context">{JsonBlock(thisVal)}</DetailCard>
521
+ <Collapsible label="Context">{JsonBlock(thisVal)}</Collapsible>
503
522
  )}
504
523
  </>
505
524
  );
@@ -507,10 +526,10 @@ const attributeToDisplayFn: Record<
507
526
 
508
527
  // Fallback: treat as plain array or object
509
528
  if (!hasDisplayContent(value)) {
510
- return <DetailCard summary="Input (no data)" disabled />;
529
+ return <Collapsible label="Input (no data)" disabled />;
511
530
  }
512
531
  return (
513
- <DetailCard summary="Input">
532
+ <Collapsible label="Input">
514
533
  {Array.isArray(value)
515
534
  ? value.map((v, i) => (
516
535
  <div className="mt-2 first:mt-0" key={i}>
@@ -518,27 +537,27 @@ const attributeToDisplayFn: Record<
518
537
  </div>
519
538
  ))
520
539
  : JsonBlock(value)}
521
- </DetailCard>
540
+ </Collapsible>
522
541
  );
523
542
  },
524
543
  output: (value: unknown) => {
525
544
  if (isEncryptedMarker(value)) {
526
545
  return (
527
- <DetailCard summary="Output">
546
+ <Collapsible label="Output">
528
547
  <EncryptedFieldBlock />
529
- </DetailCard>
548
+ </Collapsible>
530
549
  );
531
550
  }
532
551
  if (!hasDisplayContent(value)) return null;
533
552
  if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
534
- return <DetailCard summary="Output">{JsonBlock(value)}</DetailCard>;
553
+ return <Collapsible label="Output">{JsonBlock(value)}</Collapsible>;
535
554
  },
536
555
  error: (value: unknown) => {
537
556
  if (isEncryptedMarker(value)) {
538
557
  return (
539
- <DetailCard summary="Error" defaultOpen>
558
+ <Collapsible label="Error" defaultOpen>
540
559
  <EncryptedFieldBlock />
541
- </DetailCard>
560
+ </Collapsible>
542
561
  );
543
562
  }
544
563
  if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
@@ -548,32 +567,32 @@ const attributeToDisplayFn: Record<
548
567
  // `{ message, stack }`. Render both with the dedicated error block.
549
568
  if (isStructuredError(value)) {
550
569
  return (
551
- <DetailCard summary="Error" defaultOpen>
570
+ <Collapsible label="Error" defaultOpen>
552
571
  <ErrorStackBlock value={value} />
553
- </DetailCard>
572
+ </Collapsible>
554
573
  );
555
574
  }
556
575
 
557
576
  return (
558
- <DetailCard summary="Error" defaultOpen>
577
+ <Collapsible label="Error" defaultOpen>
559
578
  {JsonBlock(value)}
560
- </DetailCard>
579
+ </Collapsible>
561
580
  );
562
581
  },
563
582
  eventData: (value: unknown) => {
564
583
  if (isEncryptedMarker(value)) {
565
584
  return (
566
- <DetailCard summary="Event Data" defaultOpen>
585
+ <Collapsible label="Event Data" defaultOpen>
567
586
  <EncryptedFieldBlock />
568
- </DetailCard>
587
+ </Collapsible>
569
588
  );
570
589
  }
571
590
  if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
572
591
  if (!hasDisplayContent(value)) return null;
573
592
  return (
574
- <DetailCard summary="Event Data" defaultOpen>
593
+ <Collapsible label="Event Data" defaultOpen>
575
594
  {JsonBlock(value)}
576
- </DetailCard>
595
+ </Collapsible>
577
596
  );
578
597
  },
579
598
  errorCode: (value: unknown) => {
@@ -591,12 +610,13 @@ const resolvableAttributes = [
591
610
  'eventData',
592
611
  ];
593
612
 
594
- // Attributes whose displayFn renders its own section header via DetailCard,
613
+ // Attributes whose displayFn renders its own section header via Collapsible,
595
614
  // so the outer AttributeBlock should not duplicate the label.
596
615
  const selfHeaderedAttributes = new Set([
597
616
  'input',
598
617
  'output',
599
618
  'error',
619
+ 'metadata',
600
620
  'attributes',
601
621
  'eventData',
602
622
  ]);
@@ -649,12 +669,12 @@ export const AttributeBlock = ({
649
669
  : 'Input';
650
670
  if (decryptCtx?.hasEncryptedData) {
651
671
  return (
652
- <DetailCard summary={label} defaultOpen={attribute === 'eventData'}>
672
+ <Collapsible label={label} defaultOpen={attribute === 'eventData'}>
653
673
  <EncryptedFieldBlock />
654
- </DetailCard>
674
+ </Collapsible>
655
675
  );
656
676
  }
657
- return <DetailCard summary={label} />;
677
+ return <Collapsible label={label} />;
658
678
  }
659
679
 
660
680
  const displayFn =
@@ -835,61 +855,44 @@ export const AttributePanel = ({
835
855
  <StreamClickContext.Provider value={onStreamClick}>
836
856
  <DecryptClickContext.Provider value={decryptValue}>
837
857
  {visibleBasicAttributes.length > 0 && (
838
- <div className="flex flex-col overflow-hidden divide-y divide-gray-alpha-400 mb-3">
839
- {orderedBasicAttributes.map((attribute) => {
840
- const displayValue = attributeToDisplayFn[
841
- attribute as keyof typeof attributeToDisplayFn
842
- ]?.(displayData[attribute as keyof typeof displayData]);
843
- const isCopyableBasicAttribute =
844
- copyableBasicAttributes.has(attribute as AttributeKey) &&
845
- typeof displayValue === 'string';
846
-
847
- return (
848
- <div
849
- className="flex items-center justify-between py-2"
850
- key={attribute}
851
- >
852
- <span className="text-label-14 text-gray-900">
853
- {getAttributeDisplayName(attribute)}
854
- </span>
855
- {isCopyableBasicAttribute ? (
856
- <div
857
- className="flex min-w-0 max-w-[70%] items-center justify-end gap-1 text-[13px] font-mono text-gray-1000"
858
- title={displayValue}
859
- >
860
- <MiddleTruncate
861
- value={displayValue}
862
- className="text-right"
863
- style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}
864
- />
865
- <CopyButton
866
- copyText={displayValue}
867
- ariaLabel={`Copy ${getAttributeDisplayName(attribute)}`}
868
- className="shrink-0 -mr-1"
869
- />
858
+ <CollapsibleRoot defaultOpen>
859
+ <CollapsibleTrigger>Metadata</CollapsibleTrigger>
860
+ <CollapsibleContent className="mt-0 mb-2">
861
+ <div className="flex flex-col">
862
+ {orderedBasicAttributes.map((attribute) => {
863
+ const displayValue = attributeToDisplayFn[
864
+ attribute as keyof typeof attributeToDisplayFn
865
+ ]?.(displayData[attribute as keyof typeof displayData]);
866
+ const isCopyableBasicAttribute =
867
+ copyableBasicAttributes.has(
868
+ attribute as AttributeKey
869
+ ) && typeof displayValue === 'string';
870
+ const label = getAttributeDisplayName(attribute);
871
+
872
+ return (
873
+ <DetailMonoKeyValueRow
874
+ key={attribute}
875
+ label={label}
876
+ value={displayValue}
877
+ copyText={
878
+ isCopyableBasicAttribute ? displayValue : undefined
879
+ }
880
+ />
881
+ );
882
+ })}
883
+ {isLoading &&
884
+ resource === 'sleep' &&
885
+ !displayData.resumeAt && (
886
+ <div className="flex items-center justify-between gap-3 py-0.5">
887
+ <span className="text-label-13 text-gray-900">
888
+ Resume
889
+ </span>
890
+ <Skeleton className="h-4 w-[55%]" />
870
891
  </div>
871
- ) : (
872
- <span className="text-right text-label-13 font-mono">
873
- {displayValue}
874
- </span>
875
892
  )}
876
- </div>
877
- );
878
- })}
879
- {isLoading && resource === 'sleep' && !displayData.resumeAt && (
880
- <div className="py-1">
881
- <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
882
- <span
883
- className="text-[14px] first-letter:uppercase"
884
- style={{ color: 'var(--ds-gray-700)' }}
885
- >
886
- resumeAt
887
- </span>
888
- <Skeleton className="h-4 w-[55%]" />
889
- </div>
890
893
  </div>
891
- )}
892
- </div>
894
+ </CollapsibleContent>
895
+ </CollapsibleRoot>
893
896
  )}
894
897
  {error ? (
895
898
  <ErrorCard
@@ -900,17 +903,15 @@ export const AttributePanel = ({
900
903
  ) : hasExpired ? (
901
904
  <ExpiredDataMessage />
902
905
  ) : resolvedAttributes.length > 0 ? (
903
- <>
904
- {resolvedAttributes.map((attribute) => (
905
- <AttributeBlock
906
- isLoading={isLoading}
907
- key={attribute}
908
- attribute={attribute}
909
- value={displayData[attribute as keyof typeof displayData]}
910
- context={displayContext}
911
- />
912
- ))}
913
- </>
906
+ resolvedAttributes.map((attribute) => (
907
+ <AttributeBlock
908
+ isLoading={isLoading}
909
+ key={attribute}
910
+ attribute={attribute}
911
+ value={displayData[attribute as keyof typeof displayData]}
912
+ context={displayContext}
913
+ />
914
+ ))
914
915
  ) : null}
915
916
  </DecryptClickContext.Provider>
916
917
  </StreamClickContext.Provider>
@@ -4,60 +4,94 @@ import {
4
4
  type AttributeChange,
5
5
  RESERVED_ATTRIBUTE_KEY_PREFIX,
6
6
  } from '@workflow/world';
7
+ import { cva, type VariantProps } from 'class-variance-authority';
8
+ import type { ReactNode } from 'react';
9
+ import { cn } from '../../lib/cn';
10
+ import { CopyButton } from '../new-trace-viewer/components/copy-button';
11
+ import { MiddleTruncate } from '../new-trace-viewer/components/middle-truncate/middle-truncate';
12
+ import {
13
+ CollapsibleContent,
14
+ CollapsibleRoot,
15
+ CollapsibleTrigger,
16
+ } from '../ui/collapsible';
7
17
  import { CopyableDataBlock } from './copyable-data-block';
8
- import { DetailCard } from './detail-card';
9
-
10
- /**
11
- * Check whether an attribute key is in the reserved (`$`-prefixed)
12
- * namespace used by framework/library code.
13
- */
14
- export const isReservedAttributeKey = (key: string): boolean =>
15
- key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX);
16
18
 
17
- function ReservedBadge() {
18
- return (
19
- <span
20
- className="shrink-0 rounded border px-1 py-px text-[10px] font-medium leading-3"
21
- style={{
22
- borderColor: 'var(--ds-gray-alpha-400)',
23
- backgroundColor: 'var(--ds-gray-100)',
24
- color: 'var(--ds-gray-700)',
25
- }}
26
- title={`Keys prefixed with "${RESERVED_ATTRIBUTE_KEY_PREFIX}" are reserved for framework and library code`}
27
- >
28
- Reserved
29
- </span>
30
- );
19
+ function isReservedAttributeKey(key: string): boolean {
20
+ return key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX);
31
21
  }
32
22
 
33
- function AttributeRow({
34
- attributeKey,
23
+ const rowValueVariants = cva(
24
+ 'max-w-[60%] truncate text-right text-copy-13 text-gray-1000',
25
+ {
26
+ variants: {
27
+ variant: {
28
+ default: '',
29
+ mono: 'font-mono',
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ variant: 'default',
34
+ },
35
+ }
36
+ );
37
+
38
+ const rowCopyValueVariants = cva(
39
+ 'flex min-w-0 max-w-[60%] items-center justify-end gap-1 text-copy-13 text-gray-1000',
40
+ {
41
+ variants: {
42
+ variant: {
43
+ default: '',
44
+ mono: 'font-mono',
45
+ },
46
+ },
47
+ defaultVariants: {
48
+ variant: 'default',
49
+ },
50
+ }
51
+ );
52
+
53
+ type DetailKeyValueRowProps = {
54
+ label: string;
55
+ value?: ReactNode;
56
+ copyText?: string;
57
+ removed?: boolean;
58
+ };
59
+
60
+ function DetailKeyValueRowBase({
61
+ label,
35
62
  value,
63
+ copyText,
36
64
  removed = false,
37
- }: {
38
- attributeKey: string;
39
- value?: string;
40
- removed?: boolean;
41
- }) {
42
- const reserved = isReservedAttributeKey(attributeKey);
65
+ variant,
66
+ }: DetailKeyValueRowProps & VariantProps<typeof rowValueVariants>) {
67
+ const stringValue = typeof value === 'string' ? value : undefined;
68
+
43
69
  return (
44
- <div className="flex items-center justify-between gap-3 py-0.5">
45
- <span
46
- className="flex min-w-0 items-center gap-1.5 text-label-13 text-gray-900"
47
- style={reserved ? { color: 'var(--ds-gray-700)' } : undefined}
48
- >
49
- <span className="truncate">{attributeKey}</span>
50
- {reserved && <ReservedBadge />}
70
+ <div className="px-1.5 hover:bg-gray-100 flex justify-between gap-3 -mx-1.5 py-0.5 rounded items-center">
71
+ <span className="flex min-w-0 items-center gap-1.5 truncate text-label-13 text-gray-900">
72
+ {label}
51
73
  </span>
52
74
  {removed ? (
53
75
  <span className="shrink-0 text-copy-13 italic text-gray-700">
54
76
  removed
55
77
  </span>
78
+ ) : copyText ? (
79
+ <div className={cn(rowCopyValueVariants({ variant }))} title={copyText}>
80
+ <MiddleTruncate
81
+ value={copyText}
82
+ className={cn(
83
+ rowValueVariants({ variant, className: 'text-right' })
84
+ )}
85
+ style={{ gridTemplateColumns: 'minmax(0, 1fr)' }}
86
+ />
87
+ <CopyButton
88
+ copyText={copyText}
89
+ ariaLabel={`Copy ${label}`}
90
+ className="shrink-0 -mr-1"
91
+ />
92
+ </div>
56
93
  ) : (
57
- <span
58
- className="max-w-[60%] truncate text-copy-13 text-gray-1000"
59
- title={value}
60
- >
94
+ <span className={cn(rowValueVariants({ variant }))} title={stringValue}>
61
95
  {value}
62
96
  </span>
63
97
  )}
@@ -65,6 +99,28 @@ function AttributeRow({
65
99
  );
66
100
  }
67
101
 
102
+ export function DetailKeyValueRow(props: DetailKeyValueRowProps) {
103
+ return <DetailKeyValueRowBase {...props} />;
104
+ }
105
+
106
+ export function DetailMonoKeyValueRow(props: DetailKeyValueRowProps) {
107
+ return <DetailKeyValueRowBase {...props} variant="mono" />;
108
+ }
109
+
110
+ function AttributeRow({
111
+ attributeKey,
112
+ value,
113
+ removed = false,
114
+ }: {
115
+ attributeKey: string;
116
+ value?: string;
117
+ removed?: boolean;
118
+ }) {
119
+ return (
120
+ <DetailKeyValueRow label={attributeKey} value={value} removed={removed} />
121
+ );
122
+ }
123
+
68
124
  /**
69
125
  * Sort attribute keys for display: user keys first (alphabetical), then
70
126
  * reserved `$`-prefixed keys (alphabetical), so framework metadata doesn't
@@ -81,8 +137,7 @@ export function sortAttributeKeys(keys: string[]): string[] {
81
137
 
82
138
  /**
83
139
  * Collapsible section showing a run's materialized attributes as key-value
84
- * rows. Reserved (`$`-prefixed) keys are visually de-emphasized with a
85
- * badge and sorted after user keys.
140
+ * rows. Reserved (`$`-prefixed) keys are sorted after user keys.
86
141
  */
87
142
  export function RunAttributesCard({
88
143
  attributes,
@@ -93,13 +148,78 @@ export function RunAttributesCard({
93
148
  if (keys.length === 0) return null;
94
149
 
95
150
  return (
96
- <DetailCard summary="Attributes" defaultOpen contentClassName="mb-4">
97
- <div className="flex flex-col">
98
- {keys.map((key) => (
99
- <AttributeRow attributeKey={key} key={key} value={attributes[key]} />
100
- ))}
101
- </div>
102
- </DetailCard>
151
+ <CollapsibleRoot defaultOpen>
152
+ <CollapsibleTrigger>Attributes</CollapsibleTrigger>
153
+ <CollapsibleContent className="mb-4">
154
+ <div className="flex flex-col">
155
+ {keys.map((key) => (
156
+ <AttributeRow
157
+ attributeKey={key}
158
+ key={key}
159
+ value={attributes[key]}
160
+ />
161
+ ))}
162
+ </div>
163
+ </CollapsibleContent>
164
+ </CollapsibleRoot>
165
+ );
166
+ }
167
+
168
+ function formatMetadataValue(value: unknown): string {
169
+ if (value == null) {
170
+ return String(value);
171
+ }
172
+ if (typeof value === 'string') {
173
+ return value;
174
+ }
175
+ if (typeof value === 'number' || typeof value === 'boolean') {
176
+ return String(value);
177
+ }
178
+ try {
179
+ return JSON.stringify(value);
180
+ } catch {
181
+ return String(value);
182
+ }
183
+ }
184
+
185
+ function isMetadataRecord(value: unknown): value is Record<string, unknown> {
186
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
187
+ }
188
+
189
+ /**
190
+ * Collapsible section showing hook (or other resource) metadata as key-value
191
+ * rows, matching the Attributes section styling.
192
+ */
193
+ export function RunMetadataCard({ metadata }: { metadata: unknown }) {
194
+ if (!isMetadataRecord(metadata)) {
195
+ return (
196
+ <CollapsibleRoot defaultOpen>
197
+ <CollapsibleTrigger>Metadata</CollapsibleTrigger>
198
+ <CollapsibleContent className="mb-4">
199
+ <CopyableDataBlock data={metadata} />
200
+ </CollapsibleContent>
201
+ </CollapsibleRoot>
202
+ );
203
+ }
204
+
205
+ const keys = Object.keys(metadata).sort((a, b) => a.localeCompare(b));
206
+ if (keys.length === 0) return null;
207
+
208
+ return (
209
+ <CollapsibleRoot defaultOpen>
210
+ <CollapsibleTrigger>Metadata</CollapsibleTrigger>
211
+ <CollapsibleContent className="mb-4">
212
+ <div className="flex flex-col">
213
+ {keys.map((key) => (
214
+ <AttributeRow
215
+ attributeKey={key}
216
+ key={key}
217
+ value={formatMetadataValue(metadata[key])}
218
+ />
219
+ ))}
220
+ </div>
221
+ </CollapsibleContent>
222
+ </CollapsibleRoot>
103
223
  );
104
224
  }
105
225