From 0d13f2038d0df3f885bfa5c66e1a0b96dcfde6ee Mon Sep 17 00:00:00 2001 From: mitul-s Date: Tue, 9 Jun 2026 19:24:32 -0400 Subject: [PATCH] simplify passing span details --- packages/web-shared/src/components/index.ts | 13 +- .../new-trace-viewer/trace-viewer.tsx | 5 +- .../src/components/run-trace-view.tsx | 19 +-- .../sidebar/entity-detail-panel.tsx | 85 ++++------ .../src/components/sidebar/events-list.tsx | 59 ++++--- .../sidebar/sidebar-data-context.tsx | 12 +- .../components/sidebar/span-detail-merge.ts | 39 +++++ .../src/components/sidebar/use-span-detail.ts | 91 ++++++++++ .../src/components/trace-viewer/context.tsx | 2 +- .../src/components/workflow-trace-view.tsx | 27 +-- .../web-shared/test/span-detail-merge.test.ts | 84 +++++++++ .../web/app/components/run-detail-view.tsx | 45 +---- .../app/lib/client/hooks/use-resource-data.ts | 159 +++++++++--------- packages/web/app/lib/workflow-api-client.ts | 5 +- 14 files changed, 396 insertions(+), 249 deletions(-) create mode 100644 packages/web-shared/src/components/sidebar/span-detail-merge.ts create mode 100644 packages/web-shared/src/components/sidebar/use-span-detail.ts create mode 100644 packages/web-shared/test/span-detail-merge.test.ts diff --git a/packages/web-shared/src/components/index.ts b/packages/web-shared/src/components/index.ts index 6b0adcf993..96cd5a01ec 100644 --- a/packages/web-shared/src/components/index.ts +++ b/packages/web-shared/src/components/index.ts @@ -13,18 +13,21 @@ export { ResolveHookModal, useHookActions, } from './hook-actions'; +export { TraceViewerSkeleton } from './new-trace-viewer/components/trace-viewer-skeleton'; export { RunTraceView } from './run-trace-view'; export { ConversationView } from './sidebar/conversation-view'; -export { - SidebarDataProvider, - type SidebarDataContextValue, -} from './sidebar/sidebar-data-context'; export type { SelectedSpanInfo, SpanSelectionInfo, } from './sidebar/entity-detail-panel'; +export { + type SidebarDataContextValue, + SidebarDataProvider, +} from './sidebar/sidebar-data-context'; +export type { FetchSpanDetail, SpanDetail } from './sidebar/use-span-detail'; export { type StreamChunk, StreamViewer } from './stream-viewer'; export type { Span, SpanEvent } from './trace-viewer/types'; +export { NewTraceViewer } from './trace-viewer-new'; export { DataInspector, type DataInspectorProps, @@ -37,5 +40,3 @@ export { LoadMoreButton } from './ui/load-more-button'; export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown'; export { Spinner } from './ui/spinner'; export { WorkflowTraceViewer } from './workflow-trace-view'; -export { NewTraceViewer } from './trace-viewer-new'; -export { TraceViewerSkeleton } from './new-trace-viewer/components/trace-viewer-skeleton'; diff --git a/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx b/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx index 49801d90e1..12c3dc9b6b 100644 --- a/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx @@ -734,10 +734,7 @@ function NewTraceViewerContent({ run={sidebar.run} onStreamClick={sidebar.onStreamClick} onRunClick={sidebar.onRunClick} - spanDetailData={sidebar.spanDetailData} - spanDetailError={sidebar.spanDetailError} - spanDetailLoading={sidebar.spanDetailLoading} - onSpanSelect={sidebar.onSpanSelect} + fetchSpanDetail={sidebar.fetchSpanDetail} onWakeUpSleep={sidebar.onWakeUpSleep} onLoadEventData={sidebar.onLoadEventData} onResolveHook={sidebar.onResolveHook} diff --git a/packages/web-shared/src/components/run-trace-view.tsx b/packages/web-shared/src/components/run-trace-view.tsx index be5d21380d..e75d136a8a 100644 --- a/packages/web-shared/src/components/run-trace-view.tsx +++ b/packages/web-shared/src/components/run-trace-view.tsx @@ -1,8 +1,8 @@ 'use client'; -import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; +import type { Event, Hook, WorkflowRun } from '@workflow/world'; import { AlertCircle } from 'lucide-react'; -import type { SpanSelectionInfo } from './sidebar/entity-detail-panel'; +import type { FetchSpanDetail } from './sidebar/use-span-detail'; import { WorkflowTraceViewer } from './workflow-trace-view'; interface RunTraceViewProps { @@ -10,9 +10,7 @@ interface RunTraceViewProps { events: Event[]; isLoading?: boolean; error?: Error | null; - spanDetailData?: WorkflowRun | Step | Hook | Event | null; - spanDetailLoading?: boolean; - spanDetailError?: Error | null; + fetchSpanDetail?: FetchSpanDetail; onWakeUpSleep?: ( runId: string, correlationId: string @@ -25,7 +23,6 @@ interface RunTraceViewProps { onCancelRun?: (runId: string) => Promise; onStreamClick?: (streamId: string) => void; onRunClick?: (runId: string) => void; - onSpanSelect?: (info: SpanSelectionInfo) => void; onLoadMoreSpans?: () => void | Promise; hasMoreSpans?: boolean; isLoadingMoreSpans?: boolean; @@ -36,15 +33,12 @@ export function RunTraceView({ events, isLoading, error, - spanDetailData, - spanDetailLoading, - spanDetailError, + fetchSpanDetail, onWakeUpSleep, onResolveHook, onCancelRun, onStreamClick, onRunClick, - onSpanSelect, onLoadMoreSpans, hasMoreSpans, isLoadingMoreSpans, @@ -66,15 +60,12 @@ export function RunTraceView({ events={events} run={run} isLoading={isLoading} - spanDetailData={spanDetailData} - spanDetailLoading={spanDetailLoading} - spanDetailError={spanDetailError} + fetchSpanDetail={fetchSpanDetail} onWakeUpSleep={onWakeUpSleep} onResolveHook={onResolveHook} onCancelRun={onCancelRun} onStreamClick={onStreamClick} onRunClick={onRunClick} - onSpanSelect={onSpanSelect} onLoadMoreSpans={onLoadMoreSpans} hasMoreSpans={hasMoreSpans} isLoadingMoreSpans={isLoadingMoreSpans} diff --git a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx index 483b4e6dd5..f0f67b9696 100644 --- a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -3,13 +3,15 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import clsx from 'clsx'; import { Send, Zap } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useToast } from '../../lib/toast'; import { DecryptClickContext } from '../ui/data-inspector'; import { AttributePanel } from './attribute-panel'; import { EventsList } from './events-list'; import { ResolveHookModal } from './resolve-hook-modal'; import { useSidebarDataOptional } from './sidebar-data-context'; +import { mergeSpanDetail } from './span-detail-merge'; +import { type FetchSpanDetail, useSpanDetail } from './use-span-detail'; // Type guards for runtime validation of span attribute data function isStep(data: unknown): data is Step { @@ -57,10 +59,7 @@ export function EntityDetailPanel({ run, onStreamClick, onRunClick, - spanDetailData, - spanDetailError, - spanDetailLoading, - onSpanSelect, + fetchSpanDetail, onWakeUpSleep, onLoadEventData, onResolveHook, @@ -74,14 +73,12 @@ export function EntityDetailPanel({ onStreamClick?: (streamId: string) => void; /** Callback when a run reference is clicked */ onRunClick?: (runId: string) => void; - /** Pre-fetched span detail data for the selected span. */ - spanDetailData: WorkflowRun | Step | Hook | Event | null; - /** Error from external span detail fetch. */ - spanDetailError?: Error | null; - /** Loading state from external span detail fetch. */ - spanDetailLoading?: boolean; - /** Callback when a span is selected. Use this to fetch data externally and pass via spanDetailData. */ - onSpanSelect: (info: SpanSelectionInfo) => void; + /** + * Fetches the detail (input/output/etc.) for a selected span. The panel + * owns the fetch lifecycle and keys the result to the current selection, so + * the returned data always belongs to the span being rendered. + */ + fetchSpanDetail?: FetchSpanDetail; /** Callback to wake up a pending sleep call. */ onWakeUpSleep?: ( runId: string, @@ -124,10 +121,10 @@ export function EntityDetailPanel({ const rawEvents = selectedSpan?.rawEvents; const rawEventsLength = rawEvents?.length ?? 0; - // Determine resource type, ID, and runId from the selected span - const { resource, resourceId, runId } = useMemo(() => { + // Determine the selection (resource type, ID, and runId) from the selected span + const selection = useMemo(() => { if (!selectedSpan) { - return { resource: undefined, resourceId: undefined, runId: undefined }; + return null; } const res = selectedSpan.resource; @@ -135,12 +132,12 @@ export function EntityDetailPanel({ return { resource: 'step', resourceId: data.stepId, runId: data.runId }; } if (res === 'run' && isWorkflowRun(data)) { - return { resource: 'run', resourceId: data.runId, runId: undefined }; + return { resource: 'run', resourceId: data.runId }; } if (res === 'hook' && isHook(data)) { - return { resource: 'hook', resourceId: data.hookId, runId: undefined }; + return { resource: 'hook', resourceId: data.hookId }; } - if (res === 'sleep') { + if (res === 'sleep' && selectedSpan.spanId) { const waitData = data as { runId?: string } | undefined; return { resource: 'sleep', @@ -148,29 +145,16 @@ export function EntityDetailPanel({ runId: waitData?.runId, }; } - return { resource: undefined, resourceId: undefined, runId: undefined }; + return null; }, [selectedSpan, data]); + const resource = selection?.resource; + const resourceId = selection?.resourceId; - // Notify parent when span selection changes. - // Use a ref for the callback so the effect only fires when the actual - // selection values change, not when the callback identity changes due to - // parent re-renders from polling. - const onSpanSelectRef = useRef(onSpanSelect); - onSpanSelectRef.current = onSpanSelect; - - useEffect(() => { - if ( - resource && - resourceId && - ['run', 'step', 'hook', 'sleep'].includes(resource) - ) { - onSpanSelectRef.current({ - resource: resource as 'run' | 'step' | 'hook' | 'sleep', - resourceId, - runId, - }); - } - }, [resource, resourceId, runId]); + const { + data: spanDetailData, + loading: spanDetailLoading, + error: spanDetailError, + } = useSpanDetail(selection, fetchSpanDetail, { encryptionKey }); // Check if this sleep is still pending and can be woken up const canWakeUp = useMemo(() => { @@ -213,7 +197,7 @@ export function EntityDetailPanel({ ]); const error = spanDetailError ?? undefined; - const loading = spanDetailLoading ?? false; + const loading = spanDetailLoading; // Get the hook token for resolving (prefer fetched data, then hooks array fallback) const hookToken = useMemo(() => { @@ -313,14 +297,15 @@ export function EntityDetailPanel({ [onResolveHook, hookToken, resolvingHook, spanDetailData, data] ); - // Prefer externally-fetched details when available. For sleep spans, the - // host fetches full correlated events (withData=true) and materializes a wait - // entity, so this includes resumeAt/completedAt without bloating trace payloads. - const displayData = (spanDetailData ?? data) as - | WorkflowRun - | Step - | Hook - | Event; + const displayData = useMemo( + () => + mergeSpanDetail(data, spanDetailData) as + | WorkflowRun + | Step + | Hook + | Event, + [data, spanDetailData] + ); const moduleSpecifier = useMemo(() => { const displayRecord = displayData as Record; @@ -435,6 +420,8 @@ export function EntityDetailPanel({ )} diff --git a/packages/web-shared/src/components/sidebar/events-list.tsx b/packages/web-shared/src/components/sidebar/events-list.tsx index bd3d2e77c9..fdab658d02 100644 --- a/packages/web-shared/src/components/sidebar/events-list.tsx +++ b/packages/web-shared/src/components/sidebar/events-list.tsx @@ -3,6 +3,7 @@ import { EVENT_DATA_REF_FIELDS, type Event } from '@workflow/world'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { hasEncryptedFields, isExpiredMarker } from '../../lib/hydration'; +import { RunClickContext, StreamClickContext } from '../ui/data-inspector'; import { ErrorCard } from '../ui/error-card'; import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block'; import { Skeleton } from '../ui/skeleton'; @@ -256,6 +257,8 @@ export function EventsList({ isLoading = false, error, onLoadEventData, + onStreamClick, + onRunClick, encryptionKey, }: { events: Event[]; @@ -265,6 +268,8 @@ export function EventsList({ correlationId: string, eventId: string ) => Promise; + onStreamClick?: (streamId: string) => void; + onRunClick?: (runId: string) => void; /** When provided, signals that decryption is active (triggers re-load of expanded events) */ encryptionKey?: Uint8Array; }) { @@ -285,31 +290,35 @@ export function EventsList({ } return ( - - {isLoading ? ( -
- {[0, 1, 2].map((i) => ( -
- - + + + + {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+ + +
+ ))}
- ))} -
- ) : ( -
- {sortedEvents.map((event) => ( - - ))} -
- )} - + ) : ( +
+ {sortedEvents.map((event) => ( + + ))} +
+ )} + + + ); } diff --git a/packages/web-shared/src/components/sidebar/sidebar-data-context.tsx b/packages/web-shared/src/components/sidebar/sidebar-data-context.tsx index 633b5ea8c9..f382c1c132 100644 --- a/packages/web-shared/src/components/sidebar/sidebar-data-context.tsx +++ b/packages/web-shared/src/components/sidebar/sidebar-data-context.tsx @@ -1,16 +1,14 @@ 'use client'; -import { createContext, useContext, type ReactNode } from 'react'; -import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; -import type { SpanSelectionInfo } from './entity-detail-panel'; +import type { Event, Hook, WorkflowRun } from '@workflow/world'; +import { createContext, type ReactNode, useContext } from 'react'; +import type { FetchSpanDetail } from './use-span-detail'; export interface SidebarDataContextValue { run: WorkflowRun; events: Event[]; - spanDetailData: WorkflowRun | Step | Hook | Event | null; - spanDetailError?: Error | null; - spanDetailLoading?: boolean; - onSpanSelect: (info: SpanSelectionInfo) => void; + /** Fetches the detail for a selected span; the panel owns the lifecycle. */ + fetchSpanDetail?: FetchSpanDetail; onStreamClick?: (streamId: string) => void; onRunClick?: (runId: string) => void; onWakeUpSleep?: ( diff --git a/packages/web-shared/src/components/sidebar/span-detail-merge.ts b/packages/web-shared/src/components/sidebar/span-detail-merge.ts new file mode 100644 index 0000000000..878cf34681 --- /dev/null +++ b/packages/web-shared/src/components/sidebar/span-detail-merge.ts @@ -0,0 +1,39 @@ +/** + * Merges the externally-fetched span detail with the span's own attribute + * data for display. + * + * The fetched detail exists only to supply the heavy fields the trace + * deliberately strips to keep payloads small — input, output, error, + * metadata, and (for sleeps) resumeAt. Everything the span entity already + * knows from the event log — its identity (stepId/runId/hookId), status, and + * the event-derived timestamps — stays authoritative. + * + * `detail` is keyed to the current selection by useSpanDetail, so it always + * belongs to this span (and is null while loading). Keeping the span's own + * fields means identity and timestamps stay put while the heavy fields fill + * in — and it avoids the backend row's timestamps (which differ from the + * event-derived ones by milliseconds) flickering in. The span-derived + * timestamps are also what the timeline is drawn from, so the panel stays + * consistent with the chart. + */ +export function mergeSpanDetail(spanData: unknown, detail: unknown): unknown { + if (!detail || typeof detail !== 'object') { + return spanData; + } + if (!spanData || typeof spanData !== 'object') { + return detail; + } + // Start from the fetched detail (for input/output/error/metadata), then let + // every field the span actually has override it. `undefined` span fields are + // skipped so they don't clobber a value the detail legitimately provides + // (e.g. a step's optional startedAt, or a sleep's resumeAt). + const merged: Record = { ...detail }; + for (const [key, value] of Object.entries( + spanData as Record + )) { + if (value !== undefined) { + merged[key] = value; + } + } + return merged; +} diff --git a/packages/web-shared/src/components/sidebar/use-span-detail.ts b/packages/web-shared/src/components/sidebar/use-span-detail.ts new file mode 100644 index 0000000000..f19009bc26 --- /dev/null +++ b/packages/web-shared/src/components/sidebar/use-span-detail.ts @@ -0,0 +1,91 @@ +'use client'; + +import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; +import { useEffect, useRef, useState } from 'react'; +import type { SpanSelectionInfo } from './entity-detail-panel'; + +export type SpanDetail = WorkflowRun | Step | Hook | Event | null; + +export type FetchSpanDetail = (info: SpanSelectionInfo) => Promise; + +interface SpanDetailState { + data: SpanDetail; + loading: boolean; + error: Error | null; +} + +const IDLE: SpanDetailState = { data: null, loading: false, error: null }; + +/** + * Fetches the detail for the currently selected span, keyed to the selection + * so the result can never belong to a different span than the one rendered. + * + * The fetch is async, so a result could land after the selection has moved + * on. The effect is keyed on the selection (+ encryptionKey, which changes + * hydration) and a per-run `ignore` flag drops any resolution from a + * superseded selection, so data is only ever set for the current selection. + * + * `fetchSpanDetail` is read through a ref so a changing fetcher identity + * (hosts close over trace events / encryption key) does not trigger a refetch + * on its own — only an actual selection or encryptionKey change does. + */ +export function useSpanDetail( + selection: SpanSelectionInfo | null, + fetchSpanDetail: FetchSpanDetail | undefined, + options: { encryptionKey?: Uint8Array } = {} +): SpanDetailState { + const resource = selection?.resource; + const resourceId = selection?.resourceId; + const runId = selection?.runId; + const { encryptionKey } = options; + + const [state, setState] = useState(IDLE); + + const fetchRef = useRef(fetchSpanDetail); + fetchRef.current = fetchSpanDetail; + + // Tracks the last resource+id so data is cleared on a selection change but + // preserved across an encryptionKey-only refetch (decrypt keeps data visible). + const prevSelectionKeyRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: encryptionKey re-runs the fetch when the decrypt key arrives; the fetcher reads it via closure on the host side + useEffect(() => { + const fetcher = fetchRef.current; + if (!resource || !resourceId || !fetcher) { + prevSelectionKeyRef.current = null; + setState(IDLE); + return; + } + + const selectionKey = `${resource}:${resourceId}`; + const selectionChanged = selectionKey !== prevSelectionKeyRef.current; + prevSelectionKeyRef.current = selectionKey; + + setState((prev) => ({ + data: selectionChanged ? null : prev.data, + loading: true, + error: null, + })); + + let ignore = false; + fetcher({ resource, resourceId, runId }) + .then((data) => { + if (!ignore) setState({ data, loading: false, error: null }); + }) + .catch((err) => { + if (!ignore) { + setState({ + data: null, + loading: false, + error: err instanceof Error ? err : new Error(String(err)), + }); + } + }); + + return () => { + ignore = true; + }; + }, [resource, resourceId, runId, encryptionKey]); + + return state; +} diff --git a/packages/web-shared/src/components/trace-viewer/context.tsx b/packages/web-shared/src/components/trace-viewer/context.tsx index 5e26af8baa..bd63b02392 100644 --- a/packages/web-shared/src/components/trace-viewer/context.tsx +++ b/packages/web-shared/src/components/trace-viewer/context.tsx @@ -678,7 +678,7 @@ export const useTraceViewer = (): TraceViewerContextProps => /** * Separate context for the custom panel component. This is intentionally * outside the useReducer state so that the panel re-renders reactively - * when props like spanDetailData change. + * when the host re-renders it with new props. */ const CustomPanelContext = createContext(null); diff --git a/packages/web-shared/src/components/workflow-trace-view.tsx b/packages/web-shared/src/components/workflow-trace-view.tsx index cb372df83b..57ca383c80 100644 --- a/packages/web-shared/src/components/workflow-trace-view.tsx +++ b/packages/web-shared/src/components/workflow-trace-view.tsx @@ -1,7 +1,7 @@ 'use client'; import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; -import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; +import type { Event, Hook, WorkflowRun } from '@workflow/world'; import { ChevronDown, ChevronUp, @@ -25,6 +25,7 @@ import { type SpanSelectionInfo, } from './sidebar/entity-detail-panel'; import { ResolveHookModal } from './sidebar/resolve-hook-modal'; +import type { FetchSpanDetail } from './sidebar/use-span-detail'; import { TraceViewerContextProvider, TraceViewerTimeline, @@ -760,15 +761,12 @@ export const WorkflowTraceViewer = ({ events, isLoading, error, - spanDetailData, - spanDetailLoading, - spanDetailError, + fetchSpanDetail, onWakeUpSleep, onResolveHook, onCancelRun, onStreamClick, onRunClick, - onSpanSelect, onLoadEventData, onLoadMoreSpans, hasMoreSpans = false, @@ -781,9 +779,8 @@ export const WorkflowTraceViewer = ({ events: Event[]; isLoading?: boolean; error?: Error | null; - spanDetailData?: WorkflowRun | Step | Hook | Event | null; - spanDetailLoading?: boolean; - spanDetailError?: Error | null; + /** Fetches the detail for a selected span; the panel owns the lifecycle. */ + fetchSpanDetail?: FetchSpanDetail; onWakeUpSleep?: ( runId: string, correlationId: string @@ -799,8 +796,6 @@ export const WorkflowTraceViewer = ({ onStreamClick?: (streamId: string) => void; /** Callback when a run reference is clicked in the detail panel */ onRunClick?: (runId: string) => void; - /** Callback when a span is selected. */ - onSpanSelect?: (info: SpanSelectionInfo) => void; /** Callback to load event data for a specific event (lazy loading in sidebar) */ onLoadEventData?: ( correlationId: string, @@ -852,13 +847,6 @@ export const WorkflowTraceViewer = ({ } }, [error, isLoading]); - const handleSpanSelect = useCallback( - (info: SpanSelectionInfo) => { - onSpanSelect?.(info); - }, - [onSpanSelect] - ); - const handleSelectionChange = useCallback( (info: SelectedSpanInfo | null) => { if (info) { @@ -1150,10 +1138,7 @@ export const WorkflowTraceViewer = ({ run={run} onStreamClick={onStreamClick} onRunClick={onRunClick} - spanDetailData={spanDetailData ?? null} - spanDetailError={spanDetailError} - spanDetailLoading={spanDetailLoading} - onSpanSelect={handleSpanSelect} + fetchSpanDetail={fetchSpanDetail} onWakeUpSleep={onWakeUpSleep} onLoadEventData={onLoadEventData} onResolveHook={onResolveHook} diff --git a/packages/web-shared/test/span-detail-merge.test.ts b/packages/web-shared/test/span-detail-merge.test.ts new file mode 100644 index 0000000000..083aab3538 --- /dev/null +++ b/packages/web-shared/test/span-detail-merge.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { mergeSpanDetail } from '../src/components/sidebar/span-detail-merge.js'; + +describe('mergeSpanDetail', () => { + const spanStep = { + stepId: 'step_a', + runId: 'wrun_1', + createdAt: new Date('2026-06-05T16:55:18.569Z'), + startedAt: new Date('2026-06-05T16:55:18.812Z'), + completedAt: new Date('2026-06-05T16:55:18.913Z'), + }; + + const fetchedStep = { + stepId: 'step_a', + runId: 'wrun_1', + createdAt: new Date('2026-06-05T16:55:18.571Z'), + startedAt: new Date('2026-06-05T16:55:18.820Z'), + completedAt: new Date('2026-06-05T16:55:18.911Z'), + output: { ok: true }, + }; + + it('keeps span-derived timestamps when the fetched detail swaps in', () => { + const merged = mergeSpanDetail(spanStep, fetchedStep) as Record< + string, + unknown + >; + expect(merged.createdAt).toBe(spanStep.createdAt); + expect(merged.startedAt).toBe(spanStep.startedAt); + expect(merged.completedAt).toBe(spanStep.completedAt); + }); + + it('takes non-timestamp fields from the fetched detail', () => { + const merged = mergeSpanDetail(spanStep, fetchedStep) as Record< + string, + unknown + >; + expect(merged.output).toEqual({ ok: true }); + }); + + it('fills timestamps missing from the span data with fetched values', () => { + const { startedAt: _ignored, ...spanWithoutStartedAt } = spanStep; + const merged = mergeSpanDetail(spanWithoutStartedAt, fetchedStep) as Record< + string, + unknown + >; + expect(merged.startedAt).toBe(fetchedStep.startedAt); + }); + + it('keeps the span identity even when the detail is for a stale selection', () => { + // While navigating with J/K, the fetched detail can briefly hold the + // previously selected span. Identity must come from the current span so + // fields like Step ID never show the wrong value or vanish. + const stalePrevStep = { + stepId: 'step_PREVIOUS', + runId: 'wrun_1', + output: { stale: true }, + }; + const merged = mergeSpanDetail(spanStep, stalePrevStep) as Record< + string, + unknown + >; + expect(merged.stepId).toBe('step_a'); + }); + + it('does not clobber a fetched field with an explicit undefined on the span', () => { + const spanWithUndefined = { stepId: 'step_a', startedAt: undefined }; + const merged = mergeSpanDetail(spanWithUndefined, fetchedStep) as Record< + string, + unknown + >; + expect(merged.startedAt).toBe(fetchedStep.startedAt); + expect(merged.output).toEqual({ ok: true }); + }); + + it('returns span data when there is no fetched detail', () => { + expect(mergeSpanDetail(spanStep, null)).toBe(spanStep); + expect(mergeSpanDetail(spanStep, undefined)).toBe(spanStep); + }); + + it('returns fetched detail when there is no span data', () => { + expect(mergeSpanDetail(null, fetchedStep)).toBe(fetchedStep); + expect(mergeSpanDetail(undefined, fetchedStep)).toBe(fetchedStep); + }); +}); diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index 5011c3a736..1e35d94337 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -1,5 +1,5 @@ import { parseWorkflowName } from '@workflow/utils/parse-name'; -import type { SpanSelectionInfo } from '@workflow/web-shared'; +import type { FetchSpanDetail } from '@workflow/web-shared'; import { DecryptButton, ErrorBoundary, @@ -56,10 +56,10 @@ import { fetchEvent, getEncryptionKeyForRun } from '~/lib/rpc-client'; import type { EnvMap } from '~/lib/types'; import { cancelRun, + fetchSpanDetailData, recreateRun, resumeHook, unwrapServerActionResult, - useWorkflowResourceData, useWorkflowStreams, useWorkflowTraceViewerData, wakeUpRun, @@ -373,34 +373,15 @@ export function RunDetailView({ enabled: activeTab === 'events', }); - const [spanSelection, setSpanSelection] = useState( - null - ); - const { - data: spanDetailData, - loading: spanDetailLoading, - error: spanDetailError, - refresh: refreshSpanDetail, - } = useWorkflowResourceData( - env, - spanSelection?.resource ?? 'run', - spanSelection?.resourceId ?? '', - { - runId: spanSelection?.runId, - enabled: Boolean( - spanSelection?.resource && - spanSelection?.resourceId && - spanSelection.resource !== 'hook' - ), - encryptionKey: encryptionKey ?? undefined, - } + const fetchSpanDetail = useCallback( + (info) => fetchSpanDetailData(env, info, encryptionKey ?? undefined), + [env, encryptionKey] ); const [isDecrypting, setIsDecrypting] = useState(false); const handleDecrypt = useCallback(async () => { if (encryptionKey) { - refreshSpanDetail(); return; } setIsDecrypting(true); @@ -419,20 +400,13 @@ export function RunDetailView({ } finally { setIsDecrypting(false); } - }, [encryptionKey, env, runId, refreshSpanDetail]); - - const handleSpanSelect = useCallback((info: SpanSelectionInfo) => { - setSpanSelection(info); - }, []); + }, [encryptionKey, env, runId]); const sidebarData = useMemo( () => ({ run, events: allEvents ?? [], - spanDetailData: spanDetailData ?? null, - spanDetailError, - spanDetailLoading, - onSpanSelect: handleSpanSelect, + fetchSpanDetail, onStreamClick: handleStreamClick, onRunClick: handleRunRefClick, onWakeUpSleep: handleWakeUpSleep, @@ -446,10 +420,7 @@ export function RunDetailView({ [ run, allEvents, - spanDetailData, - spanDetailError, - spanDetailLoading, - handleSpanSelect, + fetchSpanDetail, handleStreamClick, handleRunRefClick, handleWakeUpSleep, diff --git a/packages/web/app/lib/client/hooks/use-resource-data.ts b/packages/web/app/lib/client/hooks/use-resource-data.ts index 66a120073c..6e89d69d28 100644 --- a/packages/web/app/lib/client/hooks/use-resource-data.ts +++ b/packages/web/app/lib/client/hooks/use-resource-data.ts @@ -1,6 +1,7 @@ import { hydrateResourceIO, hydrateResourceIOWithKey, + type SpanSelectionInfo, waitEventsToWaitEntity, } from '@workflow/web-shared'; import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; @@ -56,6 +57,65 @@ async function fetchResourceWithCorrelationId( return { data: resourceData, correlationId }; } +/** + * Fetches the detail for a selected span (run/step/sleep), resolving and + * hydrating input/output/metadata. Hooks return null — they can be + * auto-disposed and the span's inline data is sufficient. Throws on error so + * the caller's keyed fetch hook (web-shared `useSpanDetail`) owns the loading + * and error lifecycle. + */ +export async function fetchSpanDetailData( + env: EnvMap, + info: SpanSelectionInfo, + encryptionKey?: Uint8Array +): Promise { + const hydrate = (resource: T): Promise => + encryptionKey + ? hydrateResourceIOWithKey(resource, encryptionKey) + : hydrateResourceIO(resource); + + if (info.resource === 'hook') { + return null; + } + + if (info.resource === 'sleep') { + if (!info.runId) { + throw new Error('runId is required for loading sleep details'); + } + const { error, result } = await unwrapServerActionResult( + fetchEvents(env, info.runId, { + sortOrder: 'asc', + limit: 1000, + withData: true, + }) + ); + if (error) { + throw error; + } + const allEvents = (result.data as unknown as Event[]).map( + hydrateResourceIO + ); + const waitEvents = await Promise.all( + allEvents.filter((e) => e.correlationId === info.resourceId).map(hydrate) + ); + const wait = waitEventsToWaitEntity(waitEvents); + if (wait === null) { + throw new Error( + 'Failed to load sleep details: missing required event data' + ); + } + return wait as unknown as Hook | Event; + } + + const { data } = await fetchResourceWithCorrelationId( + env, + info.resource, + info.resourceId, + { runId: info.runId } + ); + return hydrate(data); +} + /** * Returns (and keeps up-to-date) data inherent to a specific run/step/hook, * resolving input/output/metadata, AND loading all related events with full event data. @@ -108,95 +168,26 @@ export function useWorkflowResourceData( } setError(null); setLoading(true); - if (resource === 'hook') { - try { - const { error, result } = await unwrapServerActionResult( - fetchHook(env, resourceId, 'all') - ); - if (error) { - setError(error); - return; - } - try { - setData(await hydrate(result)); - } catch (hydrateError) { - setError( - hydrateError instanceof Error - ? hydrateError - : new Error(String(hydrateError)) - ); - } - } finally { - setLoading(false); - } - return; - } - if (resource === 'sleep') { - try { - if (!runId) { - setError(new Error('runId is required for loading sleep details')); - return; - } - const { error, result } = await unwrapServerActionResult( - fetchEvents(env, runId, { - sortOrder: 'asc', - limit: 1000, - withData: true, - }) - ); - if (error) { - setError(error); - return; - } - try { - const allEvents = (result.data as unknown as Event[]).map( - hydrateResourceIO - ); - const waitEvents = await Promise.all( - allEvents.filter((e) => e.correlationId === resourceId).map(hydrate) - ); - const data = waitEventsToWaitEntity(waitEvents); - if (data === null) { - setError( - new Error( - `Failed to load ${resource} details: missing required event data` - ) - ); - return; - } - setData(data as unknown as Hook | Event); - } catch (hydrateError) { - setError( - hydrateError instanceof Error - ? hydrateError - : new Error(String(hydrateError)) - ); - } - } finally { - setLoading(false); - } - return; - } - // Fetch resource with full data try { - const { data: resourceData } = await fetchResourceWithCorrelationId( - env, - resource, - resourceId, - { runId } - ); - setData(await hydrate(resourceData)); + // fetchSpanDetailData skips hooks (the trace detail panel renders them + // from span data), but this hook's consumers want them fetched. + const result = + resource === 'hook' + ? await hydrate( + await unwrapOrThrow(fetchHook(env, resourceId, 'all')) + ) + : await fetchSpanDetailData( + env, + { resource, resourceId, runId }, + encryptionKey + ); + setData(result); } catch (error: unknown) { - if (error instanceof Error) { - setError(error); - } else { - setError(new Error(String(error))); - } - return; + setError(error instanceof Error ? error : new Error(String(error))); } finally { setLoading(false); } - }, [env, resource, resourceId, runId, enabled, hydrate]); + }, [env, resource, resourceId, runId, enabled, hydrate, encryptionKey]); // Initial load useEffect(() => { diff --git a/packages/web/app/lib/workflow-api-client.ts b/packages/web/app/lib/workflow-api-client.ts index 478531b550..cef1e636c8 100644 --- a/packages/web/app/lib/workflow-api-client.ts +++ b/packages/web/app/lib/workflow-api-client.ts @@ -20,7 +20,10 @@ export { useWorkflowHooks, useWorkflowRuns, } from './client/hooks/use-paginated-list'; -export { useWorkflowResourceData } from './client/hooks/use-resource-data'; +export { + fetchSpanDetailData, + useWorkflowResourceData, +} from './client/hooks/use-resource-data'; export { useWorkflowTraceViewerData } from './client/hooks/use-trace-viewer'; export { useWorkflowStreams } from './client/hooks/use-workflow-streams'; export {