@workflow/web-shared 4.1.0-beta.55 → 4.1.0-beta.57

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 (42) hide show
  1. package/dist/components/event-list-view.d.ts.map +1 -1
  2. package/dist/components/event-list-view.js +34 -2
  3. package/dist/components/event-list-view.js.map +1 -1
  4. package/dist/components/run-trace-view.d.ts +4 -1
  5. package/dist/components/run-trace-view.d.ts.map +1 -1
  6. package/dist/components/run-trace-view.js +2 -2
  7. package/dist/components/run-trace-view.js.map +1 -1
  8. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  9. package/dist/components/sidebar/attribute-panel.js +122 -45
  10. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  11. package/dist/components/sidebar/copyable-data-block.d.ts +4 -0
  12. package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -0
  13. package/dist/components/sidebar/copyable-data-block.js +33 -0
  14. package/dist/components/sidebar/copyable-data-block.js.map +1 -0
  15. package/dist/components/sidebar/detail-card.d.ts +7 -1
  16. package/dist/components/sidebar/detail-card.d.ts.map +1 -1
  17. package/dist/components/sidebar/detail-card.js +12 -3
  18. package/dist/components/sidebar/detail-card.js.map +1 -1
  19. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  20. package/dist/components/sidebar/entity-detail-panel.js +30 -16
  21. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  22. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  23. package/dist/components/sidebar/events-list.js +37 -7
  24. package/dist/components/sidebar/events-list.js.map +1 -1
  25. package/dist/components/ui/error-stack-block.d.ts +19 -0
  26. package/dist/components/ui/error-stack-block.d.ts.map +1 -0
  27. package/dist/components/ui/error-stack-block.js +39 -0
  28. package/dist/components/ui/error-stack-block.js.map +1 -0
  29. package/dist/components/workflow-trace-view.d.ts +7 -1
  30. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  31. package/dist/components/workflow-trace-view.js +137 -24
  32. package/dist/components/workflow-trace-view.js.map +1 -1
  33. package/package.json +10 -10
  34. package/src/components/event-list-view.tsx +53 -2
  35. package/src/components/run-trace-view.tsx +9 -0
  36. package/src/components/sidebar/attribute-panel.tsx +285 -127
  37. package/src/components/sidebar/copyable-data-block.tsx +51 -0
  38. package/src/components/sidebar/detail-card.tsx +28 -2
  39. package/src/components/sidebar/entity-detail-panel.tsx +138 -81
  40. package/src/components/sidebar/events-list.tsx +72 -21
  41. package/src/components/ui/error-stack-block.tsx +80 -0
  42. package/src/components/workflow-trace-view.tsx +208 -28
@@ -3,12 +3,19 @@
3
3
  import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
4
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
5
5
  import type { ModelMessage } from 'ai';
6
- import type { ReactNode } from 'react';
7
- import { useMemo, useState } from 'react';
6
+ import type { KeyboardEvent, ReactNode } from 'react';
7
+ import { useCallback, useMemo, useState } from 'react';
8
+ import { toast } from 'sonner';
8
9
  import { extractConversation, isDoStreamStep } from '../../lib/utils';
9
- import { DataInspector, StreamClickContext } from '../ui/data-inspector';
10
+ import { StreamClickContext } from '../ui/data-inspector';
10
11
  import { ErrorCard } from '../ui/error-card';
12
+ import {
13
+ ErrorStackBlock,
14
+ isStructuredErrorWithStack,
15
+ } from '../ui/error-stack-block';
16
+ import { Skeleton } from '../ui/skeleton';
11
17
  import { ConversationView } from './conversation-view';
18
+ import { CopyableDataBlock } from './copyable-data-block';
12
19
  import { DetailCard } from './detail-card';
13
20
 
14
21
  /**
@@ -26,6 +33,9 @@ function TabButton({
26
33
  return (
27
34
  <button
28
35
  type="button"
36
+ role="tab"
37
+ aria-selected={active}
38
+ tabIndex={active ? 0 : -1}
29
39
  onClick={onClick}
30
40
  className="px-3 py-1.5 text-[11px] font-medium transition-colors -mb-px"
31
41
  style={{
@@ -49,49 +59,97 @@ function TabButton({
49
59
  }
50
60
 
51
61
  /**
52
- * Tabbed view for conversation and raw JSON
62
+ * Shared tabbed container with accessible ARIA roles and keyboard navigation.
63
+ * Used by ConversationWithTabs for the conversation/JSON toggle.
53
64
  */
54
- function ConversationWithTabs({
55
- conversation,
56
- args,
65
+ function TabbedContainer<T extends string>({
66
+ tabs,
67
+ activeTab,
68
+ onTabChange,
69
+ ariaLabel,
70
+ children,
57
71
  }: {
58
- conversation: ModelMessage[];
59
- args: unknown[];
72
+ tabs: { id: T; label: string }[];
73
+ activeTab: T;
74
+ onTabChange: (tab: T) => void;
75
+ ariaLabel: string;
76
+ children: ReactNode;
60
77
  }) {
61
- const [activeTab, setActiveTab] = useState<'conversation' | 'json'>(
62
- 'conversation'
78
+ const handleKeyDown = useCallback(
79
+ (event: KeyboardEvent<HTMLDivElement>) => {
80
+ if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
81
+ event.preventDefault();
82
+ const currentIndex = tabs.findIndex((t) => t.id === activeTab);
83
+ const nextIndex =
84
+ event.key === 'ArrowRight'
85
+ ? (currentIndex + 1) % tabs.length
86
+ : (currentIndex - 1 + tabs.length) % tabs.length;
87
+ onTabChange(tabs[nextIndex].id);
88
+ },
89
+ [tabs, activeTab, onTabChange]
63
90
  );
64
91
 
65
92
  return (
66
- <DetailCard summary={`Input (${conversation.length} messages)`}>
93
+ <div
94
+ className="rounded-md border"
95
+ style={{
96
+ borderColor: 'var(--ds-gray-300)',
97
+ backgroundColor: 'transparent',
98
+ }}
99
+ >
67
100
  <div
68
- className="rounded-md border"
101
+ className="flex gap-1 border-b"
102
+ role="tablist"
103
+ aria-label={ariaLabel}
104
+ onKeyDown={handleKeyDown}
69
105
  style={{
70
106
  borderColor: 'var(--ds-gray-300)',
71
107
  backgroundColor: 'transparent',
72
108
  }}
73
109
  >
74
- <div
75
- className="flex gap-1 border-b"
76
- style={{
77
- borderColor: 'var(--ds-gray-300)',
78
- backgroundColor: 'transparent',
79
- }}
80
- >
110
+ {tabs.map((tab) => (
81
111
  <TabButton
82
- active={activeTab === 'conversation'}
83
- onClick={() => setActiveTab('conversation')}
112
+ key={tab.id}
113
+ active={activeTab === tab.id}
114
+ onClick={() => onTabChange(tab.id)}
84
115
  >
85
- Conversation
116
+ {tab.label}
86
117
  </TabButton>
87
- <TabButton
88
- active={activeTab === 'json'}
89
- onClick={() => setActiveTab('json')}
90
- >
91
- Raw JSON
92
- </TabButton>
93
- </div>
118
+ ))}
119
+ </div>
120
+
121
+ <div role="tabpanel">{children}</div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ const conversationTabs = [
127
+ { id: 'conversation' as const, label: 'Conversation' },
128
+ { id: 'json' as const, label: 'Raw JSON' },
129
+ ];
130
+
131
+ /**
132
+ * Tabbed view for conversation and raw JSON
133
+ */
134
+ function ConversationWithTabs({
135
+ conversation,
136
+ args,
137
+ }: {
138
+ conversation: ModelMessage[];
139
+ args: unknown[];
140
+ }) {
141
+ const [activeTab, setActiveTab] = useState<'conversation' | 'json'>(
142
+ 'conversation'
143
+ );
94
144
 
145
+ return (
146
+ <DetailCard summary={`Input (${conversation.length} messages)`}>
147
+ <TabbedContainer
148
+ tabs={conversationTabs}
149
+ activeTab={activeTab}
150
+ onTabChange={setActiveTab}
151
+ ariaLabel="Conversation view"
152
+ >
95
153
  {activeTab === 'conversation' ? (
96
154
  <ConversationView messages={conversation} />
97
155
  ) : (
@@ -105,7 +163,7 @@ function ConversationWithTabs({
105
163
  : JsonBlock(args)}
106
164
  </div>
107
165
  )}
108
- </div>
166
+ </TabbedContainer>
109
167
  </DetailCard>
110
168
  );
111
169
  }
@@ -115,16 +173,17 @@ function ConversationWithTabs({
115
173
  * custom theming, nodeRenderer for StreamRef/ClassInstanceRef, etc.)
116
174
  */
117
175
  function JsonBlock(value: unknown) {
118
- return (
119
- <div
120
- className="overflow-x-auto rounded-md border p-3"
121
- style={{ borderColor: 'var(--ds-gray-300)' }}
122
- >
123
- <DataInspector data={value} />
124
- </div>
125
- );
176
+ return <CopyableDataBlock data={value} />;
126
177
  }
127
178
 
179
+ const hasDisplayContent = (value: unknown): boolean => {
180
+ if (value == null) return false;
181
+ if (typeof value === 'string') return value.trim().length > 0;
182
+ if (Array.isArray(value)) return value.length > 0;
183
+ if (typeof value === 'object') return Object.keys(value).length > 0;
184
+ return true;
185
+ };
186
+
128
187
  type AttributeKey =
129
188
  | keyof Step
130
189
  | keyof WorkflowRun
@@ -272,16 +331,23 @@ const attributeToDisplayFn: Record<
272
331
  retryAfter: localMillisecondTime,
273
332
  resumeAt: localMillisecondTime,
274
333
  // Resolved attributes, won't actually use this function
275
- metadata: JsonBlock,
334
+ metadata: (value: unknown) => {
335
+ if (!hasDisplayContent(value)) return null;
336
+ return JsonBlock(value);
337
+ },
276
338
  input: (value: unknown, context?: DisplayContext) => {
277
339
  // Check if input has args + closure vars structure
278
340
  if (value && typeof value === 'object' && 'args' in value) {
279
- const { args, closureVars } = value as {
341
+ const { args, closureVars, thisVal } = value as {
280
342
  args: unknown[];
281
343
  closureVars?: Record<string, unknown>;
344
+ thisVal?: unknown;
282
345
  };
283
346
  const argCount = Array.isArray(args) ? args.length : 0;
284
- const hasClosureVars = closureVars && Object.keys(closureVars).length > 0;
347
+ const argLabel = argCount === 1 ? 'argument' : 'arguments';
348
+ const hasClosureVars = hasDisplayContent(closureVars);
349
+ const hasThisVal = hasDisplayContent(thisVal);
350
+ const hasArgs = hasDisplayContent(args);
285
351
 
286
352
  // Check if this is a doStreamStep - show conversation view with tabs
287
353
  if (context?.stepName && isDoStreamStep(context.stepName)) {
@@ -295,17 +361,37 @@ const attributeToDisplayFn: Record<
295
361
  {JsonBlock(closureVars)}
296
362
  </DetailCard>
297
363
  )}
364
+ {hasThisVal && (
365
+ <DetailCard summary="This Value">
366
+ {JsonBlock(thisVal)}
367
+ </DetailCard>
368
+ )}
298
369
  </>
299
370
  );
300
371
  }
301
372
  }
302
373
 
374
+ // Don't render an empty "Input (0 arguments)" card when no input exists.
375
+ if (!hasArgs && !hasClosureVars && !hasThisVal) {
376
+ return (
377
+ <DetailCard
378
+ summary="Input (no data)"
379
+ disabled
380
+ summaryClassName="text-base py-2"
381
+ />
382
+ );
383
+ }
384
+
303
385
  return (
304
386
  <>
305
- <DetailCard summary={`Input (${argCount} arguments)`}>
387
+ <DetailCard
388
+ summary={`Input (${argCount} ${argLabel})`}
389
+ summaryClassName="text-base py-2"
390
+ contentClassName="mt-0"
391
+ >
306
392
  {Array.isArray(args)
307
393
  ? args.map((v, i) => (
308
- <div className="mt-2" key={i}>
394
+ <div className="mt-2 first:mt-0" key={i}>
309
395
  {JsonBlock(v)}
310
396
  </div>
311
397
  ))
@@ -316,17 +402,34 @@ const attributeToDisplayFn: Record<
316
402
  {JsonBlock(closureVars)}
317
403
  </DetailCard>
318
404
  )}
405
+ {hasThisVal && (
406
+ <DetailCard summary="this">{JsonBlock(thisVal)}</DetailCard>
407
+ )}
319
408
  </>
320
409
  );
321
410
  }
322
411
 
323
412
  // Fallback: treat as plain array or object
324
413
  const argCount = Array.isArray(value) ? value.length : 0;
414
+ const argLabel = argCount === 1 ? 'argument' : 'arguments';
415
+ if (!hasDisplayContent(value)) {
416
+ return (
417
+ <DetailCard
418
+ summary="Input (no data)"
419
+ disabled
420
+ summaryClassName="text-base py-2"
421
+ />
422
+ );
423
+ }
325
424
  return (
326
- <DetailCard summary={`Input (${argCount} arguments)`}>
425
+ <DetailCard
426
+ summary={`Input (${argCount} ${argLabel})`}
427
+ summaryClassName="text-base py-2"
428
+ contentClassName="mt-0"
429
+ >
327
430
  {Array.isArray(value)
328
431
  ? value.map((v, i) => (
329
- <div className="mt-2" key={i}>
432
+ <div className="mt-2 first:mt-0" key={i}>
330
433
  {JsonBlock(v)}
331
434
  </div>
332
435
  ))
@@ -335,72 +438,46 @@ const attributeToDisplayFn: Record<
335
438
  );
336
439
  },
337
440
  output: (value: unknown) => {
338
- return <DetailCard summary="Output">{JsonBlock(value)}</DetailCard>;
441
+ if (!hasDisplayContent(value)) return null;
442
+ return (
443
+ <DetailCard
444
+ summary="Output"
445
+ summaryClassName="text-base py-2"
446
+ contentClassName="mt-0"
447
+ >
448
+ {JsonBlock(value)}
449
+ </DetailCard>
450
+ );
339
451
  },
340
452
  error: (value: unknown) => {
341
- // Handle structured error format
342
- if (value && typeof value === 'object' && 'message' in value) {
343
- const error = value as {
344
- message: string;
345
- stack?: string;
346
- code?: string;
347
- };
453
+ if (!hasDisplayContent(value)) return null;
348
454
 
455
+ // If the error object has a `stack` field, render it as readable
456
+ // pre-formatted text. Otherwise fall back to the raw JSON viewer.
457
+ if (isStructuredErrorWithStack(value)) {
349
458
  return (
350
- <DetailCard summary="Error">
351
- <div className="flex flex-col gap-2">
352
- {/* Show code if it exists */}
353
- {error.code && (
354
- <div>
355
- <span
356
- className="text-[11px] font-medium"
357
- style={{ color: 'var(--ds-gray-700)' }}
358
- >
359
- Error Code:{' '}
360
- </span>
361
- <code
362
- className="text-[11px]"
363
- style={{ color: 'var(--ds-gray-1000)' }}
364
- >
365
- {error.code}
366
- </code>
367
- </div>
368
- )}
369
- {/* Show stack if available, otherwise just the message */}
370
- <pre
371
- className="text-[11px] overflow-x-auto rounded-md border p-3"
372
- style={{
373
- borderColor: 'var(--ds-gray-300)',
374
- backgroundColor: 'var(--ds-gray-100)',
375
- color: 'var(--ds-gray-1000)',
376
- whiteSpace: 'pre-wrap',
377
- }}
378
- >
379
- <code>{error.stack || error.message}</code>
380
- </pre>
381
- </div>
459
+ <DetailCard
460
+ summary="Error"
461
+ summaryClassName="text-base py-2"
462
+ contentClassName="mt-0"
463
+ >
464
+ <ErrorStackBlock value={value} />
382
465
  </DetailCard>
383
466
  );
384
467
  }
385
468
 
386
- // Fallback for plain string errors
387
469
  return (
388
- <DetailCard summary="Error">
389
- <pre
390
- className="text-[11px] overflow-x-auto rounded-md border p-3"
391
- style={{
392
- borderColor: 'var(--ds-gray-300)',
393
- backgroundColor: 'var(--ds-gray-100)',
394
- color: 'var(--ds-gray-1000)',
395
- whiteSpace: 'pre-wrap',
396
- }}
397
- >
398
- <code>{String(value)}</code>
399
- </pre>
470
+ <DetailCard
471
+ summary="Error"
472
+ summaryClassName="text-base py-2"
473
+ contentClassName="mt-0"
474
+ >
475
+ {JsonBlock(value)}
400
476
  </DetailCard>
401
477
  );
402
478
  },
403
479
  eventData: (value: unknown) => {
480
+ if (!hasDisplayContent(value)) return null;
404
481
  return <DetailCard summary="Event Data">{JsonBlock(value)}</DetailCard>;
405
482
  },
406
483
  };
@@ -439,6 +516,44 @@ export const AttributeBlock = ({
439
516
  inline?: boolean;
440
517
  context?: DisplayContext;
441
518
  }) => {
519
+ const isExpandableLoadingTarget =
520
+ attribute === 'input' ||
521
+ attribute === 'output' ||
522
+ attribute === 'eventData';
523
+ if (isLoading && isExpandableLoadingTarget) {
524
+ const label =
525
+ attribute === 'eventData'
526
+ ? 'Event Data'
527
+ : attribute === 'output'
528
+ ? 'Output'
529
+ : 'Input';
530
+ return (
531
+ <div
532
+ className={`my-2 flex flex-col ${attribute === 'input' || attribute === 'output' ? 'gap-2 my-3.5' : 'gap-0'}`}
533
+ >
534
+ <span
535
+ className={`${attribute === 'input' || attribute === 'output' ? 'text-base' : 'text-xs'} font-medium first-letter:uppercase`}
536
+ style={{ color: 'var(--ds-gray-700)' }}
537
+ >
538
+ {attribute}
539
+ </span>
540
+ <DetailCard
541
+ summary={label}
542
+ summaryClassName="text-base py-2"
543
+ disabled
544
+ />
545
+ <div
546
+ className="overflow-x-auto rounded-md border p-3"
547
+ style={{ borderColor: 'var(--ds-gray-300)' }}
548
+ >
549
+ <Skeleton className="h-4 w-[38%]" />
550
+ <Skeleton className="mt-2 h-4 w-[88%]" />
551
+ <Skeleton className="mt-2 h-4 w-[72%]" />
552
+ </div>
553
+ </div>
554
+ );
555
+ }
556
+
442
557
  const displayFn =
443
558
  attributeToDisplayFn[attribute as keyof typeof attributeToDisplayFn];
444
559
  if (!displayFn) {
@@ -475,9 +590,12 @@ export const AttributeBlock = ({
475
590
  />
476
591
  </div>
477
592
  )}
478
- <div key={attribute} className="flex flex-col gap-0 my-2">
593
+ <div
594
+ key={attribute}
595
+ className={`my-2 flex flex-col ${attribute === 'input' || attribute === 'output' || attribute === 'error' ? 'gap-2 my-3.5' : 'gap-0'}`}
596
+ >
479
597
  <span
480
- className="text-xs font-medium"
598
+ className={`${attribute === 'input' || attribute === 'output' || attribute === 'error' ? 'text-base' : 'text-xs'} font-medium first-letter:uppercase`}
481
599
  style={{ color: 'var(--ds-gray-700)' }}
482
600
  >
483
601
  {attribute}
@@ -575,6 +693,16 @@ export const AttributePanel = ({
575
693
  }),
576
694
  [displayData.stepName]
577
695
  );
696
+ const handleCopyModuleSpecifier = useCallback((value: string) => {
697
+ navigator.clipboard
698
+ .writeText(value)
699
+ .then(() => {
700
+ toast.success('moduleSpecifier copied');
701
+ })
702
+ .catch(() => {
703
+ toast.error('Failed to copy moduleSpecifier');
704
+ });
705
+ }, []);
578
706
 
579
707
  return (
580
708
  <StreamClickContext.Provider value={onStreamClick}>
@@ -582,36 +710,66 @@ export const AttributePanel = ({
582
710
  {/* Basic attributes in a vertical layout with border */}
583
711
  {visibleBasicAttributes.length > 0 && (
584
712
  <div
585
- className="flex flex-col divide-y rounded-lg border mb-3 overflow-hidden"
713
+ className="mb-3 flex flex-col overflow-hidden rounded-lg border"
586
714
  style={{
587
715
  borderColor: 'var(--ds-gray-300)',
588
- backgroundColor: 'var(--ds-gray-100)',
589
716
  }}
590
717
  >
591
- {orderedBasicAttributes.map((attribute) => (
592
- <div
593
- key={attribute}
594
- className="flex items-center justify-between px-3 py-1.5"
595
- style={{
596
- borderColor: 'var(--ds-gray-300)',
597
- }}
598
- >
599
- <span
600
- className="text-[11px] font-medium"
601
- style={{ color: 'var(--ds-gray-700)' }}
602
- >
603
- {getAttributeDisplayName(attribute)}
604
- </span>
605
- <span
606
- className="text-[11px] font-mono"
607
- style={{ color: 'var(--ds-gray-1000)' }}
608
- >
609
- {attributeToDisplayFn[
610
- attribute as keyof typeof attributeToDisplayFn
611
- ]?.(displayData[attribute as keyof typeof displayData])}
612
- </span>
613
- </div>
614
- ))}
718
+ {orderedBasicAttributes.map((attribute, index) => {
719
+ const displayValue = attributeToDisplayFn[
720
+ attribute as keyof typeof attributeToDisplayFn
721
+ ]?.(displayData[attribute as keyof typeof displayData]);
722
+ const isModuleSpecifier = attribute === 'moduleSpecifier';
723
+ const moduleSpecifierValue =
724
+ typeof displayValue === 'string'
725
+ ? displayValue
726
+ : String(displayValue ?? displayData.moduleSpecifier ?? '');
727
+ const showDivider = index < orderedBasicAttributes.length - 1;
728
+
729
+ return (
730
+ <div key={attribute} className="py-1">
731
+ <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
732
+ <span
733
+ className="text-[14px] first-letter:uppercase"
734
+ style={{ color: 'var(--ds-gray-700)' }}
735
+ >
736
+ {getAttributeDisplayName(attribute)}
737
+ </span>
738
+ {isModuleSpecifier ? (
739
+ <button
740
+ type="button"
741
+ className="min-w-0 max-w-[70%] truncate text-right text-[13px] font-mono"
742
+ style={{
743
+ color: 'var(--ds-gray-1000)',
744
+ background: 'transparent',
745
+ border: 'none',
746
+ padding: 0,
747
+ }}
748
+ title={moduleSpecifierValue}
749
+ onClick={() =>
750
+ handleCopyModuleSpecifier(moduleSpecifierValue)
751
+ }
752
+ >
753
+ {moduleSpecifierValue}
754
+ </button>
755
+ ) : (
756
+ <span
757
+ className="min-w-0 max-w-[70%] truncate text-right text-[13px] font-mono"
758
+ style={{ color: 'var(--ds-gray-1000)' }}
759
+ >
760
+ {displayValue}
761
+ </span>
762
+ )}
763
+ </div>
764
+ {showDivider ? (
765
+ <div
766
+ className="mx-2.5 border-b"
767
+ style={{ borderColor: 'var(--ds-gray-300)' }}
768
+ />
769
+ ) : null}
770
+ </div>
771
+ );
772
+ })}
615
773
  </div>
616
774
  )}
617
775
  {error ? (
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import { Copy } from 'lucide-react';
4
+ import { toast } from 'sonner';
5
+ import { DataInspector } from '../ui/data-inspector';
6
+
7
+ const serializeForClipboard = (value: unknown): string => {
8
+ if (typeof value === 'string') return value;
9
+ if (
10
+ typeof value === 'number' ||
11
+ typeof value === 'boolean' ||
12
+ value === null
13
+ ) {
14
+ return String(value);
15
+ }
16
+ try {
17
+ return JSON.stringify(value, null, 2);
18
+ } catch {
19
+ return String(value);
20
+ }
21
+ };
22
+
23
+ export function CopyableDataBlock({ data }: { data: unknown }) {
24
+ return (
25
+ <div
26
+ className="relative overflow-x-auto rounded-md border p-3 pt-9"
27
+ style={{ borderColor: 'var(--ds-gray-300)' }}
28
+ >
29
+ <button
30
+ type="button"
31
+ aria-label="Copy data"
32
+ title="Copy"
33
+ className="!absolute !right-2 !top-2 !flex !h-6 !w-6 !items-center !justify-center !rounded-md !border !bg-[var(--ds-background-100)] !text-[var(--ds-gray-800)] transition-transform transition-colors duration-100 hover:!bg-[var(--ds-gray-alpha-200)] active:!scale-95 active:!bg-[var(--ds-gray-alpha-300)]"
34
+ style={{ borderColor: 'var(--ds-gray-300)' }}
35
+ onClick={() => {
36
+ navigator.clipboard
37
+ .writeText(serializeForClipboard(data))
38
+ .then(() => {
39
+ toast.success('Copied to clipboard');
40
+ })
41
+ .catch(() => {
42
+ toast.error('Failed to copy');
43
+ });
44
+ }}
45
+ >
46
+ <Copy size={12} />
47
+ </button>
48
+ <DataInspector data={data} />
49
+ </div>
50
+ );
51
+ }
@@ -4,19 +4,45 @@ export function DetailCard({
4
4
  summary,
5
5
  children,
6
6
  onToggle,
7
+ disabled = false,
8
+ summaryClassName,
9
+ contentClassName,
7
10
  }: {
8
11
  summary: ReactNode;
9
12
  children?: ReactNode;
10
13
  /** Called when the detail card is expanded/collapsed */
11
14
  onToggle?: (open: boolean) => void;
15
+ /** Renders a non-expandable summary card when true. */
16
+ disabled?: boolean;
17
+ /** Extra classes for the summary row. */
18
+ summaryClassName?: string;
19
+ /** Extra classes for expanded content wrapper. */
20
+ contentClassName?: string;
12
21
  }) {
22
+ if (disabled) {
23
+ return (
24
+ <div
25
+ className={`rounded-md border px-2.5 py-1.5 text-xs ${summaryClassName ?? ''}`}
26
+ style={{
27
+ borderColor: 'var(--ds-gray-300)',
28
+ backgroundColor: 'var(--ds-gray-100)',
29
+ color: 'var(--ds-gray-700)',
30
+ cursor: 'not-allowed',
31
+ opacity: 0.8,
32
+ }}
33
+ >
34
+ {summary}
35
+ </div>
36
+ );
37
+ }
38
+
13
39
  return (
14
40
  <details
15
41
  className="group"
16
42
  onToggle={(e) => onToggle?.((e.target as HTMLDetailsElement).open)}
17
43
  >
18
44
  <summary
19
- className="cursor-pointer rounded-md border px-2.5 py-1.5 text-xs hover:brightness-95"
45
+ className={`cursor-pointer rounded-md border px-2.5 py-1.5 text-xs hover:brightness-95 ${summaryClassName ?? ''}`}
20
46
  style={{
21
47
  borderColor: 'var(--ds-gray-300)',
22
48
  backgroundColor: 'var(--ds-gray-100)',
@@ -26,7 +52,7 @@ export function DetailCard({
26
52
  {summary}
27
53
  </summary>
28
54
  {/* Expanded content with connecting line */}
29
- <div className="relative pl-6 mt-3">
55
+ <div className={`relative pl-6 mt-3 ${contentClassName ?? ''}`}>
30
56
  {/* Curved connecting line - vertical part from summary */}
31
57
  <div
32
58
  className="absolute left-3 -top-3 w-px h-3"