Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-span-detail-flicker-state-machine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/web-shared': patch
'@workflow/web': patch
---

Fix the run trace detail panel flickering its Input/Output sections when navigating between spans. Span detail is now driven by a single selection-derived state machine (`useSelectedSpanDetail`) whose loading state stays in phase with the selected span, replacing the fetch flag that lagged selection by a few renders.
12 changes: 2 additions & 10 deletions packages/web-shared/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,20 +15,12 @@ It comes with pre-styled UI components that accept data + callbacks:
```tsx
import { WorkflowTraceViewer } from '@workflow/web-shared';

export default function MyRunDetailView({
run,
steps,
hooks,
events,
onSpanSelect,
}) {
export default function MyRunDetailView({ run, events, fetchSpanDetail }) {
return (
<WorkflowTraceViewer
run={run}
steps={steps}
hooks={hooks}
events={events}
onSpanSelect={onSpanSelect}
fetchSpanDetail={fetchSpanDetail}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/web-shared/src/components/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,18 +13,24 @@ 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 {
DetailResource,
FetchSpanDetail,
} from './sidebar/use-selected-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,
Expand All@@ -38,5 +44,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';
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,14 +39,14 @@ import {
TooltipTrigger,
} from '../ui/tooltip';
import EventList from './components/event-list';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { SplitPane } from './components/split-pane';
import {
TIMELINE_PADDING_PX,
Timeline,
TimelineHeader,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
import type { TraceWithMeta } from './types';
Expand DownExpand Up@@ -822,10 +822,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}
Expand Down
19 changes: 5 additions & 14 deletions packages/web-shared/src/components/run-trace-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
'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-selected-span-detail';
import { WorkflowTraceViewer } from './workflow-trace-view';

interface RunTraceViewProps {
run: WorkflowRun;
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
Expand All@@ -25,7 +23,6 @@ interface RunTraceViewProps {
onCancelRun?: (runId: string) => Promise<void>;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onSpanSelect?: (info: SpanSelectionInfo) => void;
onLoadMoreSpans?: () => void | Promise<void>;
hasMoreSpans?: boolean;
isLoadingMoreSpans?: boolean;
Expand All@@ -36,15 +33,12 @@ export function RunTraceView({
events,
isLoading,
error,
spanDetailData,
spanDetailLoading,
spanDetailError,
fetchSpanDetail,
onWakeUpSleep,
onResolveHook,
onCancelRun,
onStreamClick,
onRunClick,
onSpanSelect,
onLoadMoreSpans,
hasMoreSpans,
isLoadingMoreSpans,
Expand All@@ -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}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,10 +768,8 @@ export const AttributePanel = ({

if (resource === 'sleep') return present;

// During loading, ensure sections appear so their skeletons render
// in the correct position (above the events section).
const loadingDefaults = ['input', 'output'];
for (const key of loadingDefaults) {
const loadingPlaceholders = ['input', 'output'];
for (const key of loadingPlaceholders) {
if (!present.includes(key)) {
present.push(key);
}
Expand Down
111 changes: 17 additions & 94 deletions packages/web-shared/src/components/sidebar/entity-detail-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
'use client';

import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { Event, Hook, 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,
spanDetailMatchesSelection,
} from './span-detail-merge';

// Type guards for runtime validation of span attribute data
function isStep(data: unknown): data is Step {
return data !== null && typeof data === 'object' && 'stepId' in data;
}

function isWorkflowRun(data: unknown): data is WorkflowRun {
return data !== null && typeof data === 'object' && 'runId' in data;
}
type FetchSpanDetail,
useSelectedSpanDetail,
} from './use-selected-span-detail';

// Type guard for runtime validation of span attribute data
function isHook(data: unknown): data is Hook {
return data !== null && typeof data === 'object' && 'hookId' in data;
}
Expand DownExpand Up@@ -61,10 +53,7 @@ export function EntityDetailPanel({
run,
onStreamClick,
onRunClick,
spanDetailData,
spanDetailError,
spanDetailLoading,
onSpanSelect,
fetchSpanDetail,
onWakeUpSleep,
onLoadEventData,
onResolveHook,
Expand All@@ -78,14 +67,7 @@ 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;
fetchSpanDetail: FetchSpanDetail;
/** Callback to wake up a pending sleep call. */
onWakeUpSleep?: (
runId: string,
Expand DownExpand Up@@ -128,53 +110,15 @@ 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(() => {
if (!selectedSpan) {
return { resource: undefined, resourceId: undefined, runId: undefined };
}

const res = selectedSpan.resource;
if (res === 'step' && isStep(data)) {
return { resource: 'step', resourceId: data.stepId, runId: data.runId };
}
if (res === 'run' && isWorkflowRun(data)) {
return { resource: 'run', resourceId: data.runId, runId: undefined };
}
if (res === 'hook' && isHook(data)) {
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
}
if (res === 'sleep') {
const waitData = data as { runId?: string } | undefined;
return {
resource: 'sleep',
resourceId: selectedSpan.spanId,
runId: waitData?.runId,
};
}
return { resource: undefined, resourceId: undefined, runId: undefined };
}, [selectedSpan, data]);

// 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 {
status,
resource,
resourceId,
displayData,
detail: matchedSpanDetailData,
error,
} = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
const loading = status === 'loading';

// Check if this sleep is still pending and can be woken up
const canWakeUp = useMemo(() => {
Expand DownExpand Up@@ -216,17 +160,6 @@ export function EntityDetailPanel({
resolvedHookIds,
]);

const error = spanDetailError ?? undefined;
const loading = spanDetailLoading ?? false;

const matchedSpanDetailData = useMemo(
() =>
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
? spanDetailData
: null,
[spanDetailData, resource, resourceId]
);

// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
const hookToken = useMemo(() => {
if (resource !== 'hook' || !resourceId) return undefined;
Expand DownExpand Up@@ -325,16 +258,6 @@ export function EntityDetailPanel({
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
);

const displayData = useMemo(
() =>
mergeSpanDetail(data, matchedSpanDetailData) as
| WorkflowRun
| Step
| Hook
| Event,
[data, matchedSpanDetailData]
);

const moduleSpecifier = useMemo(() => {
const displayRecord = displayData as Record<string, unknown>;
const displayStepName = displayRecord.stepName;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
'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-selected-span-detail';

export interface SidebarDataContextValue {
run: WorkflowRun;
events: Event[];
spanDetailData: WorkflowRun | Step | Hook | Event | null;
spanDetailError?: Error | null;
spanDetailLoading?: boolean;
onSpanSelect: (info: SpanSelectionInfo) => void;
fetchSpanDetail: FetchSpanDetail;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onWakeUpSleep?: (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Fix trace detail panel Input/Output flicker (selection-driven state machine) by mitul-s · Pull Request #2637 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-span-detail-flicker-state-machine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/web-shared': patch
'@workflow/web': patch
---

Fix the run trace detail panel flickering its Input/Output sections when navigating between spans. Span detail is now driven by a single selection-derived state machine (`useSelectedSpanDetail`) whose loading state stays in phase with the selected span, replacing the fetch flag that lagged selection by a few renders.
12 changes: 2 additions & 10 deletions packages/web-shared/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,20 +15,12 @@ It comes with pre-styled UI components that accept data + callbacks:
```tsx
import { WorkflowTraceViewer } from '@workflow/web-shared';

export default function MyRunDetailView({
run,
steps,
hooks,
events,
onSpanSelect,
}) {
export default function MyRunDetailView({ run, events, fetchSpanDetail }) {
return (
<WorkflowTraceViewer
run={run}
steps={steps}
hooks={hooks}
events={events}
onSpanSelect={onSpanSelect}
fetchSpanDetail={fetchSpanDetail}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/web-shared/src/components/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,18 +13,24 @@ 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 {
DetailResource,
FetchSpanDetail,
} from './sidebar/use-selected-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,
Expand All@@ -38,5 +44,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';
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,14 +39,14 @@ import {
TooltipTrigger,
} from '../ui/tooltip';
import EventList from './components/event-list';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { SplitPane } from './components/split-pane';
import {
TIMELINE_PADDING_PX,
Timeline,
TimelineHeader,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
import type { TraceWithMeta } from './types';
Expand DownExpand Up@@ -822,10 +822,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}
Expand Down
19 changes: 5 additions & 14 deletions packages/web-shared/src/components/run-trace-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
'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-selected-span-detail';
import { WorkflowTraceViewer } from './workflow-trace-view';

interface RunTraceViewProps {
run: WorkflowRun;
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
Expand All@@ -25,7 +23,6 @@ interface RunTraceViewProps {
onCancelRun?: (runId: string) => Promise<void>;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onSpanSelect?: (info: SpanSelectionInfo) => void;
onLoadMoreSpans?: () => void | Promise<void>;
hasMoreSpans?: boolean;
isLoadingMoreSpans?: boolean;
Expand All@@ -36,15 +33,12 @@ export function RunTraceView({
events,
isLoading,
error,
spanDetailData,
spanDetailLoading,
spanDetailError,
fetchSpanDetail,
onWakeUpSleep,
onResolveHook,
onCancelRun,
onStreamClick,
onRunClick,
onSpanSelect,
onLoadMoreSpans,
hasMoreSpans,
isLoadingMoreSpans,
Expand All@@ -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}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,10 +768,8 @@ export const AttributePanel = ({

if (resource === 'sleep') return present;

// During loading, ensure sections appear so their skeletons render
// in the correct position (above the events section).
const loadingDefaults = ['input', 'output'];
for (const key of loadingDefaults) {
const loadingPlaceholders = ['input', 'output'];
for (const key of loadingPlaceholders) {
if (!present.includes(key)) {
present.push(key);
}
Expand Down
111 changes: 17 additions & 94 deletions packages/web-shared/src/components/sidebar/entity-detail-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
'use client';

import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { Event, Hook, 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,
spanDetailMatchesSelection,
} from './span-detail-merge';

// Type guards for runtime validation of span attribute data
function isStep(data: unknown): data is Step {
return data !== null && typeof data === 'object' && 'stepId' in data;
}

function isWorkflowRun(data: unknown): data is WorkflowRun {
return data !== null && typeof data === 'object' && 'runId' in data;
}
type FetchSpanDetail,
useSelectedSpanDetail,
} from './use-selected-span-detail';

// Type guard for runtime validation of span attribute data
function isHook(data: unknown): data is Hook {
return data !== null && typeof data === 'object' && 'hookId' in data;
}
Expand DownExpand Up@@ -61,10 +53,7 @@ export function EntityDetailPanel({
run,
onStreamClick,
onRunClick,
spanDetailData,
spanDetailError,
spanDetailLoading,
onSpanSelect,
fetchSpanDetail,
onWakeUpSleep,
onLoadEventData,
onResolveHook,
Expand All@@ -78,14 +67,7 @@ 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;
fetchSpanDetail: FetchSpanDetail;
/** Callback to wake up a pending sleep call. */
onWakeUpSleep?: (
runId: string,
Expand DownExpand Up@@ -128,53 +110,15 @@ 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(() => {
if (!selectedSpan) {
return { resource: undefined, resourceId: undefined, runId: undefined };
}

const res = selectedSpan.resource;
if (res === 'step' && isStep(data)) {
return { resource: 'step', resourceId: data.stepId, runId: data.runId };
}
if (res === 'run' && isWorkflowRun(data)) {
return { resource: 'run', resourceId: data.runId, runId: undefined };
}
if (res === 'hook' && isHook(data)) {
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
}
if (res === 'sleep') {
const waitData = data as { runId?: string } | undefined;
return {
resource: 'sleep',
resourceId: selectedSpan.spanId,
runId: waitData?.runId,
};
}
return { resource: undefined, resourceId: undefined, runId: undefined };
}, [selectedSpan, data]);

// 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 {
status,
resource,
resourceId,
displayData,
detail: matchedSpanDetailData,
error,
} = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
const loading = status === 'loading';

// Check if this sleep is still pending and can be woken up
const canWakeUp = useMemo(() => {
Expand DownExpand Up@@ -216,17 +160,6 @@ export function EntityDetailPanel({
resolvedHookIds,
]);

const error = spanDetailError ?? undefined;
const loading = spanDetailLoading ?? false;

const matchedSpanDetailData = useMemo(
() =>
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
? spanDetailData
: null,
[spanDetailData, resource, resourceId]
);

// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
const hookToken = useMemo(() => {
if (resource !== 'hook' || !resourceId) return undefined;
Expand DownExpand Up@@ -325,16 +258,6 @@ export function EntityDetailPanel({
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
);

const displayData = useMemo(
() =>
mergeSpanDetail(data, matchedSpanDetailData) as
| WorkflowRun
| Step
| Hook
| Event,
[data, matchedSpanDetailData]
);

const moduleSpecifier = useMemo(() => {
const displayRecord = displayData as Record<string, unknown>;
const displayStepName = displayRecord.stepName;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
'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-selected-span-detail';

export interface SidebarDataContextValue {
run: WorkflowRun;
events: Event[];
spanDetailData: WorkflowRun | Step | Hook | Event | null;
spanDetailError?: Error | null;
spanDetailLoading?: boolean;
onSpanSelect: (info: SpanSelectionInfo) => void;
fetchSpanDetail: FetchSpanDetail;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onWakeUpSleep?: (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix trace detail panel Input/Output flicker (selection-driven state machine) by mitul-s · Pull Request #2637 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-span-detail-flicker-state-machine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/web-shared': patch
'@workflow/web': patch
---

Fix the run trace detail panel flickering its Input/Output sections when navigating between spans. Span detail is now driven by a single selection-derived state machine (`useSelectedSpanDetail`) whose loading state stays in phase with the selected span, replacing the fetch flag that lagged selection by a few renders.
12 changes: 2 additions & 10 deletions packages/web-shared/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,20 +15,12 @@ It comes with pre-styled UI components that accept data + callbacks:
```tsx
import { WorkflowTraceViewer } from '@workflow/web-shared';

export default function MyRunDetailView({
run,
steps,
hooks,
events,
onSpanSelect,
}) {
export default function MyRunDetailView({ run, events, fetchSpanDetail }) {
return (
<WorkflowTraceViewer
run={run}
steps={steps}
hooks={hooks}
events={events}
onSpanSelect={onSpanSelect}
fetchSpanDetail={fetchSpanDetail}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/web-shared/src/components/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,18 +13,24 @@ 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 {
DetailResource,
FetchSpanDetail,
} from './sidebar/use-selected-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,
Expand All@@ -38,5 +44,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';
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,14 +39,14 @@ import {
TooltipTrigger,
} from '../ui/tooltip';
import EventList from './components/event-list';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { SplitPane } from './components/split-pane';
import {
TIMELINE_PADDING_PX,
Timeline,
TimelineHeader,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
import type { TraceWithMeta } from './types';
Expand DownExpand Up@@ -822,10 +822,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}
Expand Down
19 changes: 5 additions & 14 deletions packages/web-shared/src/components/run-trace-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
'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-selected-span-detail';
import { WorkflowTraceViewer } from './workflow-trace-view';

interface RunTraceViewProps {
run: WorkflowRun;
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
Expand All@@ -25,7 +23,6 @@ interface RunTraceViewProps {
onCancelRun?: (runId: string) => Promise<void>;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onSpanSelect?: (info: SpanSelectionInfo) => void;
onLoadMoreSpans?: () => void | Promise<void>;
hasMoreSpans?: boolean;
isLoadingMoreSpans?: boolean;
Expand All@@ -36,15 +33,12 @@ export function RunTraceView({
events,
isLoading,
error,
spanDetailData,
spanDetailLoading,
spanDetailError,
fetchSpanDetail,
onWakeUpSleep,
onResolveHook,
onCancelRun,
onStreamClick,
onRunClick,
onSpanSelect,
onLoadMoreSpans,
hasMoreSpans,
isLoadingMoreSpans,
Expand All@@ -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}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,10 +768,8 @@ export const AttributePanel = ({

if (resource === 'sleep') return present;

// During loading, ensure sections appear so their skeletons render
// in the correct position (above the events section).
const loadingDefaults = ['input', 'output'];
for (const key of loadingDefaults) {
const loadingPlaceholders = ['input', 'output'];
for (const key of loadingPlaceholders) {
if (!present.includes(key)) {
present.push(key);
}
Expand Down
111 changes: 17 additions & 94 deletions packages/web-shared/src/components/sidebar/entity-detail-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
'use client';

import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { Event, Hook, 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,
spanDetailMatchesSelection,
} from './span-detail-merge';

// Type guards for runtime validation of span attribute data
function isStep(data: unknown): data is Step {
return data !== null && typeof data === 'object' && 'stepId' in data;
}

function isWorkflowRun(data: unknown): data is WorkflowRun {
return data !== null && typeof data === 'object' && 'runId' in data;
}
type FetchSpanDetail,
useSelectedSpanDetail,
} from './use-selected-span-detail';

// Type guard for runtime validation of span attribute data
function isHook(data: unknown): data is Hook {
return data !== null && typeof data === 'object' && 'hookId' in data;
}
Expand DownExpand Up@@ -61,10 +53,7 @@ export function EntityDetailPanel({
run,
onStreamClick,
onRunClick,
spanDetailData,
spanDetailError,
spanDetailLoading,
onSpanSelect,
fetchSpanDetail,
onWakeUpSleep,
onLoadEventData,
onResolveHook,
Expand All@@ -78,14 +67,7 @@ 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;
fetchSpanDetail: FetchSpanDetail;
/** Callback to wake up a pending sleep call. */
onWakeUpSleep?: (
runId: string,
Expand DownExpand Up@@ -128,53 +110,15 @@ 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(() => {
if (!selectedSpan) {
return { resource: undefined, resourceId: undefined, runId: undefined };
}

const res = selectedSpan.resource;
if (res === 'step' && isStep(data)) {
return { resource: 'step', resourceId: data.stepId, runId: data.runId };
}
if (res === 'run' && isWorkflowRun(data)) {
return { resource: 'run', resourceId: data.runId, runId: undefined };
}
if (res === 'hook' && isHook(data)) {
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
}
if (res === 'sleep') {
const waitData = data as { runId?: string } | undefined;
return {
resource: 'sleep',
resourceId: selectedSpan.spanId,
runId: waitData?.runId,
};
}
return { resource: undefined, resourceId: undefined, runId: undefined };
}, [selectedSpan, data]);

// 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 {
status,
resource,
resourceId,
displayData,
detail: matchedSpanDetailData,
error,
} = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
const loading = status === 'loading';

// Check if this sleep is still pending and can be woken up
const canWakeUp = useMemo(() => {
Expand DownExpand Up@@ -216,17 +160,6 @@ export function EntityDetailPanel({
resolvedHookIds,
]);

const error = spanDetailError ?? undefined;
const loading = spanDetailLoading ?? false;

const matchedSpanDetailData = useMemo(
() =>
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
? spanDetailData
: null,
[spanDetailData, resource, resourceId]
);

// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
const hookToken = useMemo(() => {
if (resource !== 'hook' || !resourceId) return undefined;
Expand DownExpand Up@@ -325,16 +258,6 @@ export function EntityDetailPanel({
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
);

const displayData = useMemo(
() =>
mergeSpanDetail(data, matchedSpanDetailData) as
| WorkflowRun
| Step
| Hook
| Event,
[data, matchedSpanDetailData]
);

const moduleSpecifier = useMemo(() => {
const displayRecord = displayData as Record<string, unknown>;
const displayStepName = displayRecord.stepName;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
'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-selected-span-detail';

export interface SidebarDataContextValue {
run: WorkflowRun;
events: Event[];
spanDetailData: WorkflowRun | Step | Hook | Event | null;
spanDetailError?: Error | null;
spanDetailLoading?: boolean;
onSpanSelect: (info: SpanSelectionInfo) => void;
fetchSpanDetail: FetchSpanDetail;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onWakeUpSleep?: (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix trace detail panel Input/Output flicker (selection-driven state machine) by mitul-s · Pull Request #2637 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-span-detail-flicker-state-machine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/web-shared': patch
'@workflow/web': patch
---

Fix the run trace detail panel flickering its Input/Output sections when navigating between spans. Span detail is now driven by a single selection-derived state machine (`useSelectedSpanDetail`) whose loading state stays in phase with the selected span, replacing the fetch flag that lagged selection by a few renders.
12 changes: 2 additions & 10 deletions packages/web-shared/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,20 +15,12 @@ It comes with pre-styled UI components that accept data + callbacks:
```tsx
import { WorkflowTraceViewer } from '@workflow/web-shared';

export default function MyRunDetailView({
run,
steps,
hooks,
events,
onSpanSelect,
}) {
export default function MyRunDetailView({ run, events, fetchSpanDetail }) {
return (
<WorkflowTraceViewer
run={run}
steps={steps}
hooks={hooks}
events={events}
onSpanSelect={onSpanSelect}
fetchSpanDetail={fetchSpanDetail}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/web-shared/src/components/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,18 +13,24 @@ 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 {
DetailResource,
FetchSpanDetail,
} from './sidebar/use-selected-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,
Expand All@@ -38,5 +44,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';
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,14 +39,14 @@ import {
TooltipTrigger,
} from '../ui/tooltip';
import EventList from './components/event-list';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { SplitPane } from './components/split-pane';
import {
TIMELINE_PADDING_PX,
Timeline,
TimelineHeader,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
import type { TraceWithMeta } from './types';
Expand DownExpand Up@@ -822,10 +822,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}
Expand Down
19 changes: 5 additions & 14 deletions packages/web-shared/src/components/run-trace-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
'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-selected-span-detail';
import { WorkflowTraceViewer } from './workflow-trace-view';

interface RunTraceViewProps {
run: WorkflowRun;
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
Expand All@@ -25,7 +23,6 @@ interface RunTraceViewProps {
onCancelRun?: (runId: string) => Promise<void>;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onSpanSelect?: (info: SpanSelectionInfo) => void;
onLoadMoreSpans?: () => void | Promise<void>;
hasMoreSpans?: boolean;
isLoadingMoreSpans?: boolean;
Expand All@@ -36,15 +33,12 @@ export function RunTraceView({
events,
isLoading,
error,
spanDetailData,
spanDetailLoading,
spanDetailError,
fetchSpanDetail,
onWakeUpSleep,
onResolveHook,
onCancelRun,
onStreamClick,
onRunClick,
onSpanSelect,
onLoadMoreSpans,
hasMoreSpans,
isLoadingMoreSpans,
Expand All@@ -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}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,10 +768,8 @@ export const AttributePanel = ({

if (resource === 'sleep') return present;

// During loading, ensure sections appear so their skeletons render
// in the correct position (above the events section).
const loadingDefaults = ['input', 'output'];
for (const key of loadingDefaults) {
const loadingPlaceholders = ['input', 'output'];
for (const key of loadingPlaceholders) {
if (!present.includes(key)) {
present.push(key);
}
Expand Down
111 changes: 17 additions & 94 deletions packages/web-shared/src/components/sidebar/entity-detail-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
'use client';

import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { Event, Hook, 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,
spanDetailMatchesSelection,
} from './span-detail-merge';

// Type guards for runtime validation of span attribute data
function isStep(data: unknown): data is Step {
return data !== null && typeof data === 'object' && 'stepId' in data;
}

function isWorkflowRun(data: unknown): data is WorkflowRun {
return data !== null && typeof data === 'object' && 'runId' in data;
}
type FetchSpanDetail,
useSelectedSpanDetail,
} from './use-selected-span-detail';

// Type guard for runtime validation of span attribute data
function isHook(data: unknown): data is Hook {
return data !== null && typeof data === 'object' && 'hookId' in data;
}
Expand DownExpand Up@@ -61,10 +53,7 @@ export function EntityDetailPanel({
run,
onStreamClick,
onRunClick,
spanDetailData,
spanDetailError,
spanDetailLoading,
onSpanSelect,
fetchSpanDetail,
onWakeUpSleep,
onLoadEventData,
onResolveHook,
Expand All@@ -78,14 +67,7 @@ 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;
fetchSpanDetail: FetchSpanDetail;
/** Callback to wake up a pending sleep call. */
onWakeUpSleep?: (
runId: string,
Expand DownExpand Up@@ -128,53 +110,15 @@ 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(() => {
if (!selectedSpan) {
return { resource: undefined, resourceId: undefined, runId: undefined };
}

const res = selectedSpan.resource;
if (res === 'step' && isStep(data)) {
return { resource: 'step', resourceId: data.stepId, runId: data.runId };
}
if (res === 'run' && isWorkflowRun(data)) {
return { resource: 'run', resourceId: data.runId, runId: undefined };
}
if (res === 'hook' && isHook(data)) {
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
}
if (res === 'sleep') {
const waitData = data as { runId?: string } | undefined;
return {
resource: 'sleep',
resourceId: selectedSpan.spanId,
runId: waitData?.runId,
};
}
return { resource: undefined, resourceId: undefined, runId: undefined };
}, [selectedSpan, data]);

// 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 {
status,
resource,
resourceId,
displayData,
detail: matchedSpanDetailData,
error,
} = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
const loading = status === 'loading';

// Check if this sleep is still pending and can be woken up
const canWakeUp = useMemo(() => {
Expand DownExpand Up@@ -216,17 +160,6 @@ export function EntityDetailPanel({
resolvedHookIds,
]);

const error = spanDetailError ?? undefined;
const loading = spanDetailLoading ?? false;

const matchedSpanDetailData = useMemo(
() =>
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
? spanDetailData
: null,
[spanDetailData, resource, resourceId]
);

// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
const hookToken = useMemo(() => {
if (resource !== 'hook' || !resourceId) return undefined;
Expand DownExpand Up@@ -325,16 +258,6 @@ export function EntityDetailPanel({
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
);

const displayData = useMemo(
() =>
mergeSpanDetail(data, matchedSpanDetailData) as
| WorkflowRun
| Step
| Hook
| Event,
[data, matchedSpanDetailData]
);

const moduleSpecifier = useMemo(() => {
const displayRecord = displayData as Record<string, unknown>;
const displayStepName = displayRecord.stepName;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
'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-selected-span-detail';

export interface SidebarDataContextValue {
run: WorkflowRun;
events: Event[];
spanDetailData: WorkflowRun | Step | Hook | Event | null;
spanDetailError?: Error | null;
spanDetailLoading?: boolean;
onSpanSelect: (info: SpanSelectionInfo) => void;
fetchSpanDetail: FetchSpanDetail;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onWakeUpSleep?: (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Fix trace detail panel Input/Output flicker (selection-driven state machine) by mitul-s · Pull Request #2637 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-span-detail-flicker-state-machine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/web-shared': patch
'@workflow/web': patch
---

Fix the run trace detail panel flickering its Input/Output sections when navigating between spans. Span detail is now driven by a single selection-derived state machine (`useSelectedSpanDetail`) whose loading state stays in phase with the selected span, replacing the fetch flag that lagged selection by a few renders.
12 changes: 2 additions & 10 deletions packages/web-shared/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,20 +15,12 @@ It comes with pre-styled UI components that accept data + callbacks:
```tsx
import { WorkflowTraceViewer } from '@workflow/web-shared';

export default function MyRunDetailView({
run,
steps,
hooks,
events,
onSpanSelect,
}) {
export default function MyRunDetailView({ run, events, fetchSpanDetail }) {
return (
<WorkflowTraceViewer
run={run}
steps={steps}
hooks={hooks}
events={events}
onSpanSelect={onSpanSelect}
fetchSpanDetail={fetchSpanDetail}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/web-shared/src/components/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,18 +13,24 @@ 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 {
DetailResource,
FetchSpanDetail,
} from './sidebar/use-selected-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,
Expand All@@ -38,5 +44,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';
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,14 +39,14 @@ import {
TooltipTrigger,
} from '../ui/tooltip';
import EventList from './components/event-list';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { SplitPane } from './components/split-pane';
import {
TIMELINE_PADDING_PX,
Timeline,
TimelineHeader,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
import type { TraceWithMeta } from './types';
Expand DownExpand Up@@ -822,10 +822,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}
Expand Down
19 changes: 5 additions & 14 deletions packages/web-shared/src/components/run-trace-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
'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-selected-span-detail';
import { WorkflowTraceViewer } from './workflow-trace-view';

interface RunTraceViewProps {
run: WorkflowRun;
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
Expand All@@ -25,7 +23,6 @@ interface RunTraceViewProps {
onCancelRun?: (runId: string) => Promise<void>;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onSpanSelect?: (info: SpanSelectionInfo) => void;
onLoadMoreSpans?: () => void | Promise<void>;
hasMoreSpans?: boolean;
isLoadingMoreSpans?: boolean;
Expand All@@ -36,15 +33,12 @@ export function RunTraceView({
events,
isLoading,
error,
spanDetailData,
spanDetailLoading,
spanDetailError,
fetchSpanDetail,
onWakeUpSleep,
onResolveHook,
onCancelRun,
onStreamClick,
onRunClick,
onSpanSelect,
onLoadMoreSpans,
hasMoreSpans,
isLoadingMoreSpans,
Expand All@@ -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}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,10 +768,8 @@ export const AttributePanel = ({

if (resource === 'sleep') return present;

// During loading, ensure sections appear so their skeletons render
// in the correct position (above the events section).
const loadingDefaults = ['input', 'output'];
for (const key of loadingDefaults) {
const loadingPlaceholders = ['input', 'output'];
for (const key of loadingPlaceholders) {
if (!present.includes(key)) {
present.push(key);
}
Expand Down
111 changes: 17 additions & 94 deletions packages/web-shared/src/components/sidebar/entity-detail-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
'use client';

import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { Event, Hook, 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,
spanDetailMatchesSelection,
} from './span-detail-merge';

// Type guards for runtime validation of span attribute data
function isStep(data: unknown): data is Step {
return data !== null && typeof data === 'object' && 'stepId' in data;
}

function isWorkflowRun(data: unknown): data is WorkflowRun {
return data !== null && typeof data === 'object' && 'runId' in data;
}
type FetchSpanDetail,
useSelectedSpanDetail,
} from './use-selected-span-detail';

// Type guard for runtime validation of span attribute data
function isHook(data: unknown): data is Hook {
return data !== null && typeof data === 'object' && 'hookId' in data;
}
Expand DownExpand Up@@ -61,10 +53,7 @@ export function EntityDetailPanel({
run,
onStreamClick,
onRunClick,
spanDetailData,
spanDetailError,
spanDetailLoading,
onSpanSelect,
fetchSpanDetail,
onWakeUpSleep,
onLoadEventData,
onResolveHook,
Expand All@@ -78,14 +67,7 @@ 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;
fetchSpanDetail: FetchSpanDetail;
/** Callback to wake up a pending sleep call. */
onWakeUpSleep?: (
runId: string,
Expand DownExpand Up@@ -128,53 +110,15 @@ 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(() => {
if (!selectedSpan) {
return { resource: undefined, resourceId: undefined, runId: undefined };
}

const res = selectedSpan.resource;
if (res === 'step' && isStep(data)) {
return { resource: 'step', resourceId: data.stepId, runId: data.runId };
}
if (res === 'run' && isWorkflowRun(data)) {
return { resource: 'run', resourceId: data.runId, runId: undefined };
}
if (res === 'hook' && isHook(data)) {
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
}
if (res === 'sleep') {
const waitData = data as { runId?: string } | undefined;
return {
resource: 'sleep',
resourceId: selectedSpan.spanId,
runId: waitData?.runId,
};
}
return { resource: undefined, resourceId: undefined, runId: undefined };
}, [selectedSpan, data]);

// 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 {
status,
resource,
resourceId,
displayData,
detail: matchedSpanDetailData,
error,
} = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
const loading = status === 'loading';

// Check if this sleep is still pending and can be woken up
const canWakeUp = useMemo(() => {
Expand DownExpand Up@@ -216,17 +160,6 @@ export function EntityDetailPanel({
resolvedHookIds,
]);

const error = spanDetailError ?? undefined;
const loading = spanDetailLoading ?? false;

const matchedSpanDetailData = useMemo(
() =>
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
? spanDetailData
: null,
[spanDetailData, resource, resourceId]
);

// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
const hookToken = useMemo(() => {
if (resource !== 'hook' || !resourceId) return undefined;
Expand DownExpand Up@@ -325,16 +258,6 @@ export function EntityDetailPanel({
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
);

const displayData = useMemo(
() =>
mergeSpanDetail(data, matchedSpanDetailData) as
| WorkflowRun
| Step
| Hook
| Event,
[data, matchedSpanDetailData]
);

const moduleSpecifier = useMemo(() => {
const displayRecord = displayData as Record<string, unknown>;
const displayStepName = displayRecord.stepName;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
'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-selected-span-detail';

export interface SidebarDataContextValue {
run: WorkflowRun;
events: Event[];
spanDetailData: WorkflowRun | Step | Hook | Event | null;
spanDetailError?: Error | null;
spanDetailLoading?: boolean;
onSpanSelect: (info: SpanSelectionInfo) => void;
fetchSpanDetail: FetchSpanDetail;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onWakeUpSleep?: (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix trace detail panel Input/Output flicker (selection-driven state machine) by mitul-s · Pull Request #2637 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-span-detail-flicker-state-machine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/web-shared': patch
'@workflow/web': patch
---

Fix the run trace detail panel flickering its Input/Output sections when navigating between spans. Span detail is now driven by a single selection-derived state machine (`useSelectedSpanDetail`) whose loading state stays in phase with the selected span, replacing the fetch flag that lagged selection by a few renders.
12 changes: 2 additions & 10 deletions packages/web-shared/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,20 +15,12 @@ It comes with pre-styled UI components that accept data + callbacks:
```tsx
import { WorkflowTraceViewer } from '@workflow/web-shared';

export default function MyRunDetailView({
run,
steps,
hooks,
events,
onSpanSelect,
}) {
export default function MyRunDetailView({ run, events, fetchSpanDetail }) {
return (
<WorkflowTraceViewer
run={run}
steps={steps}
hooks={hooks}
events={events}
onSpanSelect={onSpanSelect}
fetchSpanDetail={fetchSpanDetail}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/web-shared/src/components/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,18 +13,24 @@ 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 {
DetailResource,
FetchSpanDetail,
} from './sidebar/use-selected-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,
Expand All@@ -38,5 +44,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';
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,14 +39,14 @@ import {
TooltipTrigger,
} from '../ui/tooltip';
import EventList from './components/event-list';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { SplitPane } from './components/split-pane';
import {
TIMELINE_PADDING_PX,
Timeline,
TimelineHeader,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
import type { TraceWithMeta } from './types';
Expand DownExpand Up@@ -822,10 +822,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}
Expand Down
19 changes: 5 additions & 14 deletions packages/web-shared/src/components/run-trace-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
'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-selected-span-detail';
import { WorkflowTraceViewer } from './workflow-trace-view';

interface RunTraceViewProps {
run: WorkflowRun;
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
Expand All@@ -25,7 +23,6 @@ interface RunTraceViewProps {
onCancelRun?: (runId: string) => Promise<void>;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onSpanSelect?: (info: SpanSelectionInfo) => void;
onLoadMoreSpans?: () => void | Promise<void>;
hasMoreSpans?: boolean;
isLoadingMoreSpans?: boolean;
Expand All@@ -36,15 +33,12 @@ export function RunTraceView({
events,
isLoading,
error,
spanDetailData,
spanDetailLoading,
spanDetailError,
fetchSpanDetail,
onWakeUpSleep,
onResolveHook,
onCancelRun,
onStreamClick,
onRunClick,
onSpanSelect,
onLoadMoreSpans,
hasMoreSpans,
isLoadingMoreSpans,
Expand All@@ -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}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,10 +768,8 @@ export const AttributePanel = ({

if (resource === 'sleep') return present;

// During loading, ensure sections appear so their skeletons render
// in the correct position (above the events section).
const loadingDefaults = ['input', 'output'];
for (const key of loadingDefaults) {
const loadingPlaceholders = ['input', 'output'];
for (const key of loadingPlaceholders) {
if (!present.includes(key)) {
present.push(key);
}
Expand Down
111 changes: 17 additions & 94 deletions packages/web-shared/src/components/sidebar/entity-detail-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
'use client';

import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { Event, Hook, 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,
spanDetailMatchesSelection,
} from './span-detail-merge';

// Type guards for runtime validation of span attribute data
function isStep(data: unknown): data is Step {
return data !== null && typeof data === 'object' && 'stepId' in data;
}

function isWorkflowRun(data: unknown): data is WorkflowRun {
return data !== null && typeof data === 'object' && 'runId' in data;
}
type FetchSpanDetail,
useSelectedSpanDetail,
} from './use-selected-span-detail';

// Type guard for runtime validation of span attribute data
function isHook(data: unknown): data is Hook {
return data !== null && typeof data === 'object' && 'hookId' in data;
}
Expand DownExpand Up@@ -61,10 +53,7 @@ export function EntityDetailPanel({
run,
onStreamClick,
onRunClick,
spanDetailData,
spanDetailError,
spanDetailLoading,
onSpanSelect,
fetchSpanDetail,
onWakeUpSleep,
onLoadEventData,
onResolveHook,
Expand All@@ -78,14 +67,7 @@ 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;
fetchSpanDetail: FetchSpanDetail;
/** Callback to wake up a pending sleep call. */
onWakeUpSleep?: (
runId: string,
Expand DownExpand Up@@ -128,53 +110,15 @@ 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(() => {
if (!selectedSpan) {
return { resource: undefined, resourceId: undefined, runId: undefined };
}

const res = selectedSpan.resource;
if (res === 'step' && isStep(data)) {
return { resource: 'step', resourceId: data.stepId, runId: data.runId };
}
if (res === 'run' && isWorkflowRun(data)) {
return { resource: 'run', resourceId: data.runId, runId: undefined };
}
if (res === 'hook' && isHook(data)) {
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
}
if (res === 'sleep') {
const waitData = data as { runId?: string } | undefined;
return {
resource: 'sleep',
resourceId: selectedSpan.spanId,
runId: waitData?.runId,
};
}
return { resource: undefined, resourceId: undefined, runId: undefined };
}, [selectedSpan, data]);

// 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 {
status,
resource,
resourceId,
displayData,
detail: matchedSpanDetailData,
error,
} = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
const loading = status === 'loading';

// Check if this sleep is still pending and can be woken up
const canWakeUp = useMemo(() => {
Expand DownExpand Up@@ -216,17 +160,6 @@ export function EntityDetailPanel({
resolvedHookIds,
]);

const error = spanDetailError ?? undefined;
const loading = spanDetailLoading ?? false;

const matchedSpanDetailData = useMemo(
() =>
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
? spanDetailData
: null,
[spanDetailData, resource, resourceId]
);

// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
const hookToken = useMemo(() => {
if (resource !== 'hook' || !resourceId) return undefined;
Expand DownExpand Up@@ -325,16 +258,6 @@ export function EntityDetailPanel({
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
);

const displayData = useMemo(
() =>
mergeSpanDetail(data, matchedSpanDetailData) as
| WorkflowRun
| Step
| Hook
| Event,
[data, matchedSpanDetailData]
);

const moduleSpecifier = useMemo(() => {
const displayRecord = displayData as Record<string, unknown>;
const displayStepName = displayRecord.stepName;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
'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-selected-span-detail';

export interface SidebarDataContextValue {
run: WorkflowRun;
events: Event[];
spanDetailData: WorkflowRun | Step | Hook | Event | null;
spanDetailError?: Error | null;
spanDetailLoading?: boolean;
onSpanSelect: (info: SpanSelectionInfo) => void;
fetchSpanDetail: FetchSpanDetail;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onWakeUpSleep?: (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix trace detail panel Input/Output flicker (selection-driven state machine) by mitul-s · Pull Request #2637 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-span-detail-flicker-state-machine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/web-shared': patch
'@workflow/web': patch
---

Fix the run trace detail panel flickering its Input/Output sections when navigating between spans. Span detail is now driven by a single selection-derived state machine (`useSelectedSpanDetail`) whose loading state stays in phase with the selected span, replacing the fetch flag that lagged selection by a few renders.
12 changes: 2 additions & 10 deletions packages/web-shared/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,20 +15,12 @@ It comes with pre-styled UI components that accept data + callbacks:
```tsx
import { WorkflowTraceViewer } from '@workflow/web-shared';

export default function MyRunDetailView({
run,
steps,
hooks,
events,
onSpanSelect,
}) {
export default function MyRunDetailView({ run, events, fetchSpanDetail }) {
return (
<WorkflowTraceViewer
run={run}
steps={steps}
hooks={hooks}
events={events}
onSpanSelect={onSpanSelect}
fetchSpanDetail={fetchSpanDetail}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/web-shared/src/components/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,18 +13,24 @@ 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 {
DetailResource,
FetchSpanDetail,
} from './sidebar/use-selected-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,
Expand All@@ -38,5 +44,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';
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,14 +39,14 @@ import {
TooltipTrigger,
} from '../ui/tooltip';
import EventList from './components/event-list';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { SplitPane } from './components/split-pane';
import {
TIMELINE_PADDING_PX,
Timeline,
TimelineHeader,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
import type { TraceWithMeta } from './types';
Expand DownExpand Up@@ -822,10 +822,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}
Expand Down
19 changes: 5 additions & 14 deletions packages/web-shared/src/components/run-trace-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
'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-selected-span-detail';
import { WorkflowTraceViewer } from './workflow-trace-view';

interface RunTraceViewProps {
run: WorkflowRun;
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
Expand All@@ -25,7 +23,6 @@ interface RunTraceViewProps {
onCancelRun?: (runId: string) => Promise<void>;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onSpanSelect?: (info: SpanSelectionInfo) => void;
onLoadMoreSpans?: () => void | Promise<void>;
hasMoreSpans?: boolean;
isLoadingMoreSpans?: boolean;
Expand All@@ -36,15 +33,12 @@ export function RunTraceView({
events,
isLoading,
error,
spanDetailData,
spanDetailLoading,
spanDetailError,
fetchSpanDetail,
onWakeUpSleep,
onResolveHook,
onCancelRun,
onStreamClick,
onRunClick,
onSpanSelect,
onLoadMoreSpans,
hasMoreSpans,
isLoadingMoreSpans,
Expand All@@ -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}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,10 +768,8 @@ export const AttributePanel = ({

if (resource === 'sleep') return present;

// During loading, ensure sections appear so their skeletons render
// in the correct position (above the events section).
const loadingDefaults = ['input', 'output'];
for (const key of loadingDefaults) {
const loadingPlaceholders = ['input', 'output'];
for (const key of loadingPlaceholders) {
if (!present.includes(key)) {
present.push(key);
}
Expand Down
111 changes: 17 additions & 94 deletions packages/web-shared/src/components/sidebar/entity-detail-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
'use client';

import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { Event, Hook, 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,
spanDetailMatchesSelection,
} from './span-detail-merge';

// Type guards for runtime validation of span attribute data
function isStep(data: unknown): data is Step {
return data !== null && typeof data === 'object' && 'stepId' in data;
}

function isWorkflowRun(data: unknown): data is WorkflowRun {
return data !== null && typeof data === 'object' && 'runId' in data;
}
type FetchSpanDetail,
useSelectedSpanDetail,
} from './use-selected-span-detail';

// Type guard for runtime validation of span attribute data
function isHook(data: unknown): data is Hook {
return data !== null && typeof data === 'object' && 'hookId' in data;
}
Expand DownExpand Up@@ -61,10 +53,7 @@ export function EntityDetailPanel({
run,
onStreamClick,
onRunClick,
spanDetailData,
spanDetailError,
spanDetailLoading,
onSpanSelect,
fetchSpanDetail,
onWakeUpSleep,
onLoadEventData,
onResolveHook,
Expand All@@ -78,14 +67,7 @@ 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;
fetchSpanDetail: FetchSpanDetail;
/** Callback to wake up a pending sleep call. */
onWakeUpSleep?: (
runId: string,
Expand DownExpand Up@@ -128,53 +110,15 @@ 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(() => {
if (!selectedSpan) {
return { resource: undefined, resourceId: undefined, runId: undefined };
}

const res = selectedSpan.resource;
if (res === 'step' && isStep(data)) {
return { resource: 'step', resourceId: data.stepId, runId: data.runId };
}
if (res === 'run' && isWorkflowRun(data)) {
return { resource: 'run', resourceId: data.runId, runId: undefined };
}
if (res === 'hook' && isHook(data)) {
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
}
if (res === 'sleep') {
const waitData = data as { runId?: string } | undefined;
return {
resource: 'sleep',
resourceId: selectedSpan.spanId,
runId: waitData?.runId,
};
}
return { resource: undefined, resourceId: undefined, runId: undefined };
}, [selectedSpan, data]);

// 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 {
status,
resource,
resourceId,
displayData,
detail: matchedSpanDetailData,
error,
} = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
const loading = status === 'loading';

// Check if this sleep is still pending and can be woken up
const canWakeUp = useMemo(() => {
Expand DownExpand Up@@ -216,17 +160,6 @@ export function EntityDetailPanel({
resolvedHookIds,
]);

const error = spanDetailError ?? undefined;
const loading = spanDetailLoading ?? false;

const matchedSpanDetailData = useMemo(
() =>
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
? spanDetailData
: null,
[spanDetailData, resource, resourceId]
);

// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
const hookToken = useMemo(() => {
if (resource !== 'hook' || !resourceId) return undefined;
Expand DownExpand Up@@ -325,16 +258,6 @@ export function EntityDetailPanel({
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
);

const displayData = useMemo(
() =>
mergeSpanDetail(data, matchedSpanDetailData) as
| WorkflowRun
| Step
| Hook
| Event,
[data, matchedSpanDetailData]
);

const moduleSpecifier = useMemo(() => {
const displayRecord = displayData as Record<string, unknown>;
const displayStepName = displayRecord.stepName;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
'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-selected-span-detail';

export interface SidebarDataContextValue {
run: WorkflowRun;
events: Event[];
spanDetailData: WorkflowRun | Step | Hook | Event | null;
spanDetailError?: Error | null;
spanDetailLoading?: boolean;
onSpanSelect: (info: SpanSelectionInfo) => void;
fetchSpanDetail: FetchSpanDetail;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onWakeUpSleep?: (
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Fix trace detail panel Input/Output flicker (selection-driven state machine) by mitul-s · Pull Request #2637 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-span-detail-flicker-state-machine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@workflow/web-shared': patch
'@workflow/web': patch
---

Fix the run trace detail panel flickering its Input/Output sections when navigating between spans. Span detail is now driven by a single selection-derived state machine (`useSelectedSpanDetail`) whose loading state stays in phase with the selected span, replacing the fetch flag that lagged selection by a few renders.
12 changes: 2 additions & 10 deletions packages/web-shared/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,20 +15,12 @@ It comes with pre-styled UI components that accept data + callbacks:
```tsx
import { WorkflowTraceViewer } from '@workflow/web-shared';

export default function MyRunDetailView({
run,
steps,
hooks,
events,
onSpanSelect,
}) {
export default function MyRunDetailView({ run, events, fetchSpanDetail }) {
return (
<WorkflowTraceViewer
run={run}
steps={steps}
hooks={hooks}
events={events}
onSpanSelect={onSpanSelect}
fetchSpanDetail={fetchSpanDetail}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/web-shared/src/components/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,18 +13,24 @@ 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 {
DetailResource,
FetchSpanDetail,
} from './sidebar/use-selected-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,
Expand All@@ -38,5 +44,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';
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,14 +39,14 @@ import {
TooltipTrigger,
} from '../ui/tooltip';
import EventList from './components/event-list';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { SplitPane } from './components/split-pane';
import {
TIMELINE_PADDING_PX,
Timeline,
TimelineHeader,
} from './components/timeline';
import { TraceShortcutHelper } from './components/trace-shortcut-helper';
import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
import { ActiveSpanProvider, useActiveSpan } from './context';
import { searchSpans } from './search';
import type { TraceWithMeta } from './types';
Expand DownExpand Up@@ -822,10 +822,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}
Expand Down
19 changes: 5 additions & 14 deletions packages/web-shared/src/components/run-trace-view.tsx
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
'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-selected-span-detail';
import { WorkflowTraceViewer } from './workflow-trace-view';

interface RunTraceViewProps {
run: WorkflowRun;
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
Expand All@@ -25,7 +23,6 @@ interface RunTraceViewProps {
onCancelRun?: (runId: string) => Promise<void>;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onSpanSelect?: (info: SpanSelectionInfo) => void;
onLoadMoreSpans?: () => void | Promise<void>;
hasMoreSpans?: boolean;
isLoadingMoreSpans?: boolean;
Expand All@@ -36,15 +33,12 @@ export function RunTraceView({
events,
isLoading,
error,
spanDetailData,
spanDetailLoading,
spanDetailError,
fetchSpanDetail,
onWakeUpSleep,
onResolveHook,
onCancelRun,
onStreamClick,
onRunClick,
onSpanSelect,
onLoadMoreSpans,
hasMoreSpans,
isLoadingMoreSpans,
Expand All@@ -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}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,10 +768,8 @@ export const AttributePanel = ({

if (resource === 'sleep') return present;

// During loading, ensure sections appear so their skeletons render
// in the correct position (above the events section).
const loadingDefaults = ['input', 'output'];
for (const key of loadingDefaults) {
const loadingPlaceholders = ['input', 'output'];
for (const key of loadingPlaceholders) {
if (!present.includes(key)) {
present.push(key);
}
Expand Down
111 changes: 17 additions & 94 deletions packages/web-shared/src/components/sidebar/entity-detail-panel.tsx
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
'use client';

import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { Event, Hook, 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,
spanDetailMatchesSelection,
} from './span-detail-merge';

// Type guards for runtime validation of span attribute data
function isStep(data: unknown): data is Step {
return data !== null && typeof data === 'object' && 'stepId' in data;
}

function isWorkflowRun(data: unknown): data is WorkflowRun {
return data !== null && typeof data === 'object' && 'runId' in data;
}
type FetchSpanDetail,
useSelectedSpanDetail,
} from './use-selected-span-detail';

// Type guard for runtime validation of span attribute data
function isHook(data: unknown): data is Hook {
return data !== null && typeof data === 'object' && 'hookId' in data;
}
Expand DownExpand Up@@ -61,10 +53,7 @@ export function EntityDetailPanel({
run,
onStreamClick,
onRunClick,
spanDetailData,
spanDetailError,
spanDetailLoading,
onSpanSelect,
fetchSpanDetail,
onWakeUpSleep,
onLoadEventData,
onResolveHook,
Expand All@@ -78,14 +67,7 @@ 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;
fetchSpanDetail: FetchSpanDetail;
/** Callback to wake up a pending sleep call. */
onWakeUpSleep?: (
runId: string,
Expand DownExpand Up@@ -128,53 +110,15 @@ 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(() => {
if (!selectedSpan) {
return { resource: undefined, resourceId: undefined, runId: undefined };
}

const res = selectedSpan.resource;
if (res === 'step' && isStep(data)) {
return { resource: 'step', resourceId: data.stepId, runId: data.runId };
}
if (res === 'run' && isWorkflowRun(data)) {
return { resource: 'run', resourceId: data.runId, runId: undefined };
}
if (res === 'hook' && isHook(data)) {
return { resource: 'hook', resourceId: data.hookId, runId: undefined };
}
if (res === 'sleep') {
const waitData = data as { runId?: string } | undefined;
return {
resource: 'sleep',
resourceId: selectedSpan.spanId,
runId: waitData?.runId,
};
}
return { resource: undefined, resourceId: undefined, runId: undefined };
}, [selectedSpan, data]);

// 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 {
status,
resource,
resourceId,
displayData,
detail: matchedSpanDetailData,
error,
} = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
const loading = status === 'loading';

// Check if this sleep is still pending and can be woken up
const canWakeUp = useMemo(() => {
Expand DownExpand Up@@ -216,17 +160,6 @@ export function EntityDetailPanel({
resolvedHookIds,
]);

const error = spanDetailError ?? undefined;
const loading = spanDetailLoading ?? false;

const matchedSpanDetailData = useMemo(
() =>
spanDetailMatchesSelection(spanDetailData, resource, resourceId)
? spanDetailData
: null,
[spanDetailData, resource, resourceId]
);

// Get the hook token for resolving (prefer fetched data, then hooks array fallback)
const hookToken = useMemo(() => {
if (resource !== 'hook' || !resourceId) return undefined;
Expand DownExpand Up@@ -325,16 +258,6 @@ export function EntityDetailPanel({
[onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
);

const displayData = useMemo(
() =>
mergeSpanDetail(data, matchedSpanDetailData) as
| WorkflowRun
| Step
| Hook
| Event,
[data, matchedSpanDetailData]
);

const moduleSpecifier = useMemo(() => {
const displayRecord = displayData as Record<string, unknown>;
const displayStepName = displayRecord.stepName;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
'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-selected-span-detail';

export interface SidebarDataContextValue {
run: WorkflowRun;
events: Event[];
spanDetailData: WorkflowRun | Step | Hook | Event | null;
spanDetailError?: Error | null;
spanDetailLoading?: boolean;
onSpanSelect: (info: SpanSelectionInfo) => void;
fetchSpanDetail: FetchSpanDetail;
onStreamClick?: (streamId: string) => void;
onRunClick?: (runId: string) => void;
onWakeUpSleep?: (
Expand Down
Loading
Loading