@workflow/web-shared 4.1.0-beta.65 → 4.1.0-beta.66

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.map +1 -1
  2. package/dist/components/event-list-view.js +52 -16
  3. package/dist/components/event-list-view.js.map +1 -1
  4. package/dist/components/hook-actions.d.ts.map +1 -1
  5. package/dist/components/hook-actions.js +2 -1
  6. package/dist/components/hook-actions.js.map +1 -1
  7. package/dist/components/sidebar/attribute-panel.d.ts +3 -1
  8. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  9. package/dist/components/sidebar/attribute-panel.js +55 -38
  10. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  11. package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
  12. package/dist/components/sidebar/copyable-data-block.js +2 -1
  13. package/dist/components/sidebar/copyable-data-block.js.map +1 -1
  14. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  15. package/dist/components/sidebar/entity-detail-panel.js +12 -6
  16. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  17. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  18. package/dist/components/sidebar/events-list.js +7 -5
  19. package/dist/components/sidebar/events-list.js.map +1 -1
  20. package/dist/components/trace-viewer/components/span-segments.d.ts.map +1 -1
  21. package/dist/components/trace-viewer/components/span-segments.js +54 -1
  22. package/dist/components/trace-viewer/components/span-segments.js.map +1 -1
  23. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  24. package/dist/components/ui/error-stack-block.js +2 -1
  25. package/dist/components/ui/error-stack-block.js.map +1 -1
  26. package/dist/components/ui/timestamp-tooltip.d.ts +6 -0
  27. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -0
  28. package/dist/components/ui/timestamp-tooltip.js +200 -0
  29. package/dist/components/ui/timestamp-tooltip.js.map +1 -0
  30. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  31. package/dist/components/workflow-trace-view.js +8 -1
  32. package/dist/components/workflow-trace-view.js.map +1 -1
  33. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  34. package/dist/components/workflow-traces/trace-span-construction.js +10 -7
  35. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/lib/toast.d.ts +25 -0
  41. package/dist/lib/toast.d.ts.map +1 -0
  42. package/dist/lib/toast.js +24 -0
  43. package/dist/lib/toast.js.map +1 -0
  44. package/package.json +6 -4
  45. package/src/components/event-list-view.tsx +125 -35
  46. package/src/components/hook-actions.tsx +2 -1
  47. package/src/components/sidebar/attribute-panel.tsx +60 -33
  48. package/src/components/sidebar/copyable-data-block.tsx +2 -1
  49. package/src/components/sidebar/entity-detail-panel.tsx +13 -5
  50. package/src/components/sidebar/events-list.tsx +14 -13
  51. package/src/components/trace-viewer/components/span-segments.ts +70 -1
  52. package/src/components/ui/error-stack-block.tsx +2 -1
  53. package/src/components/ui/timestamp-tooltip.tsx +326 -0
  54. package/src/components/workflow-trace-view.tsx +8 -1
  55. package/src/components/workflow-traces/trace-span-construction.ts +12 -7
  56. package/src/index.ts +2 -0
  57. package/src/lib/toast.tsx +42 -0
@@ -17,6 +17,7 @@ import {
17
17
  import { LoadMoreButton } from './ui/load-more-button';
18
18
  import { MenuDropdown } from './ui/menu-dropdown';
19
19
  import { Skeleton } from './ui/skeleton';
20
+ import { TimestampTooltip } from './ui/timestamp-tooltip';
20
21
 
21
22
  /**
22
23
  * Event types whose eventData contains an error field with a StructuredError.
@@ -211,6 +212,15 @@ function buildDurationMap(events: Event[]): Map<string, DurationInfo> {
211
212
  return durations;
212
213
  }
213
214
 
215
+ /** Check if a loaded eventData object contains any encrypted marker values. */
216
+ function hasEncryptedValues(data: unknown): boolean {
217
+ if (!data || typeof data !== 'object') return false;
218
+ for (const val of Object.values(data as Record<string, unknown>)) {
219
+ if (isEncryptedMarker(val)) return true;
220
+ }
221
+ return false;
222
+ }
223
+
214
224
  function isRunLevel(eventType: string): boolean {
215
225
  return (
216
226
  eventType === 'run_created' ||
@@ -602,21 +612,67 @@ const SORT_OPTIONS = [
602
612
  function RowsSkeleton() {
603
613
  return (
604
614
  <div className="flex-1 overflow-hidden">
605
- {Array.from({ length: 8 }, (_, i) => (
606
- <div
607
- key={i}
608
- className="flex items-center gap-3 px-4"
609
- style={{ height: 40 }}
610
- >
611
- <Skeleton
612
- className="h-2 w-2 flex-shrink-0"
613
- style={{ borderRadius: '50%' }}
614
- />
615
- <Skeleton className="h-3" style={{ width: 90 }} />
616
- <Skeleton className="h-3" style={{ width: 100 }} />
617
- <Skeleton className="h-3" style={{ width: 80 }} />
618
- <Skeleton className="h-3 flex-1" />
619
- <Skeleton className="h-3 flex-1" />
615
+ {Array.from({ length: 16 }, (_, i) => (
616
+ <div key={i} className="flex items-center gap-0" style={{ height: 40 }}>
617
+ {/* Gutter area */}
618
+ <div
619
+ className="relative flex-shrink-0 self-stretch flex items-center"
620
+ style={{ width: GUTTER_WIDTH }}
621
+ >
622
+ {/* Vertical line skeleton */}
623
+ <div
624
+ style={{
625
+ position: 'absolute',
626
+ left: 8,
627
+ top: i === 0 ? '50%' : 0,
628
+ bottom: 0,
629
+ width: 2,
630
+ }}
631
+ >
632
+ <Skeleton className="w-full h-full" style={{ borderRadius: 1 }} />
633
+ </div>
634
+ {/* Dot skeleton */}
635
+ <Skeleton
636
+ className="flex-shrink-0"
637
+ style={{
638
+ width: i % 4 === 0 ? 8 : 6,
639
+ height: i % 4 === 0 ? 8 : 6,
640
+ borderRadius: '50%',
641
+ marginLeft: i % 4 === 0 ? 5 : 6,
642
+ }}
643
+ />
644
+ </div>
645
+ {/* Chevron placeholder */}
646
+ <div className="w-5 flex-shrink-0 flex items-center justify-center">
647
+ <Skeleton className="w-5 h-5" style={{ borderRadius: 4 }} />
648
+ </div>
649
+ {/* Time */}
650
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
651
+ <Skeleton className="h-3" style={{ width: '70%' }} />
652
+ </div>
653
+ {/* Event Type */}
654
+ <div
655
+ className="min-w-0 px-4 flex items-center gap-1.5"
656
+ style={{ flex: '2 1 0%' }}
657
+ >
658
+ <Skeleton
659
+ className="flex-shrink-0"
660
+ style={{ width: 6, height: 6, borderRadius: '50%' }}
661
+ />
662
+ <Skeleton className="h-3" style={{ width: '60%' }} />
663
+ </div>
664
+ {/* Name */}
665
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
666
+ <Skeleton className="h-3" style={{ width: '50%' }} />
667
+ </div>
668
+ {/* Correlation ID */}
669
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
670
+ <Skeleton className="h-3" style={{ width: '75%' }} />
671
+ </div>
672
+ {/* Event ID */}
673
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
674
+ <Skeleton className="h-3" style={{ width: '75%' }} />
675
+ </div>
620
676
  </div>
621
677
  ))}
622
678
  </div>
@@ -668,6 +724,7 @@ function EventRow({
668
724
  cachedEventData,
669
725
  onCacheEventData,
670
726
  encryptionKey,
727
+ onEncryptedDataDetected,
671
728
  }: {
672
729
  event: Event;
673
730
  index: number;
@@ -687,6 +744,7 @@ function EventRow({
687
744
  cachedEventData: unknown | null;
688
745
  onCacheEventData: (eventId: string, data: unknown) => void;
689
746
  encryptionKey?: Uint8Array;
747
+ onEncryptedDataDetected?: () => void;
690
748
  }) {
691
749
  const [isLoading, setIsLoading] = useState(false);
692
750
  const [loadedEventData, setLoadedEventData] = useState<unknown | null>(
@@ -697,6 +755,18 @@ function EventRow({
697
755
  cachedEventData !== null
698
756
  );
699
757
 
758
+ // Notify parent if cached data has encrypted markers on mount
759
+ useEffect(() => {
760
+ if (
761
+ cachedEventData !== null &&
762
+ !encryptionKey &&
763
+ hasEncryptedValues(cachedEventData)
764
+ ) {
765
+ onEncryptedDataDetected?.();
766
+ }
767
+ // eslint-disable-next-line react-hooks/exhaustive-deps
768
+ }, []);
769
+
700
770
  const rowGroupKey = isRunLevel(event.eventType)
701
771
  ? '__run__'
702
772
  : (event.correlationId ?? undefined);
@@ -745,6 +815,9 @@ function EventRow({
745
815
  if (data !== null && data !== undefined) {
746
816
  setLoadedEventData(data);
747
817
  onCacheEventData(event.eventId, data);
818
+ if (!encryptionKey && hasEncryptedValues(data)) {
819
+ onEncryptedDataDetected?.();
820
+ }
748
821
  }
749
822
  } catch (err) {
750
823
  setLoadError(
@@ -760,6 +833,8 @@ function EventRow({
760
833
  hasExistingEventData,
761
834
  onLoadEventData,
762
835
  onCacheEventData,
836
+ encryptionKey,
837
+ onEncryptedDataDetected,
763
838
  ]);
764
839
 
765
840
  // Auto-load event data when remounting in expanded state without cached data
@@ -877,7 +952,9 @@ function EventRow({
877
952
  className="tabular-nums min-w-0 px-4"
878
953
  style={{ color: 'var(--ds-gray-900)', flex: '2 1 0%' }}
879
954
  >
880
- {formatEventTime(createdAt)}
955
+ <TimestampTooltip date={createdAt}>
956
+ <span>{formatEventTime(createdAt)}</span>
957
+ </TimestampTooltip>
881
958
  </div>
882
959
 
883
960
  {/* Event Type */}
@@ -1079,22 +1156,26 @@ export function EventListView({
1079
1156
  );
1080
1157
  }, [events, effectiveSortOrder]);
1081
1158
 
1082
- // Detect encrypted fields across all loaded events.
1083
- // Only checks top-level eventData values (input, output, result, etc.) —
1084
- // the current data model guarantees encrypted markers appear at this level.
1085
- const hasEncryptedData = useMemo(() => {
1159
+ // Detect encrypted fields across all loaded events (inline eventData).
1160
+ const hasEncryptedInlineData = useMemo(() => {
1086
1161
  if (!events) return false;
1087
1162
  for (const event of events) {
1088
1163
  const ed = (event as Record<string, unknown>).eventData;
1089
- if (!ed || typeof ed !== 'object') continue;
1090
- const data = ed as Record<string, unknown>;
1091
- for (const val of Object.values(data)) {
1092
- if (isEncryptedMarker(val)) return true;
1093
- }
1164
+ if (hasEncryptedValues(ed)) return true;
1094
1165
  }
1095
1166
  return false;
1096
1167
  }, [events]);
1097
1168
 
1169
+ // Tracks whether any expanded row's lazy-loaded data contained encrypted markers.
1170
+ // Set to true by EventRow via onEncryptedDataDetected; never reset (sticky).
1171
+ const [foundEncryptedInLazyData, setFoundEncryptedInLazyData] =
1172
+ useState(false);
1173
+ const handleEncryptedDataDetected = useCallback(() => {
1174
+ setFoundEncryptedInLazyData(true);
1175
+ }, []);
1176
+
1177
+ const hasEncryptedData = hasEncryptedInlineData || foundEncryptedInLazyData;
1178
+
1098
1179
  const { correlationNameMap, workflowName } = useMemo(
1099
1180
  () => buildNameMaps(events ?? null, run ?? null),
1100
1181
  [events, run]
@@ -1268,18 +1349,26 @@ export function EventListView({
1268
1349
  </div>
1269
1350
  {/* Skeleton header */}
1270
1351
  <div
1271
- className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
1352
+ className="flex items-center gap-0 h-10 border-b flex-shrink-0"
1272
1353
  style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
1273
1354
  >
1274
- <Skeleton className="h-3" style={{ width: 60 }} />
1275
- <div style={{ flex: 1 }} />
1276
- <Skeleton className="h-3" style={{ width: 80 }} />
1277
- <div style={{ flex: 1 }} />
1278
- <Skeleton className="h-3" style={{ width: 50 }} />
1279
- <div style={{ flex: 1 }} />
1280
- <Skeleton className="h-3" style={{ width: 90 }} />
1281
- <div style={{ flex: 1 }} />
1282
- <Skeleton className="h-3" style={{ width: 70 }} />
1355
+ <div className="flex-shrink-0" style={{ width: GUTTER_WIDTH }} />
1356
+ <div className="w-5 flex-shrink-0" />
1357
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1358
+ <Skeleton className="h-3" style={{ width: 40 }} />
1359
+ </div>
1360
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1361
+ <Skeleton className="h-3" style={{ width: 72 }} />
1362
+ </div>
1363
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1364
+ <Skeleton className="h-3" style={{ width: 44 }} />
1365
+ </div>
1366
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
1367
+ <Skeleton className="h-3" style={{ width: 92 }} />
1368
+ </div>
1369
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
1370
+ <Skeleton className="h-3" style={{ width: 60 }} />
1371
+ </div>
1283
1372
  </div>
1284
1373
  <RowsSkeleton />
1285
1374
  </div>
@@ -1455,6 +1544,7 @@ export function EventListView({
1455
1544
  }
1456
1545
  onCacheEventData={cacheEventData}
1457
1546
  encryptionKey={encryptionKey}
1547
+ onEncryptedDataDetected={handleEncryptedDataDetected}
1458
1548
  />
1459
1549
  );
1460
1550
  }}
@@ -3,7 +3,7 @@
3
3
  import type { Hook, WorkflowRunStatus } from '@workflow/world';
4
4
  import { Send } from 'lucide-react';
5
5
  import { useCallback, useState } from 'react';
6
- import { toast } from 'sonner';
6
+ import { useToast } from '../lib/toast';
7
7
  import { ResolveHookModal } from './sidebar/resolve-hook-modal';
8
8
 
9
9
  // ============================================================================
@@ -45,6 +45,7 @@ export function useHookActions({
45
45
  onResolve,
46
46
  callbacks,
47
47
  }: UseHookActionsOptions): UseHookActionsReturn {
48
+ const toast = useToast();
48
49
  const [isResolving, setIsResolving] = useState(false);
49
50
  const [selectedHook, setSelectedHook] = useState<Hook | null>(null);
50
51
 
@@ -6,10 +6,11 @@ import type { ModelMessage } from 'ai';
6
6
  import { Lock } from 'lucide-react';
7
7
  import type { KeyboardEvent, ReactNode } from 'react';
8
8
  import { useCallback, useMemo, useState } from 'react';
9
- import { toast } from 'sonner';
9
+ import { useToast } from '../../lib/toast';
10
10
  import { isEncryptedMarker } from '../../lib/hydration';
11
11
  import { extractConversation, isDoStreamStep } from '../../lib/utils';
12
12
  import { StreamClickContext } from '../ui/data-inspector';
13
+ import { TimestampTooltip } from '../ui/timestamp-tooltip';
13
14
  import { ErrorCard } from '../ui/error-card';
14
15
  import {
15
16
  ErrorStackBlock,
@@ -336,6 +337,16 @@ const localMillisecondTimeOrNull = (value: unknown): string | null => {
336
337
  return formatLocalMillisecondTime(date);
337
338
  };
338
339
 
340
+ const timestampWithTooltipOrNull = (value: unknown): ReactNode | null => {
341
+ const date = parseDateValue(value);
342
+ if (!date) return null;
343
+ return (
344
+ <TimestampTooltip date={date}>
345
+ <span>{formatLocalMillisecondTime(date)}</span>
346
+ </TimestampTooltip>
347
+ );
348
+ };
349
+
339
350
  interface DisplayContext {
340
351
  stepName?: string;
341
352
  }
@@ -375,15 +386,14 @@ const attributeToDisplayFn: Record<
375
386
  projectId: (_value: unknown) => null,
376
387
  environment: (_value: unknown) => null,
377
388
  executionContext: (_value: unknown) => null,
378
- // Dates
379
- // TODO: relative time with tooltips for ISO times
380
- createdAt: localMillisecondTimeOrNull,
381
- startedAt: localMillisecondTimeOrNull,
382
- updatedAt: localMillisecondTimeOrNull,
383
- completedAt: localMillisecondTimeOrNull,
384
- expiredAt: localMillisecondTimeOrNull,
385
- retryAfter: localMillisecondTimeOrNull,
386
- resumeAt: localMillisecondTimeOrNull,
389
+ // Dates — wrapped with TimestampTooltip showing UTC/local + relative time
390
+ createdAt: timestampWithTooltipOrNull,
391
+ startedAt: timestampWithTooltipOrNull,
392
+ updatedAt: timestampWithTooltipOrNull,
393
+ completedAt: timestampWithTooltipOrNull,
394
+ expiredAt: timestampWithTooltipOrNull,
395
+ retryAfter: timestampWithTooltipOrNull,
396
+ resumeAt: timestampWithTooltipOrNull,
387
397
  // Resolved attributes, won't actually use this function
388
398
  metadata: (value: unknown) => {
389
399
  if (!hasDisplayContent(value)) return null;
@@ -580,12 +590,6 @@ export const AttributeBlock = ({
580
590
  attribute === 'output' ||
581
591
  attribute === 'eventData';
582
592
  if (isLoading && isExpandableLoadingTarget) {
583
- const label =
584
- attribute === 'eventData'
585
- ? 'Event Data'
586
- : attribute === 'output'
587
- ? 'Output'
588
- : 'Input';
589
593
  return (
590
594
  <div
591
595
  className={`my-2 flex flex-col ${attribute === 'input' || attribute === 'output' ? 'gap-2 my-3.5' : 'gap-0'}`}
@@ -596,19 +600,7 @@ export const AttributeBlock = ({
596
600
  >
597
601
  {attribute}
598
602
  </span>
599
- <DetailCard
600
- summary={label}
601
- summaryClassName="text-base py-2"
602
- disabled
603
- />
604
- <div
605
- className="overflow-x-auto rounded-md border p-3"
606
- style={{ borderColor: 'var(--ds-gray-300)' }}
607
- >
608
- <Skeleton className="h-4 w-[38%]" />
609
- <Skeleton className="mt-2 h-4 w-[88%]" />
610
- <Skeleton className="mt-2 h-4 w-[72%]" />
611
- </div>
603
+ <Skeleton className="h-9 w-full rounded-md" />
612
604
  </div>
613
605
  );
614
606
  }
@@ -674,6 +666,7 @@ export const AttributePanel = ({
674
666
  error,
675
667
  expiredAt,
676
668
  onStreamClick,
669
+ resource,
677
670
  }: {
678
671
  data: Record<string, unknown>;
679
672
  moduleSpecifier?: string;
@@ -682,7 +675,10 @@ export const AttributePanel = ({
682
675
  expiredAt?: string | Date;
683
676
  /** Callback when a stream reference is clicked */
684
677
  onStreamClick?: (streamId: string) => void;
678
+ /** Resource type of the selected span — used to show targeted loading skeletons. */
679
+ resource?: string;
685
680
  }) => {
681
+ const toast = useToast();
686
682
  // Extract workflowCoreVersion from executionContext for display
687
683
  const displayData = useMemo(() => {
688
684
  const result = { ...data };
@@ -705,9 +701,23 @@ export const AttributePanel = ({
705
701
  const basicAttributes = Object.keys(displayData)
706
702
  .filter((key) => !resolvableAttributes.includes(key))
707
703
  .sort(sortByAttributeOrder);
708
- const resolvedAttributes = Object.keys(displayData)
709
- .filter((key) => resolvableAttributes.includes(key))
710
- .sort(sortByAttributeOrder);
704
+ const resolvedAttributes = useMemo(() => {
705
+ const present = Object.keys(displayData)
706
+ .filter((key) => resolvableAttributes.includes(key))
707
+ .sort(sortByAttributeOrder);
708
+
709
+ if (!isLoading) return present;
710
+
711
+ // During loading, ensure input/output appear so their skeletons render
712
+ // in the correct position (above the events section).
713
+ const loadingDefaults = ['input', 'output'];
714
+ for (const key of loadingDefaults) {
715
+ if (!present.includes(key)) {
716
+ present.push(key);
717
+ }
718
+ }
719
+ return present.sort(sortByAttributeOrder);
720
+ }, [displayData, isLoading]);
711
721
 
712
722
  // Filter out attributes that return null
713
723
  const visibleBasicAttributes = basicAttributes.filter((attribute) => {
@@ -784,7 +794,11 @@ export const AttributePanel = ({
784
794
  ? displayValue
785
795
  : String(displayValue ?? displayData.moduleSpecifier ?? '');
786
796
  const shouldCapitalizeLabel = attribute !== 'workflowCoreVersion';
787
- const showDivider = index < orderedBasicAttributes.length - 1;
797
+ const showResumeAtSkeleton =
798
+ isLoading && resource === 'sleep' && !displayData.resumeAt;
799
+ const showDivider =
800
+ index < orderedBasicAttributes.length - 1 ||
801
+ showResumeAtSkeleton;
788
802
 
789
803
  return (
790
804
  <div key={attribute} className="py-1">
@@ -834,6 +848,19 @@ export const AttributePanel = ({
834
848
  </div>
835
849
  );
836
850
  })}
851
+ {isLoading && resource === 'sleep' && !displayData.resumeAt && (
852
+ <div className="py-1">
853
+ <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
854
+ <span
855
+ className="text-[14px] first-letter:uppercase"
856
+ style={{ color: 'var(--ds-gray-700)' }}
857
+ >
858
+ resumeAt
859
+ </span>
860
+ <Skeleton className="h-4 w-[55%]" />
861
+ </div>
862
+ </div>
863
+ )}
837
864
  </div>
838
865
  )}
839
866
  {error ? (
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { Copy } from 'lucide-react';
4
- import { toast } from 'sonner';
4
+ import { useToast } from '../../lib/toast';
5
5
  import { DataInspector } from '../ui/data-inspector';
6
6
 
7
7
  const serializeForClipboard = (value: unknown): string => {
@@ -21,6 +21,7 @@ const serializeForClipboard = (value: unknown): string => {
21
21
  };
22
22
 
23
23
  export function CopyableDataBlock({ data }: { data: unknown }) {
24
+ const toast = useToast();
24
25
  return (
25
26
  <div
26
27
  className="relative overflow-x-auto rounded-md border p-3 pt-9"
@@ -3,8 +3,8 @@
3
3
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
4
  import clsx from 'clsx';
5
5
  import { Send, Zap } from 'lucide-react';
6
- import { useCallback, useEffect, useMemo, useState } from 'react';
7
- import { toast } from 'sonner';
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { useToast } from '../../lib/toast';
8
8
  import { isEncryptedMarker } from '../../lib/hydration';
9
9
  import { DecryptButton } from '../ui/decrypt-button';
10
10
  import { AttributePanel } from './attribute-panel';
@@ -104,6 +104,7 @@ export function EntityDetailPanel({
104
104
  /** Info about the currently selected span from the trace viewer */
105
105
  selectedSpan: SelectedSpanInfo | null;
106
106
  }): React.JSX.Element | null {
107
+ const toast = useToast();
107
108
  const [stoppingSleep, setStoppingSleep] = useState(false);
108
109
  const [showResolveHookModal, setShowResolveHookModal] = useState(false);
109
110
  const [resolvingHook, setResolvingHook] = useState(false);
@@ -144,20 +145,26 @@ export function EntityDetailPanel({
144
145
  return { resource: undefined, resourceId: undefined, runId: undefined };
145
146
  }, [selectedSpan, data]);
146
147
 
147
- // Notify parent when span selection changes
148
+ // Notify parent when span selection changes.
149
+ // Use a ref for the callback so the effect only fires when the actual
150
+ // selection values change, not when the callback identity changes due to
151
+ // parent re-renders from polling.
152
+ const onSpanSelectRef = useRef(onSpanSelect);
153
+ onSpanSelectRef.current = onSpanSelect;
154
+
148
155
  useEffect(() => {
149
156
  if (
150
157
  resource &&
151
158
  resourceId &&
152
159
  ['run', 'step', 'hook', 'sleep'].includes(resource)
153
160
  ) {
154
- onSpanSelect({
161
+ onSpanSelectRef.current({
155
162
  resource: resource as 'run' | 'step' | 'hook' | 'sleep',
156
163
  resourceId,
157
164
  runId,
158
165
  });
159
166
  }
160
- }, [resource, resourceId, runId, onSpanSelect]);
167
+ }, [resource, resourceId, runId]);
161
168
 
162
169
  // Check if this sleep is still pending and can be woken up
163
170
  const canWakeUp = useMemo(() => {
@@ -474,6 +481,7 @@ export function EntityDetailPanel({
474
481
  isLoading={loading}
475
482
  error={error ?? undefined}
476
483
  onStreamClick={onStreamClick}
484
+ resource={resource}
477
485
  />
478
486
  </section>
479
487
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { Event } from '@workflow/world';
4
4
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
+ import { ErrorCard } from '../ui/error-card';
5
6
  import {
6
7
  ErrorStackBlock,
7
8
  isStructuredErrorWithStack,
@@ -23,10 +24,14 @@ const DATA_EVENT_TYPES = new Set([
23
24
  'step_created',
24
25
  'step_completed',
25
26
  'step_failed',
27
+ 'step_retrying',
26
28
  'hook_created',
27
29
  'hook_received',
28
30
  'run_created',
29
31
  'run_completed',
32
+ 'run_failed',
33
+ 'wait_created',
34
+ 'wait_completed',
30
35
  ]);
31
36
 
32
37
  /**
@@ -175,15 +180,11 @@ function EventItem({
175
180
 
176
181
  {/* Error state */}
177
182
  {loadError && (
178
- <div
179
- className="mt-2 rounded-md border p-2 text-sm"
180
- style={{
181
- borderColor: 'var(--ds-red-300)',
182
- color: 'var(--ds-red-700)',
183
- }}
184
- >
185
- {loadError}
186
- </div>
183
+ <ErrorCard
184
+ title="Failed to load event data"
185
+ details={loadError}
186
+ className="mt-2"
187
+ />
187
188
  )}
188
189
 
189
190
  {/* Event data */}
@@ -269,10 +270,10 @@ export function EventsList({
269
270
  Events {!isLoading && `(${sortedEvents.length})`}
270
271
  </h3>
271
272
  {isLoading ? (
272
- <div className="flex flex-col gap-3">
273
- <Skeleton className="h-[48px] w-full rounded-lg border" />
274
- <Skeleton className="h-[48px] w-full rounded-lg border" />
275
- <Skeleton className="h-[48px] w-full rounded-lg border" />
273
+ <div className="flex flex-col gap-4">
274
+ <Skeleton className="h-9 w-full rounded-md" />
275
+ <Skeleton className="h-9 w-full rounded-md" />
276
+ <Skeleton className="h-9 w-full rounded-md" />
276
277
  </div>
277
278
  ) : null}
278
279
  {!isLoading && !error && sortedEvents.length === 0 && (
@@ -341,6 +341,26 @@ function computeRunSegments(node: SpanNode): Segment[] {
341
341
 
342
342
  if (duration <= 0) return segments;
343
343
 
344
+ // V1 runs (specVersion 1) don't emit run lifecycle events (run_created,
345
+ // run_started, run_completed). Detect this by checking for the absence of
346
+ // run_created — it's always the first event for v2 runs and is always
347
+ // loaded in the first page, so its absence reliably signals a v1 run.
348
+ // For v1, fall back to the run entity's status from span.attributes.data.
349
+ const hasRunCreated = events.some((e) => e.event.name === 'run_created');
350
+
351
+ if (!hasRunCreated) {
352
+ const runData = node.span.attributes?.data as
353
+ | Record<string, unknown>
354
+ | undefined;
355
+ const runStatus = runData?.status as string | undefined;
356
+ return computeV1RunSegments(
357
+ startTime,
358
+ duration,
359
+ activeStartTime,
360
+ runStatus
361
+ );
362
+ }
363
+
344
364
  const failedEvent = events.find((e) => e.event.name === 'run_failed');
345
365
  const completedEvent = events.find((e) => e.event.name === 'run_completed');
346
366
 
@@ -396,7 +416,56 @@ function computeRunSegments(node: SpanNode): Segment[] {
396
416
  status: 'succeeded',
397
417
  });
398
418
  } else {
399
- // Running to completion
419
+ // Running to completion (or terminal events haven't loaded yet)
420
+ segments.push({
421
+ startFraction: cursor,
422
+ endFraction: 1,
423
+ status: 'running',
424
+ });
425
+ }
426
+
427
+ return segments;
428
+ }
429
+
430
+ /**
431
+ * Compute segments for a v1 run using only the run entity's status and
432
+ * activeStartTime (derived from run.startedAt). V1 runs have no run
433
+ * lifecycle events, so we infer the visual segments from entity fields.
434
+ */
435
+ function computeV1RunSegments(
436
+ startTime: number,
437
+ duration: number,
438
+ activeStartTime: number | undefined,
439
+ runStatus: string | undefined
440
+ ): Segment[] {
441
+ const segments: Segment[] = [];
442
+
443
+ let cursor = 0;
444
+ if (activeStartTime && activeStartTime > startTime) {
445
+ const queuedFraction = timeToFraction(activeStartTime, startTime, duration);
446
+ if (queuedFraction > 0.001) {
447
+ segments.push({
448
+ startFraction: 0,
449
+ endFraction: queuedFraction,
450
+ status: 'queued',
451
+ });
452
+ cursor = queuedFraction;
453
+ }
454
+ }
455
+
456
+ if (runStatus === 'failed') {
457
+ segments.push({
458
+ startFraction: cursor,
459
+ endFraction: 1,
460
+ status: 'failed',
461
+ });
462
+ } else if (runStatus === 'completed' || runStatus === 'cancelled') {
463
+ segments.push({
464
+ startFraction: cursor,
465
+ endFraction: 1,
466
+ status: 'succeeded',
467
+ });
468
+ } else {
400
469
  segments.push({
401
470
  startFraction: cursor,
402
471
  endFraction: 1,
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { AlertCircle, Copy } from 'lucide-react';
4
- import { toast } from 'sonner';
4
+ import { useToast } from '../../lib/toast';
5
5
 
6
6
  /**
7
7
  * Check whether `value` looks like a structured error object with a `stack`
@@ -28,6 +28,7 @@ export function ErrorStackBlock({
28
28
  }: {
29
29
  value: Record<string, unknown> & { stack: string };
30
30
  }) {
31
+ const toast = useToast();
31
32
  const stack = value.stack;
32
33
  const message = typeof value.message === 'string' ? value.message : undefined;
33
34
  const copyText = message ? `${message}\n\n${stack}` : stack;