@workflow/web-shared 4.1.0-beta.64 → 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 (89) hide show
  1. package/dist/components/event-list-view.d.ts +5 -1
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +79 -22
  4. package/dist/components/event-list-view.js.map +1 -1
  5. package/dist/components/hook-actions.d.ts.map +1 -1
  6. package/dist/components/hook-actions.js +2 -1
  7. package/dist/components/hook-actions.js.map +1 -1
  8. package/dist/components/index.d.ts +3 -0
  9. package/dist/components/index.d.ts.map +1 -1
  10. package/dist/components/index.js +3 -0
  11. package/dist/components/index.js.map +1 -1
  12. package/dist/components/sidebar/attribute-panel.d.ts +3 -1
  13. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  14. package/dist/components/sidebar/attribute-panel.js +55 -38
  15. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  16. package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
  17. package/dist/components/sidebar/copyable-data-block.js +2 -1
  18. package/dist/components/sidebar/copyable-data-block.js.map +1 -1
  19. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
  20. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  21. package/dist/components/sidebar/entity-detail-panel.js +16 -20
  22. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  23. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  24. package/dist/components/sidebar/events-list.js +7 -5
  25. package/dist/components/sidebar/events-list.js.map +1 -1
  26. package/dist/components/stream-viewer.d.ts +3 -1
  27. package/dist/components/stream-viewer.d.ts.map +1 -1
  28. package/dist/components/stream-viewer.js +23 -28
  29. package/dist/components/stream-viewer.js.map +1 -1
  30. package/dist/components/trace-viewer/components/span-segments.d.ts.map +1 -1
  31. package/dist/components/trace-viewer/components/span-segments.js +54 -1
  32. package/dist/components/trace-viewer/components/span-segments.js.map +1 -1
  33. package/dist/components/ui/decrypt-button.d.ts +15 -0
  34. package/dist/components/ui/decrypt-button.d.ts.map +1 -0
  35. package/dist/components/ui/decrypt-button.js +12 -0
  36. package/dist/components/ui/decrypt-button.js.map +1 -0
  37. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  38. package/dist/components/ui/error-stack-block.js +2 -1
  39. package/dist/components/ui/error-stack-block.js.map +1 -1
  40. package/dist/components/ui/load-more-button.d.ts +13 -0
  41. package/dist/components/ui/load-more-button.d.ts.map +1 -0
  42. package/dist/components/ui/load-more-button.js +12 -0
  43. package/dist/components/ui/load-more-button.js.map +1 -0
  44. package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
  45. package/dist/components/ui/menu-dropdown.js +2 -6
  46. package/dist/components/ui/menu-dropdown.js.map +1 -1
  47. package/dist/components/ui/spinner.d.ts +9 -0
  48. package/dist/components/ui/spinner.d.ts.map +1 -0
  49. package/dist/components/ui/spinner.js +57 -0
  50. package/dist/components/ui/spinner.js.map +1 -0
  51. package/dist/components/ui/timestamp-tooltip.d.ts +6 -0
  52. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -0
  53. package/dist/components/ui/timestamp-tooltip.js +200 -0
  54. package/dist/components/ui/timestamp-tooltip.js.map +1 -0
  55. package/dist/components/workflow-trace-view.d.ts +3 -1
  56. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  57. package/dist/components/workflow-trace-view.js +12 -4
  58. package/dist/components/workflow-trace-view.js.map +1 -1
  59. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  60. package/dist/components/workflow-traces/trace-span-construction.js +10 -7
  61. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/lib/toast.d.ts +25 -0
  67. package/dist/lib/toast.d.ts.map +1 -0
  68. package/dist/lib/toast.js +24 -0
  69. package/dist/lib/toast.js.map +1 -0
  70. package/package.json +6 -4
  71. package/src/components/event-list-view.tsx +241 -111
  72. package/src/components/hook-actions.tsx +2 -1
  73. package/src/components/index.ts +3 -0
  74. package/src/components/sidebar/attribute-panel.tsx +60 -33
  75. package/src/components/sidebar/copyable-data-block.tsx +2 -1
  76. package/src/components/sidebar/entity-detail-panel.tsx +22 -30
  77. package/src/components/sidebar/events-list.tsx +14 -13
  78. package/src/components/stream-viewer.tsx +52 -63
  79. package/src/components/trace-viewer/components/span-segments.ts +70 -1
  80. package/src/components/ui/decrypt-button.tsx +69 -0
  81. package/src/components/ui/error-stack-block.tsx +2 -1
  82. package/src/components/ui/load-more-button.tsx +38 -0
  83. package/src/components/ui/menu-dropdown.tsx +3 -6
  84. package/src/components/ui/spinner.tsx +76 -0
  85. package/src/components/ui/timestamp-tooltip.tsx +326 -0
  86. package/src/components/workflow-trace-view.tsx +14 -20
  87. package/src/components/workflow-traces/trace-span-construction.ts +12 -7
  88. package/src/index.ts +2 -0
  89. package/src/lib/toast.tsx +42 -0
@@ -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"
@@ -2,10 +2,11 @@
2
2
 
3
3
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
4
  import clsx from 'clsx';
5
- import { Lock, Send, Unlock, Zap } from 'lucide-react';
6
- import { useCallback, useEffect, useMemo, useState } from 'react';
7
- import { toast } from 'sonner';
5
+ import { Send, Zap } from 'lucide-react';
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { useToast } from '../../lib/toast';
8
8
  import { isEncryptedMarker } from '../../lib/hydration';
9
+ import { DecryptButton } from '../ui/decrypt-button';
9
10
  import { AttributePanel } from './attribute-panel';
10
11
  import { EventsList } from './events-list';
11
12
  import { ResolveHookModal } from './resolve-hook-modal';
@@ -64,6 +65,7 @@ export function EntityDetailPanel({
64
65
  onResolveHook,
65
66
  encryptionKey,
66
67
  onDecrypt,
68
+ isDecrypting = false,
67
69
  selectedSpan,
68
70
  }: {
69
71
  run: WorkflowRun;
@@ -97,9 +99,12 @@ export function EntityDetailPanel({
97
99
  encryptionKey?: Uint8Array;
98
100
  /** Callback to initiate decryption of encrypted run data */
99
101
  onDecrypt?: () => void;
102
+ /** Whether the encryption key is currently being fetched */
103
+ isDecrypting?: boolean;
100
104
  /** Info about the currently selected span from the trace viewer */
101
105
  selectedSpan: SelectedSpanInfo | null;
102
106
  }): React.JSX.Element | null {
107
+ const toast = useToast();
103
108
  const [stoppingSleep, setStoppingSleep] = useState(false);
104
109
  const [showResolveHookModal, setShowResolveHookModal] = useState(false);
105
110
  const [resolvingHook, setResolvingHook] = useState(false);
@@ -140,20 +145,26 @@ export function EntityDetailPanel({
140
145
  return { resource: undefined, resourceId: undefined, runId: undefined };
141
146
  }, [selectedSpan, data]);
142
147
 
143
- // 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
+
144
155
  useEffect(() => {
145
156
  if (
146
157
  resource &&
147
158
  resourceId &&
148
159
  ['run', 'step', 'hook', 'sleep'].includes(resource)
149
160
  ) {
150
- onSpanSelect({
161
+ onSpanSelectRef.current({
151
162
  resource: resource as 'run' | 'step' | 'hook' | 'sleep',
152
163
  resourceId,
153
164
  runId,
154
165
  });
155
166
  }
156
- }, [resource, resourceId, runId, onSpanSelect]);
167
+ }, [resource, resourceId, runId]);
157
168
 
158
169
  // Check if this sleep is still pending and can be woken up
159
170
  const canWakeUp = useMemo(() => {
@@ -381,31 +392,11 @@ export function EntityDetailPanel({
381
392
  </p>
382
393
  </div>
383
394
  {(hasEncryptedFields || encryptionKey) && onDecrypt && (
384
- <button
385
- type="button"
395
+ <DecryptButton
396
+ decrypted={!!encryptionKey}
397
+ loading={isDecrypting}
386
398
  onClick={onDecrypt}
387
- disabled={!!encryptionKey}
388
- className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors flex-shrink-0"
389
- style={{
390
- borderColor: encryptionKey
391
- ? 'var(--ds-green-400)'
392
- : 'var(--ds-gray-300)',
393
- color: encryptionKey
394
- ? 'var(--ds-green-900)'
395
- : 'var(--ds-gray-900)',
396
- backgroundColor: encryptionKey
397
- ? 'var(--ds-green-100)'
398
- : 'var(--ds-background-100)',
399
- cursor: encryptionKey ? 'default' : 'pointer',
400
- }}
401
- >
402
- {encryptionKey ? (
403
- <Unlock className="h-3 w-3" />
404
- ) : (
405
- <Lock className="h-3 w-3" />
406
- )}
407
- {encryptionKey ? 'Decrypted' : 'Decrypt'}
408
- </button>
399
+ />
409
400
  )}
410
401
  </div>
411
402
  </div>
@@ -490,6 +481,7 @@ export function EntityDetailPanel({
490
481
  isLoading={loading}
491
482
  error={error ?? undefined}
492
483
  onStreamClick={onStreamClick}
484
+ resource={resource}
493
485
  />
494
486
  </section>
495
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 && (
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import React, { useCallback, useEffect, useRef, useState } from 'react';
3
+ import React, { useEffect, useRef } from 'react';
4
+ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
4
5
  import { DataInspector } from './ui/data-inspector';
5
6
  import { Skeleton } from './ui/skeleton';
6
7
 
@@ -46,6 +47,8 @@ interface StreamViewerProps {
46
47
  error?: string | null;
47
48
  /** True while the initial stream connection is being established */
48
49
  isLoading?: boolean;
50
+ /** Called when the user scrolls near the bottom, for triggering pagination */
51
+ onScrollEnd?: () => void;
49
52
  }
50
53
 
51
54
  // ──────────────────────────────────────────────────────────────────────────
@@ -114,45 +117,41 @@ function StreamSkeleton() {
114
117
  * of complex types (Map, Set, Date, custom classes, etc.).
115
118
  */
116
119
  export function StreamViewer({
117
- streamId,
120
+ streamId: _streamId,
118
121
  chunks,
119
122
  isLive,
120
123
  error,
121
124
  isLoading,
125
+ onScrollEnd,
122
126
  }: StreamViewerProps) {
123
- const [hasMoreBelow, setHasMoreBelow] = useState(false);
124
- const scrollRef = useRef<HTMLDivElement>(null);
125
-
126
- const checkScrollPosition = useCallback(() => {
127
- if (scrollRef.current) {
128
- const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
129
- const isAtBottom = scrollHeight - scrollTop - clientHeight < 10;
130
- setHasMoreBelow(!isAtBottom && scrollHeight > clientHeight);
131
- }
132
- }, []);
127
+ const virtuosoRef = useRef<VirtuosoHandle>(null);
128
+ const prevChunkCountRef = useRef(0);
133
129
 
134
- // biome-ignore lint/correctness/useExhaustiveDependencies: chunks.length triggers scroll on new chunks
130
+ // Auto-scroll to bottom when new chunks arrive (live streaming)
135
131
  useEffect(() => {
136
- if (scrollRef.current) {
137
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
132
+ if (chunks.length > prevChunkCountRef.current && chunks.length > 0) {
133
+ virtuosoRef.current?.scrollToIndex({
134
+ index: chunks.length - 1,
135
+ align: 'end',
136
+ });
138
137
  }
139
- checkScrollPosition();
140
- }, [chunks.length, checkScrollPosition]);
138
+ prevChunkCountRef.current = chunks.length;
139
+ }, [chunks.length]);
141
140
 
142
141
  // Show skeleton when loading and no chunks have arrived yet
143
142
  if (isLoading && chunks.length === 0) {
144
143
  return (
145
- <div className="flex flex-col h-full pb-4">
144
+ <div className="flex flex-col h-full">
146
145
  <StreamSkeleton />
147
146
  </div>
148
147
  );
149
148
  }
150
149
 
151
150
  return (
152
- <div className="flex flex-col h-full pb-4">
151
+ <div className="flex flex-col h-full">
153
152
  {/* Live indicator */}
154
153
  {isLive && (
155
- <div className="flex items-center gap-1.5 mb-3 px-1">
154
+ <div className="flex items-center gap-1.5 mb-2 px-1">
156
155
  <span
157
156
  className="inline-block w-2 h-2 rounded-full"
158
157
  style={{ backgroundColor: 'var(--ds-green-600)' }}
@@ -182,52 +181,42 @@ export function StreamViewer({
182
181
  )}
183
182
 
184
183
  {/* Content */}
185
- <div className="relative flex-1 min-h-[200px]">
186
- <div
187
- ref={scrollRef}
188
- onScroll={checkScrollPosition}
189
- className="absolute inset-0 overflow-auto flex flex-col gap-2"
190
- >
191
- {error ? (
192
- <div
193
- className="text-[11px] rounded-md border p-3"
194
- style={{
195
- borderColor: 'var(--ds-red-300)',
196
- backgroundColor: 'var(--ds-red-100)',
197
- color: 'var(--ds-red-700)',
198
- }}
199
- >
200
- <div>Error reading stream:</div>
201
- <div>{error}</div>
202
- </div>
203
- ) : chunks.length === 0 ? (
204
- <div
205
- className="text-[11px] rounded-md border p-3"
206
- style={{
207
- borderColor: 'var(--ds-gray-300)',
208
- backgroundColor: 'var(--ds-gray-100)',
209
- color: 'var(--ds-gray-600)',
210
- }}
211
- >
212
- {isLive ? 'Waiting for stream data...' : 'Stream is empty'}
213
- </div>
214
- ) : (
215
- chunks.map((chunk, index) => (
216
- <ChunkRow
217
- key={`${streamId}-chunk-${chunk.id}`}
218
- chunk={chunk}
219
- index={index}
220
- />
221
- ))
222
- )}
223
- </div>
224
- {hasMoreBelow && (
184
+ <div className="flex-1 min-h-0">
185
+ {error ? (
186
+ <div
187
+ className="text-[11px] rounded-md border p-3"
188
+ style={{
189
+ borderColor: 'var(--ds-red-300)',
190
+ backgroundColor: 'var(--ds-red-100)',
191
+ color: 'var(--ds-red-700)',
192
+ }}
193
+ >
194
+ <div>Error reading stream:</div>
195
+ <div>{error}</div>
196
+ </div>
197
+ ) : chunks.length === 0 ? (
225
198
  <div
226
- className="absolute bottom-0 left-0 right-0 h-8 pointer-events-none"
199
+ className="text-[11px] rounded-md border p-3"
227
200
  style={{
228
- background:
229
- 'linear-gradient(to top, var(--ds-background-100), transparent)',
201
+ borderColor: 'var(--ds-gray-300)',
202
+ backgroundColor: 'var(--ds-gray-100)',
203
+ color: 'var(--ds-gray-600)',
230
204
  }}
205
+ >
206
+ {isLive ? 'Waiting for stream data...' : 'Stream is empty'}
207
+ </div>
208
+ ) : (
209
+ <Virtuoso
210
+ ref={virtuosoRef}
211
+ totalCount={chunks.length}
212
+ overscan={10}
213
+ endReached={() => onScrollEnd?.()}
214
+ itemContent={(index) => (
215
+ <div style={{ paddingBottom: 8 }}>
216
+ <ChunkRow chunk={chunks[index]} index={index} />
217
+ </div>
218
+ )}
219
+ style={{ flex: 1, minHeight: 0 }}
231
220
  />
232
221
  )}
233
222
  </div>
@@ -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,
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { Spinner } from './spinner';
4
+
5
+ const STYLES = `.wf-decrypt-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
6
+
7
+ interface DecryptButtonProps {
8
+ /** Whether an encryption key has been obtained (decryption is active). */
9
+ decrypted?: boolean;
10
+ /** Whether the key is currently being fetched. */
11
+ loading?: boolean;
12
+ /** Called when the user clicks to initiate decryption. */
13
+ onClick?: () => void;
14
+ }
15
+
16
+ /**
17
+ * Decrypt/Decrypted button using Geist secondary style.
18
+ * Three states: idle (secondary gray), decrypting (spinner), decrypted (green success).
19
+ */
20
+ export function DecryptButton({
21
+ decrypted = false,
22
+ loading = false,
23
+ onClick,
24
+ }: DecryptButtonProps) {
25
+ return (
26
+ <>
27
+ <style dangerouslySetInnerHTML={{ __html: STYLES }} />
28
+ <button
29
+ type="button"
30
+ onClick={decrypted ? undefined : onClick}
31
+ disabled={decrypted || loading}
32
+ className={`wf-decrypt-btn ${decrypted ? 'wf-decrypt-done' : 'wf-decrypt-idle'}`}
33
+ >
34
+ {loading ? (
35
+ <Spinner size={14} />
36
+ ) : decrypted ? (
37
+ <svg
38
+ width={14}
39
+ height={14}
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeWidth={2}
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ >
47
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
48
+ <path d="M7 11V7a5 5 0 0 1 9.9-1" />
49
+ </svg>
50
+ ) : (
51
+ <svg
52
+ width={14}
53
+ height={14}
54
+ viewBox="0 0 24 24"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ strokeWidth={2}
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ >
61
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
62
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
63
+ </svg>
64
+ )}
65
+ {loading ? 'Decrypting…' : decrypted ? 'Decrypted' : 'Decrypt'}
66
+ </button>
67
+ </>
68
+ );
69
+ }