@workflow/web-shared 5.0.0-beta.14 → 5.0.0-beta.16

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 (57) hide show
  1. package/dist/components/event-list-view.d.ts +1 -1
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +20 -3
  4. package/dist/components/new-trace-viewer/components/timeline.d.ts +1 -1
  5. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  6. package/dist/components/new-trace-viewer/components/timeline.js +61 -13
  7. package/dist/components/new-trace-viewer/components/timeline.module.css +31 -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 +8 -9
  10. package/dist/components/new-trace-viewer/utils.d.ts +1 -1
  11. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  12. package/dist/components/new-trace-viewer/utils.js +12 -5
  13. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  14. package/dist/components/sidebar/attribute-panel.js +26 -32
  15. package/dist/components/sidebar/attributes-block.d.ts +28 -0
  16. package/dist/components/sidebar/attributes-block.d.ts.map +1 -0
  17. package/dist/components/sidebar/attributes-block.js +83 -0
  18. package/dist/components/sidebar/entity-detail-panel.js +2 -2
  19. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  20. package/dist/components/sidebar/events-list.js +8 -1
  21. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  22. package/dist/components/trace-viewer/components/node.js +2 -1
  23. package/dist/components/ui/context-card.d.ts +47 -0
  24. package/dist/components/ui/context-card.d.ts.map +1 -0
  25. package/dist/components/ui/context-card.js +471 -0
  26. package/dist/components/ui/timestamp-tooltip.d.ts +34 -1
  27. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -1
  28. package/dist/components/ui/timestamp-tooltip.js +80 -132
  29. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  30. package/dist/components/workflow-trace-view.js +8 -10
  31. package/dist/components/workflow-traces/event-colors.d.ts +1 -0
  32. package/dist/components/workflow-traces/event-colors.d.ts.map +1 -1
  33. package/dist/components/workflow-traces/event-colors.js +12 -1
  34. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  35. package/dist/components/workflow-traces/trace-span-construction.js +2 -1
  36. package/dist/lib/trace-builder.d.ts +15 -0
  37. package/dist/lib/trace-builder.d.ts.map +1 -1
  38. package/dist/lib/trace-builder.js +27 -2
  39. package/dist/styles.css +9 -0
  40. package/package.json +6 -4
  41. package/src/components/event-list-view.tsx +25 -2
  42. package/src/components/new-trace-viewer/components/timeline.module.css +31 -0
  43. package/src/components/new-trace-viewer/components/timeline.tsx +134 -10
  44. package/src/components/new-trace-viewer/trace-viewer.tsx +17 -14
  45. package/src/components/new-trace-viewer/utils.ts +12 -4
  46. package/src/components/sidebar/attribute-panel.tsx +117 -119
  47. package/src/components/sidebar/attributes-block.tsx +182 -0
  48. package/src/components/sidebar/entity-detail-panel.tsx +1 -1
  49. package/src/components/sidebar/events-list.tsx +8 -0
  50. package/src/components/trace-viewer/components/node.tsx +3 -2
  51. package/src/components/ui/context-card.tsx +784 -0
  52. package/src/components/ui/timestamp-tooltip.tsx +132 -194
  53. package/src/components/workflow-trace-view.tsx +18 -10
  54. package/src/components/workflow-traces/event-colors.ts +12 -0
  55. package/src/components/workflow-traces/trace-span-construction.ts +1 -0
  56. package/src/lib/trace-builder.ts +32 -1
  57. package/src/styles.css +9 -0
@@ -4,13 +4,14 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
4
4
  import type { CSSProperties, ReactNode } from 'react';
5
5
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { cn } from '../../../lib/utils';
7
- import type { Span } from '../types';
8
7
  import {
9
8
  formatDuration,
10
9
  formatDurationPrecise,
11
10
  getHighResInMs,
12
11
  } from '../../trace-viewer/util/timing';
12
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
13
13
  import { isSpanDimmedBySearch, type SpanSearchResult } from '../search';
14
+ import type { Span } from '../types';
14
15
  import type { Segment, SegmentStatus, TimeMarker } from '../utils';
15
16
  import {
16
17
  computeSpanGaps,
@@ -18,6 +19,7 @@ import {
18
19
  getResourceColor,
19
20
  getSpanDurationMs,
20
21
  } from '../utils';
22
+ import styles from './timeline.module.css';
21
23
  import { ROW_HEIGHT_PX, useRowWindow } from './use-row-window';
22
24
 
23
25
  // ---------------------------------------------------------------------------
@@ -33,6 +35,7 @@ const SEGMENT_CLASSES: Record<SegmentStatus, string> = {
33
35
  retrying: 'bg-gray-400 border border-gray-500',
34
36
  waiting: 'bg-gray-400 border border-gray-500',
35
37
  running: 'bg-blue-200 border border-blue-500',
38
+ completed: 'bg-blue-200 border border-blue-500',
36
39
  failed: 'bg-red-200 border border-red-500',
37
40
  succeeded: 'bg-green-200 border border-green-500',
38
41
  sleeping: 'bg-gray-400 border border-gray-500',
@@ -44,6 +47,15 @@ const TIMELINE_INSET_STYLE: CSSProperties = {
44
47
  right: TIMELINE_PADDING_PX,
45
48
  };
46
49
 
50
+ const ACTIVE_SEGMENT_STATUSES: ReadonlySet<SegmentStatus> = new Set([
51
+ 'running',
52
+ 'received',
53
+ ]);
54
+
55
+ function RunningStripes(): ReactNode {
56
+ return <div aria-hidden className={styles.runningStripes} />;
57
+ }
58
+
47
59
  // ---------------------------------------------------------------------------
48
60
  // Bar geometry
49
61
  // ---------------------------------------------------------------------------
@@ -222,25 +234,57 @@ function PlainBar({
222
234
  );
223
235
  }
224
236
 
237
+ function LeadInConnector({
238
+ leftPct,
239
+ widthPct,
240
+ label,
241
+ }: {
242
+ leftPct: number;
243
+ widthPct: number;
244
+ label: string | null;
245
+ }): ReactNode {
246
+ return (
247
+ <div
248
+ className="absolute top-1/2 h-6 -translate-y-1/2"
249
+ style={{
250
+ left: `calc(${leftPct}% + 0.5px)`,
251
+ width: `calc(${widthPct}% - 1px)`,
252
+ }}
253
+ >
254
+ <div className="absolute left-0 top-1/2 h-4 w-px -translate-y-1/2 bg-gray-500" />
255
+ <div className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-gray-500" />
256
+ {label ? <DurationLabel label={label} /> : null}
257
+ </div>
258
+ );
259
+ }
260
+
225
261
  function SegmentBar({ segments }: { segments: VisibleSegment[] }): ReactNode {
226
262
  return (
227
263
  <div className="relative h-6 w-full">
228
264
  {segments.map((seg, i) => {
265
+ if (seg.status === 'queued') {
266
+ const leadInLabel = formatDurationPrecise(seg.fullDurationMs);
267
+ const showLeadInLabel =
268
+ seg.pixelWidth >= Math.max(40, leadInLabel.length * 6 + 12);
269
+ return (
270
+ <LeadInConnector
271
+ key={i}
272
+ leftPct={seg.leftPct}
273
+ widthPct={seg.widthPct}
274
+ label={showLeadInLabel ? leadInLabel : null}
275
+ />
276
+ );
277
+ }
278
+
229
279
  const label = formatDurationPrecise(seg.fullDurationMs);
230
280
  // Only render the label when there's enough room for it without clipping.
231
281
  const showLabel = seg.pixelWidth >= Math.max(40, label.length * 6 + 12);
232
- // Beef up the queued segment when it's too narrow to read.
233
- const isNarrowQueued = seg.status === 'queued' && seg.pixelWidth < 20;
234
- const overrideBg = isNarrowQueued ? 'var(--ds-gray-400)' : undefined;
235
- const overrideBorder = isNarrowQueued
236
- ? 'var(--ds-gray-500)'
237
- : undefined;
238
282
 
239
283
  return (
240
284
  <div
241
285
  key={i}
242
286
  className={cn(
243
- 'absolute h-full rounded-[0.25rem]',
287
+ 'absolute h-full overflow-hidden rounded-[0.25rem]',
244
288
  SEGMENT_CLASSES[seg.status]
245
289
  )}
246
290
  style={{
@@ -248,10 +292,11 @@ function SegmentBar({ segments }: { segments: VisibleSegment[] }): ReactNode {
248
292
  left: `calc(${seg.leftPct}% + 0.5px)`,
249
293
  width: `calc(${seg.widthPct}% - 1px)`,
250
294
  minWidth: 1,
251
- background: overrideBg,
252
- borderColor: overrideBorder,
253
295
  }}
254
296
  >
297
+ {ACTIVE_SEGMENT_STATUSES.has(seg.status) ? (
298
+ <RunningStripes />
299
+ ) : null}
255
300
  {showLabel ? <DurationLabel label={label} /> : null}
256
301
  </div>
257
302
  );
@@ -260,6 +305,80 @@ function SegmentBar({ segments }: { segments: VisibleSegment[] }): ReactNode {
260
305
  );
261
306
  }
262
307
 
308
+ // ---------------------------------------------------------------------------
309
+ // Event markers (attr_set)
310
+ // ---------------------------------------------------------------------------
311
+
312
+ function formatMarkerTime(ms: number): string {
313
+ const date = new Date(ms);
314
+ return (
315
+ date.toLocaleTimeString('en-US', {
316
+ hour: '2-digit',
317
+ minute: '2-digit',
318
+ second: '2-digit',
319
+ hour12: false,
320
+ }) +
321
+ '.' +
322
+ date.getMilliseconds().toString().padStart(3, '0')
323
+ );
324
+ }
325
+
326
+ /**
327
+ * Diamond markers for `attr_set` events on a span's timeline row. Attribute
328
+ * changes are point-in-time events with no duration, so they don't get a
329
+ * status segment — instead they render as small teal diamonds at the moment
330
+ * the attributes were written.
331
+ */
332
+ function AttrSetMarkers({
333
+ span,
334
+ viewStart,
335
+ viewDuration,
336
+ }: {
337
+ span: Span;
338
+ viewStart: number;
339
+ viewDuration: number;
340
+ }): ReactNode {
341
+ const markers = useMemo(
342
+ () => span.events.filter((e) => e.name === 'attr_set'),
343
+ [span.events]
344
+ );
345
+
346
+ if (markers.length === 0 || viewDuration <= 0) return null;
347
+
348
+ return (
349
+ <>
350
+ {markers.map((event, index) => {
351
+ const timeMs = getHighResInMs(event.timestamp);
352
+ const frac = (timeMs - viewStart) / viewDuration;
353
+ if (frac < 0 || frac > 1) return null;
354
+
355
+ return (
356
+ <Tooltip key={`attr-set-${index}`}>
357
+ <TooltipTrigger asChild>
358
+ <div
359
+ aria-label="Attributes set"
360
+ className="absolute top-1/2 z-10 flex h-4 w-4 -translate-x-1/2 -translate-y-1/2 cursor-pointer items-center justify-center"
361
+ style={{ left: `${frac * 100}%` }}
362
+ >
363
+ <div
364
+ className="h-2 w-2 rotate-45 rounded-[1px] border"
365
+ style={{
366
+ backgroundColor: 'var(--ds-teal-400)',
367
+ borderColor: 'var(--ds-teal-900)',
368
+ }}
369
+ />
370
+ </div>
371
+ </TooltipTrigger>
372
+ <TooltipContent>
373
+ Attributes set · {formatMarkerTime(timeMs)}
374
+ </TooltipContent>
375
+ </Tooltip>
376
+ );
377
+ })}
378
+ </>
379
+ );
380
+ }
381
+
263
382
  // ---------------------------------------------------------------------------
264
383
  // TimelineBar
265
384
  // ---------------------------------------------------------------------------
@@ -359,6 +478,11 @@ const TimelineBar = memo(function TimelineBar({
359
478
  />
360
479
  )}
361
480
  </div>
481
+ <AttrSetMarkers
482
+ span={span}
483
+ viewStart={viewStart}
484
+ viewDuration={viewDuration}
485
+ />
362
486
  </div>
363
487
  </div>
364
488
  );
@@ -21,17 +21,23 @@ import {
21
21
  } from 'react';
22
22
  import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
23
23
  import { useReducedMotion } from '../../hooks/use-reduced-motion';
24
+ import { filterSpanRawEvents } from '../../lib/trace-builder';
24
25
  import { ErrorBoundary } from '../error-boundary';
25
26
  import {
26
27
  EntityDetailPanel,
27
28
  type SelectedSpanInfo,
28
29
  } from '../sidebar/entity-detail-panel';
29
30
  import { useSidebarData } from '../sidebar/sidebar-data-context';
30
- import type { TraceWithMeta } from './types';
31
31
  import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
32
32
  import { IconButton } from '../ui/icon-button';
33
- import { Spinner } from '../ui/spinner';
34
33
  import { Kbd } from '../ui/kbd';
34
+ import { Spinner } from '../ui/spinner';
35
+ import {
36
+ Tooltip,
37
+ TooltipContent,
38
+ TooltipProvider,
39
+ TooltipTrigger,
40
+ } from '../ui/tooltip';
35
41
  import EventList from './components/event-list';
36
42
  import { SplitPane } from './components/split-pane';
37
43
  import {
@@ -39,14 +45,9 @@ import {
39
45
  Timeline,
40
46
  TimelineHeader,
41
47
  } from './components/timeline';
42
- import {
43
- Tooltip,
44
- TooltipContent,
45
- TooltipProvider,
46
- TooltipTrigger,
47
- } from '../ui/tooltip';
48
48
  import { ActiveSpanProvider, useActiveSpan } from './context';
49
49
  import { searchSpans } from './search';
50
+ import type { TraceWithMeta } from './types';
50
51
  import { computeRootBounds, computeTimeMarkers } from './utils';
51
52
 
52
53
  interface NewTraceViewerProps {
@@ -145,14 +146,16 @@ function useSelectedSpanInfo(): SelectedSpanInfo | null {
145
146
  return useMemo(() => {
146
147
  if (!activeSpan) return null;
147
148
 
148
- const correlationId = activeSpan.spanId;
149
- const rawEvents = correlationId
150
- ? sidebar.events.filter((e) => e.correlationId === correlationId)
151
- : [];
149
+ const resource = activeSpan.attributes?.resource as string | undefined;
150
+ const rawEvents = filterSpanRawEvents(
151
+ sidebar.events,
152
+ resource,
153
+ activeSpan.spanId
154
+ );
152
155
 
153
156
  return {
154
157
  data: activeSpan.attributes?.data,
155
- resource: activeSpan.attributes?.resource as string | undefined,
158
+ resource,
156
159
  spanId: activeSpan.spanId,
157
160
  rawEvents,
158
161
  };
@@ -459,7 +462,7 @@ function NewTraceViewerContent({
459
462
  )
460
463
  );
461
464
  const isMouseWheel = e.deltaMode === 1 || Math.abs(e.deltaY) >= 50;
462
- const scaleFactor = Math.pow(2, dy / (isMouseWheel ? 200 : 60));
465
+ const scaleFactor = 2 ** (dy / (isMouseWheel ? 200 : 60));
463
466
 
464
467
  setViewport((prev) => {
465
468
  const prevDuration = prev.end - prev.start;
@@ -185,6 +185,7 @@ export function getResourceColor(resource: string): {
185
185
  export type SegmentStatus =
186
186
  | 'queued'
187
187
  | 'running'
188
+ | 'completed'
188
189
  | 'failed'
189
190
  | 'retrying'
190
191
  | 'succeeded'
@@ -373,6 +374,12 @@ function computeSleepSegmentsFromSpan(
373
374
  return [{ startFraction: 0, endFraction: 1, status: 'sleeping' }];
374
375
  }
375
376
 
377
+ function runSegmentStatus(runStatus: string | undefined): SegmentStatus {
378
+ if (runStatus === 'failed') return 'failed';
379
+ if (runStatus === 'running' || runStatus === 'pending') return 'running';
380
+ return 'completed';
381
+ }
382
+
376
383
  function computeRunSegmentsFromSpan(
377
384
  startMs: number,
378
385
  duration: number,
@@ -387,11 +394,12 @@ function computeRunSegmentsFromSpan(
387
394
  .map((e) => ({ name: e.name, time: getHighResInMs(e.timestamp) }))
388
395
  .sort((a, b) => a.time - b.time);
389
396
 
397
+ const runData = attributes?.data as Record<string, unknown> | undefined;
398
+ const runStatus = runData?.status as string | undefined;
399
+
390
400
  const hasRunCreated = sorted.some((e) => e.name === 'run_created');
391
401
 
392
402
  if (!hasRunCreated) {
393
- const runData = attributes?.data as Record<string, unknown> | undefined;
394
- const runStatus = runData?.status as string | undefined;
395
403
  return computeV1RunSegments(startMs, duration, activeStartMs, runStatus);
396
404
  }
397
405
 
@@ -413,7 +421,7 @@ function computeRunSegmentsFromSpan(
413
421
  segments.push({
414
422
  startFraction: cursor,
415
423
  endFraction: 1,
416
- status: failedEvent ? 'failed' : 'running',
424
+ status: failedEvent ? 'failed' : runSegmentStatus(runStatus),
417
425
  });
418
426
 
419
427
  return segments;
@@ -443,7 +451,7 @@ function computeV1RunSegments(
443
451
  segments.push({
444
452
  startFraction: cursor,
445
453
  endFraction: 1,
446
- status: runStatus === 'failed' ? 'failed' : 'running',
454
+ status: runSegmentStatus(runStatus),
447
455
  });
448
456
 
449
457
  return segments;
@@ -3,6 +3,7 @@
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 { format } from 'date-fns';
6
7
  import type { KeyboardEvent, ReactNode } from 'react';
7
8
  import { useCallback, useContext, useMemo, useState } from 'react';
8
9
  import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration';
@@ -10,6 +11,7 @@ import { useToast } from '../../lib/toast';
10
11
  import { extractConversation, isDoStreamStep } from '../../lib/utils';
11
12
  import { CopyButton } from '../new-trace-viewer/components/copy-button';
12
13
  import { MiddleTruncate } from '../new-trace-viewer/components/middle-truncate/middle-truncate';
14
+ import { ContextCardProvider } from '../ui/context-card';
13
15
  import {
14
16
  DecryptClickContext,
15
17
  RunClickContext,
@@ -19,6 +21,7 @@ import { ErrorCard } from '../ui/error-card';
19
21
  import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
20
22
  import { Skeleton } from '../ui/skeleton';
21
23
  import { TimestampTooltip } from '../ui/timestamp-tooltip';
24
+ import { RunAttributesCard } from './attributes-block';
22
25
  import { ConversationView } from './conversation-view';
23
26
  import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block';
24
27
  import { DetailCard } from './detail-card';
@@ -339,15 +342,7 @@ const parseDateValue = (value: unknown): Date | null => {
339
342
  };
340
343
 
341
344
  const formatLocalMillisecondTime = (date: Date): string =>
342
- date.toLocaleString(undefined, {
343
- year: 'numeric',
344
- month: 'numeric',
345
- day: 'numeric',
346
- hour: 'numeric',
347
- minute: 'numeric',
348
- second: 'numeric',
349
- fractionalSecondDigits: 3,
350
- });
345
+ format(date, 'MMM dd HH:mm:ss.SS OO').toUpperCase();
351
346
 
352
347
  export const localMillisecondTime = (value: unknown): string => {
353
348
  const date = parseDateValue(value);
@@ -355,7 +350,6 @@ export const localMillisecondTime = (value: unknown): string => {
355
350
  return '-';
356
351
  }
357
352
 
358
- // e.g. 12/17/2025, 9:08:55.182 AM
359
353
  return formatLocalMillisecondTime(date);
360
354
  };
361
355
 
@@ -416,12 +410,12 @@ const attributeToDisplayFn: Record<
416
410
  projectId: (_value: unknown) => null,
417
411
  environment: (_value: unknown) => null,
418
412
  executionContext: (_value: unknown) => null,
419
- // Attributes MVP — string-string metadata attached to the run.
420
- // Rendered in its own collapsible DetailCard; if empty/missing, hidden
421
- // by the hasDisplayContent gate.
413
+ // Attributes — string-string metadata attached to the run.
414
+ // Rendered as key-value rows in its own collapsible DetailCard;
415
+ // if empty/missing, hidden by the hasDisplayContent gate.
422
416
  attributes: (value: unknown) => {
423
417
  if (!hasDisplayContent(value)) return null;
424
- return <DetailCard summary="Attributes">{JsonBlock(value)}</DetailCard>;
418
+ return <RunAttributesCard attributes={value as Record<string, string>} />;
425
419
  },
426
420
  // Dates — wrapped with TimestampTooltip showing UTC/local + relative time
427
421
  createdAt: timestampWithTooltipOrNull,
@@ -846,113 +840,117 @@ export const AttributePanel = ({
846
840
  : outerDecryptCtx;
847
841
 
848
842
  return (
849
- <RunClickContext.Provider value={onRunClick}>
850
- <StreamClickContext.Provider value={onStreamClick}>
851
- <DecryptClickContext.Provider value={decryptValue}>
852
- {visibleBasicAttributes.length > 0 && (
853
- <div className="flex flex-col overflow-hidden divide-y divide-gray-alpha-400 mb-3">
854
- {orderedBasicAttributes.map((attribute) => {
855
- const displayValue = attributeToDisplayFn[
856
- attribute as keyof typeof attributeToDisplayFn
857
- ]?.(displayData[attribute as keyof typeof displayData]);
858
- const isModuleSpecifier = attribute === 'moduleSpecifier';
859
- const isCopyableBasicAttribute =
860
- copyableBasicAttributes.has(attribute as AttributeKey) &&
861
- typeof displayValue === 'string';
862
- const moduleSpecifierValue =
863
- typeof displayValue === 'string'
864
- ? displayValue
865
- : String(displayValue ?? displayData.moduleSpecifier ?? '');
866
-
867
- return (
868
- <div
869
- className="flex items-center justify-between py-2"
870
- key={attribute}
871
- >
872
- <span className="text-label-14 text-gray-900">
873
- {getAttributeDisplayName(attribute)}
874
- </span>
875
- {isModuleSpecifier ? (
876
- <button
877
- type="button"
878
- className="min-w-0 max-w-[70%] truncate text-right text-label-13 font-mono"
879
- style={{
880
- color: 'var(--ds-gray-1000)',
881
- background: 'transparent',
882
- border: 'none',
883
- padding: 0,
884
- }}
885
- title={moduleSpecifierValue}
886
- onClick={() =>
887
- handleCopyModuleSpecifier(moduleSpecifierValue)
888
- }
889
- >
890
- {moduleSpecifierValue}
891
- </button>
892
- ) : isCopyableBasicAttribute ? (
893
- <div
894
- className="flex min-w-0 max-w-[70%] items-center justify-end gap-1 text-right text-[13px] font-mono"
895
- style={{
896
- color: 'var(--ds-gray-1000)',
897
- }}
898
- title={displayValue}
843
+ <ContextCardProvider>
844
+ <RunClickContext.Provider value={onRunClick}>
845
+ <StreamClickContext.Provider value={onStreamClick}>
846
+ <DecryptClickContext.Provider value={decryptValue}>
847
+ {visibleBasicAttributes.length > 0 && (
848
+ <div className="flex flex-col overflow-hidden divide-y divide-gray-alpha-400 mb-3">
849
+ {orderedBasicAttributes.map((attribute) => {
850
+ const displayValue = attributeToDisplayFn[
851
+ attribute as keyof typeof attributeToDisplayFn
852
+ ]?.(displayData[attribute as keyof typeof displayData]);
853
+ const isModuleSpecifier = attribute === 'moduleSpecifier';
854
+ const isCopyableBasicAttribute =
855
+ copyableBasicAttributes.has(attribute as AttributeKey) &&
856
+ typeof displayValue === 'string';
857
+ const moduleSpecifierValue =
858
+ typeof displayValue === 'string'
859
+ ? displayValue
860
+ : String(
861
+ displayValue ?? displayData.moduleSpecifier ?? ''
862
+ );
863
+
864
+ return (
865
+ <div
866
+ className="flex items-center justify-between py-2"
867
+ key={attribute}
868
+ >
869
+ <span className="text-label-14 text-gray-900">
870
+ {getAttributeDisplayName(attribute)}
871
+ </span>
872
+ {isModuleSpecifier ? (
873
+ <button
874
+ type="button"
875
+ className="min-w-0 max-w-[70%] truncate text-right text-label-13 font-mono"
876
+ style={{
877
+ color: 'var(--ds-gray-1000)',
878
+ background: 'transparent',
879
+ border: 'none',
880
+ padding: 0,
881
+ }}
882
+ title={moduleSpecifierValue}
883
+ onClick={() =>
884
+ handleCopyModuleSpecifier(moduleSpecifierValue)
885
+ }
886
+ >
887
+ {moduleSpecifierValue}
888
+ </button>
889
+ ) : isCopyableBasicAttribute ? (
890
+ <div
891
+ className="flex min-w-0 max-w-[70%] items-center justify-end gap-1 text-right text-[13px] font-mono"
892
+ style={{
893
+ color: 'var(--ds-gray-1000)',
894
+ }}
895
+ title={displayValue}
896
+ >
897
+ <MiddleTruncate
898
+ value={displayValue}
899
+ className="flex-1"
900
+ />
901
+ <CopyButton
902
+ copyText={displayValue}
903
+ ariaLabel={`Copy ${getAttributeDisplayName(attribute)}`}
904
+ className="shrink-0 -mr-1"
905
+ />
906
+ </div>
907
+ ) : (
908
+ <span className="text-right text-label-13 font-mono">
909
+ {displayValue}
910
+ </span>
911
+ )}
912
+ </div>
913
+ );
914
+ })}
915
+ {isLoading && resource === 'sleep' && !displayData.resumeAt && (
916
+ <div className="py-1">
917
+ <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
918
+ <span
919
+ className="text-[14px] first-letter:uppercase"
920
+ style={{ color: 'var(--ds-gray-700)' }}
899
921
  >
900
- <MiddleTruncate
901
- value={displayValue}
902
- className="flex-1"
903
- />
904
- <CopyButton
905
- copyText={displayValue}
906
- ariaLabel={`Copy ${getAttributeDisplayName(attribute)}`}
907
- className="shrink-0 -mr-1"
908
- />
909
- </div>
910
- ) : (
911
- <span className="text-right text-label-13 font-mono">
912
- {displayValue}
922
+ resumeAt
913
923
  </span>
914
- )}
915
- </div>
916
- );
917
- })}
918
- {isLoading && resource === 'sleep' && !displayData.resumeAt && (
919
- <div className="py-1">
920
- <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
921
- <span
922
- className="text-[14px] first-letter:uppercase"
923
- style={{ color: 'var(--ds-gray-700)' }}
924
- >
925
- resumeAt
926
- </span>
927
- <Skeleton className="h-4 w-[55%]" />
924
+ <Skeleton className="h-4 w-[55%]" />
925
+ </div>
928
926
  </div>
929
- </div>
930
- )}
931
- </div>
932
- )}
933
- {error ? (
934
- <ErrorCard
935
- title="Failed to load resource details"
936
- details={error.message}
937
- className="my-4"
938
- />
939
- ) : hasExpired ? (
940
- <ExpiredDataMessage />
941
- ) : resolvedAttributes.length > 0 ? (
942
- <>
943
- {resolvedAttributes.map((attribute) => (
944
- <AttributeBlock
945
- isLoading={isLoading}
946
- key={attribute}
947
- attribute={attribute}
948
- value={displayData[attribute as keyof typeof displayData]}
949
- context={displayContext}
950
- />
951
- ))}
952
- </>
953
- ) : null}
954
- </DecryptClickContext.Provider>
955
- </StreamClickContext.Provider>
956
- </RunClickContext.Provider>
927
+ )}
928
+ </div>
929
+ )}
930
+ {error ? (
931
+ <ErrorCard
932
+ title="Failed to load resource details"
933
+ details={error.message}
934
+ className="my-4"
935
+ />
936
+ ) : hasExpired ? (
937
+ <ExpiredDataMessage />
938
+ ) : resolvedAttributes.length > 0 ? (
939
+ <>
940
+ {resolvedAttributes.map((attribute) => (
941
+ <AttributeBlock
942
+ isLoading={isLoading}
943
+ key={attribute}
944
+ attribute={attribute}
945
+ value={displayData[attribute as keyof typeof displayData]}
946
+ context={displayContext}
947
+ />
948
+ ))}
949
+ </>
950
+ ) : null}
951
+ </DecryptClickContext.Provider>
952
+ </StreamClickContext.Provider>
953
+ </RunClickContext.Provider>
954
+ </ContextCardProvider>
957
955
  );
958
956
  };