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

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 (70) 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 +77 -40
  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 +33 -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/trace-viewer/util/timing.d.ts.map +1 -1
  24. package/dist/components/trace-viewer/util/timing.js +1 -2
  25. package/dist/components/trace-viewer/util/timing.js.map +1 -1
  26. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  27. package/dist/components/ui/error-stack-block.js +2 -1
  28. package/dist/components/ui/error-stack-block.js.map +1 -1
  29. package/dist/components/ui/timestamp-tooltip.d.ts +6 -0
  30. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -0
  31. package/dist/components/ui/timestamp-tooltip.js +200 -0
  32. package/dist/components/ui/timestamp-tooltip.js.map +1 -0
  33. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  34. package/dist/components/workflow-trace-view.js +8 -1
  35. package/dist/components/workflow-trace-view.js.map +1 -1
  36. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  37. package/dist/components/workflow-traces/trace-span-construction.js +10 -7
  38. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/lib/hydration.d.ts +3 -0
  44. package/dist/lib/hydration.d.ts.map +1 -1
  45. package/dist/lib/hydration.js +36 -10
  46. package/dist/lib/hydration.js.map +1 -1
  47. package/dist/lib/toast.d.ts +25 -0
  48. package/dist/lib/toast.d.ts.map +1 -0
  49. package/dist/lib/toast.js +24 -0
  50. package/dist/lib/toast.js.map +1 -0
  51. package/dist/lib/utils.d.ts.map +1 -1
  52. package/dist/lib/utils.js +7 -3
  53. package/dist/lib/utils.js.map +1 -1
  54. package/package.json +6 -4
  55. package/src/components/event-list-view.tsx +125 -35
  56. package/src/components/hook-actions.tsx +2 -1
  57. package/src/components/sidebar/attribute-panel.tsx +85 -35
  58. package/src/components/sidebar/copyable-data-block.tsx +2 -1
  59. package/src/components/sidebar/entity-detail-panel.tsx +13 -5
  60. package/src/components/sidebar/events-list.tsx +51 -13
  61. package/src/components/trace-viewer/components/span-segments.ts +70 -1
  62. package/src/components/trace-viewer/util/timing.ts +1 -2
  63. package/src/components/ui/error-stack-block.tsx +2 -1
  64. package/src/components/ui/timestamp-tooltip.tsx +326 -0
  65. package/src/components/workflow-trace-view.tsx +8 -1
  66. package/src/components/workflow-traces/trace-span-construction.ts +12 -7
  67. package/src/index.ts +2 -0
  68. package/src/lib/hydration.ts +43 -9
  69. package/src/lib/toast.tsx +42 -0
  70. package/src/lib/utils.ts +7 -3
@@ -6,8 +6,8 @@ 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';
10
- import { isEncryptedMarker } from '../../lib/hydration';
9
+ import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration';
10
+ import { useToast } from '../../lib/toast';
11
11
  import { extractConversation, isDoStreamStep } from '../../lib/utils';
12
12
  import { StreamClickContext } from '../ui/data-inspector';
13
13
  import { ErrorCard } from '../ui/error-card';
@@ -16,6 +16,7 @@ import {
16
16
  isStructuredErrorWithStack,
17
17
  } from '../ui/error-stack-block';
18
18
  import { Skeleton } from '../ui/skeleton';
19
+ import { TimestampTooltip } from '../ui/timestamp-tooltip';
19
20
  import { ConversationView } from './conversation-view';
20
21
  import { CopyableDataBlock } from './copyable-data-block';
21
22
  import { DetailCard } from './detail-card';
@@ -194,6 +195,24 @@ function EncryptedFieldBlock() {
194
195
  );
195
196
  }
196
197
 
198
+ /**
199
+ * Inline display for an expired field — flat label indicating data is no longer available.
200
+ */
201
+ function ExpiredFieldBlock() {
202
+ return (
203
+ <div
204
+ className="flex items-center gap-1.5 rounded-md border px-3 py-2 text-xs"
205
+ style={{
206
+ borderColor: 'var(--ds-gray-300)',
207
+ backgroundColor: 'var(--ds-gray-100)',
208
+ color: 'var(--ds-gray-700)',
209
+ }}
210
+ >
211
+ <span className="font-medium">Data expired</span>
212
+ </div>
213
+ );
214
+ }
215
+
197
216
  function JsonBlock(value: unknown) {
198
217
  return <CopyableDataBlock data={value} />;
199
218
  }
@@ -336,6 +355,16 @@ const localMillisecondTimeOrNull = (value: unknown): string | null => {
336
355
  return formatLocalMillisecondTime(date);
337
356
  };
338
357
 
358
+ const timestampWithTooltipOrNull = (value: unknown): ReactNode | null => {
359
+ const date = parseDateValue(value);
360
+ if (!date) return null;
361
+ return (
362
+ <TimestampTooltip date={date}>
363
+ <span>{formatLocalMillisecondTime(date)}</span>
364
+ </TimestampTooltip>
365
+ );
366
+ };
367
+
339
368
  interface DisplayContext {
340
369
  stepName?: string;
341
370
  }
@@ -375,23 +404,24 @@ const attributeToDisplayFn: Record<
375
404
  projectId: (_value: unknown) => null,
376
405
  environment: (_value: unknown) => null,
377
406
  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,
407
+ // Dates — wrapped with TimestampTooltip showing UTC/local + relative time
408
+ createdAt: timestampWithTooltipOrNull,
409
+ startedAt: timestampWithTooltipOrNull,
410
+ updatedAt: timestampWithTooltipOrNull,
411
+ completedAt: timestampWithTooltipOrNull,
412
+ expiredAt: timestampWithTooltipOrNull,
413
+ retryAfter: timestampWithTooltipOrNull,
414
+ resumeAt: timestampWithTooltipOrNull,
387
415
  // Resolved attributes, won't actually use this function
388
416
  metadata: (value: unknown) => {
389
417
  if (!hasDisplayContent(value)) return null;
390
418
  if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
419
+ if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
391
420
  return JsonBlock(value);
392
421
  },
393
422
  input: (value: unknown, context?: DisplayContext) => {
394
423
  if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
424
+ if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
395
425
  // Check if input has args + closure vars structure
396
426
  if (value && typeof value === 'object' && 'args' in value) {
397
427
  const { args, closureVars, thisVal } = value as {
@@ -496,6 +526,7 @@ const attributeToDisplayFn: Record<
496
526
  output: (value: unknown) => {
497
527
  if (!hasDisplayContent(value)) return null;
498
528
  if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
529
+ if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
499
530
  return (
500
531
  <DetailCard
501
532
  summary="Output"
@@ -508,6 +539,7 @@ const attributeToDisplayFn: Record<
508
539
  },
509
540
  error: (value: unknown) => {
510
541
  if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
542
+ if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
511
543
  if (!hasDisplayContent(value)) return null;
512
544
 
513
545
  // If the error object has a `stack` field, render it as readable
@@ -536,6 +568,7 @@ const attributeToDisplayFn: Record<
536
568
  },
537
569
  eventData: (value: unknown) => {
538
570
  if (isEncryptedMarker(value)) return <EncryptedFieldBlock />;
571
+ if (isExpiredMarker(value)) return <ExpiredFieldBlock />;
539
572
  if (!hasDisplayContent(value)) return null;
540
573
  return <DetailCard summary="Event Data">{JsonBlock(value)}</DetailCard>;
541
574
  },
@@ -579,13 +612,7 @@ export const AttributeBlock = ({
579
612
  attribute === 'input' ||
580
613
  attribute === 'output' ||
581
614
  attribute === 'eventData';
582
- if (isLoading && isExpandableLoadingTarget) {
583
- const label =
584
- attribute === 'eventData'
585
- ? 'Event Data'
586
- : attribute === 'output'
587
- ? 'Output'
588
- : 'Input';
615
+ if (isLoading && isExpandableLoadingTarget && !hasDisplayContent(value)) {
589
616
  return (
590
617
  <div
591
618
  className={`my-2 flex flex-col ${attribute === 'input' || attribute === 'output' ? 'gap-2 my-3.5' : 'gap-0'}`}
@@ -596,19 +623,7 @@ export const AttributeBlock = ({
596
623
  >
597
624
  {attribute}
598
625
  </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>
626
+ <Skeleton className="h-9 w-full rounded-md" />
612
627
  </div>
613
628
  );
614
629
  }
@@ -674,6 +689,7 @@ export const AttributePanel = ({
674
689
  error,
675
690
  expiredAt,
676
691
  onStreamClick,
692
+ resource,
677
693
  }: {
678
694
  data: Record<string, unknown>;
679
695
  moduleSpecifier?: string;
@@ -682,7 +698,10 @@ export const AttributePanel = ({
682
698
  expiredAt?: string | Date;
683
699
  /** Callback when a stream reference is clicked */
684
700
  onStreamClick?: (streamId: string) => void;
701
+ /** Resource type of the selected span — used to show targeted loading skeletons. */
702
+ resource?: string;
685
703
  }) => {
704
+ const toast = useToast();
686
705
  // Extract workflowCoreVersion from executionContext for display
687
706
  const displayData = useMemo(() => {
688
707
  const result = { ...data };
@@ -705,9 +724,23 @@ export const AttributePanel = ({
705
724
  const basicAttributes = Object.keys(displayData)
706
725
  .filter((key) => !resolvableAttributes.includes(key))
707
726
  .sort(sortByAttributeOrder);
708
- const resolvedAttributes = Object.keys(displayData)
709
- .filter((key) => resolvableAttributes.includes(key))
710
- .sort(sortByAttributeOrder);
727
+ const resolvedAttributes = useMemo(() => {
728
+ const present = Object.keys(displayData)
729
+ .filter((key) => resolvableAttributes.includes(key))
730
+ .sort(sortByAttributeOrder);
731
+
732
+ if (!isLoading) return present;
733
+
734
+ // During loading, ensure input/output appear so their skeletons render
735
+ // in the correct position (above the events section).
736
+ const loadingDefaults = ['input', 'output'];
737
+ for (const key of loadingDefaults) {
738
+ if (!present.includes(key)) {
739
+ present.push(key);
740
+ }
741
+ }
742
+ return present.sort(sortByAttributeOrder);
743
+ }, [displayData, isLoading]);
711
744
 
712
745
  // Filter out attributes that return null
713
746
  const visibleBasicAttributes = basicAttributes.filter((attribute) => {
@@ -784,7 +817,11 @@ export const AttributePanel = ({
784
817
  ? displayValue
785
818
  : String(displayValue ?? displayData.moduleSpecifier ?? '');
786
819
  const shouldCapitalizeLabel = attribute !== 'workflowCoreVersion';
787
- const showDivider = index < orderedBasicAttributes.length - 1;
820
+ const showResumeAtSkeleton =
821
+ isLoading && resource === 'sleep' && !displayData.resumeAt;
822
+ const showDivider =
823
+ index < orderedBasicAttributes.length - 1 ||
824
+ showResumeAtSkeleton;
788
825
 
789
826
  return (
790
827
  <div key={attribute} className="py-1">
@@ -834,6 +871,19 @@ export const AttributePanel = ({
834
871
  </div>
835
872
  );
836
873
  })}
874
+ {isLoading && resource === 'sleep' && !displayData.resumeAt && (
875
+ <div className="py-1">
876
+ <div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
877
+ <span
878
+ className="text-[14px] first-letter:uppercase"
879
+ style={{ color: 'var(--ds-gray-700)' }}
880
+ >
881
+ resumeAt
882
+ </span>
883
+ <Skeleton className="h-4 w-[55%]" />
884
+ </div>
885
+ </div>
886
+ )}
837
887
  </div>
838
888
  )}
839
889
  {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,8 @@
2
2
 
3
3
  import type { Event } from '@workflow/world';
4
4
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
+ import { isExpiredMarker } from '../../lib/hydration';
6
+ import { ErrorCard } from '../ui/error-card';
5
7
  import {
6
8
  ErrorStackBlock,
7
9
  isStructuredErrorWithStack,
@@ -23,10 +25,14 @@ const DATA_EVENT_TYPES = new Set([
23
25
  'step_created',
24
26
  'step_completed',
25
27
  'step_failed',
28
+ 'step_retrying',
26
29
  'hook_created',
27
30
  'hook_received',
28
31
  'run_created',
29
32
  'run_completed',
33
+ 'run_failed',
34
+ 'wait_created',
35
+ 'wait_completed',
30
36
  ]);
31
37
 
32
38
  /**
@@ -175,15 +181,11 @@ function EventItem({
175
181
 
176
182
  {/* Error state */}
177
183
  {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>
184
+ <ErrorCard
185
+ title="Failed to load event data"
186
+ details={loadError}
187
+ className="mt-2"
188
+ />
187
189
  )}
188
190
 
189
191
  {/* Event data */}
@@ -196,6 +198,24 @@ function EventItem({
196
198
  );
197
199
  }
198
200
 
201
+ /**
202
+ * Check if an eventData object has only expired marker values in its serialized
203
+ * sub-fields (result, input, output, metadata, payload). Non-serialized fields
204
+ * like `resumeAt` or `reason` are ignored.
205
+ */
206
+ function hasOnlyExpiredFields(data: unknown): boolean {
207
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
208
+ return false;
209
+ }
210
+ const record = data as Record<string, unknown>;
211
+ const serializedKeys = ['result', 'input', 'output', 'metadata', 'payload'];
212
+ const presentKeys = serializedKeys.filter((k) => k in record);
213
+ return (
214
+ presentKeys.length > 0 &&
215
+ presentKeys.every((k) => isExpiredMarker(record[k]))
216
+ );
217
+ }
218
+
199
219
  /**
200
220
  * Renders event data, using ErrorStackBlock for error events that contain
201
221
  * a structured error with a stack trace, and CopyableDataBlock otherwise.
@@ -207,6 +227,24 @@ function EventDataBlock({
207
227
  eventType: string;
208
228
  data: unknown;
209
229
  }) {
230
+ // Expired data — show a simple message instead of the raw stub.
231
+ // Check both the top-level eventData and nested sub-fields (result, input, etc.)
232
+ // since the server stubs each ref field independently.
233
+ if (isExpiredMarker(data) || hasOnlyExpiredFields(data)) {
234
+ return (
235
+ <div
236
+ className="flex items-center gap-1.5 rounded-md border px-3 py-2 text-xs"
237
+ style={{
238
+ borderColor: 'var(--ds-gray-300)',
239
+ backgroundColor: 'var(--ds-gray-100)',
240
+ color: 'var(--ds-gray-700)',
241
+ }}
242
+ >
243
+ <span className="font-medium">Data expired</span>
244
+ </div>
245
+ );
246
+ }
247
+
210
248
  // For error events (step_failed, step_retrying), the eventData has the shape
211
249
  // { error: StructuredError, stack?: string, ... }. Check both the top-level
212
250
  // value and the nested `error` field for a stack trace.
@@ -269,10 +307,10 @@ export function EventsList({
269
307
  Events {!isLoading && `(${sortedEvents.length})`}
270
308
  </h3>
271
309
  {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" />
310
+ <div className="flex flex-col gap-4">
311
+ <Skeleton className="h-9 w-full rounded-md" />
312
+ <Skeleton className="h-9 w-full rounded-md" />
313
+ <Skeleton className="h-9 w-full rounded-md" />
276
314
  </div>
277
315
  ) : null}
278
316
  {!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,
@@ -41,6 +41,5 @@ export function formatWallClockTime(epochMs: number): string {
41
41
  const h = String(d.getHours()).padStart(2, '0');
42
42
  const m = String(d.getMinutes()).padStart(2, '0');
43
43
  const s = String(d.getSeconds()).padStart(2, '0');
44
- const ms = String(d.getMilliseconds()).padStart(3, '0');
45
- return `${h}:${m}:${s}.${ms}`;
44
+ return `${h}:${m}:${s}`;
46
45
  }
@@ -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;