Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/trace-viewer-zoom-render-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/web-shared': patch
---

Fix the trace viewer click-to-zoom animation jumping/stuttering on large traces. The easing clock now starts on the first animation frame (so a heavy detail-panel mount can't make the zoom skip ahead), the panel opens as a low-priority transition so it doesn't block the animation, and the panel/event list/marker tooltips no longer do redundant work on every animation frame.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
OffscreenMarkers,
Segment,
SegmentStatus,
SpanGap,
TimeMarker,
} from '../utils';
import {
Expand Down Expand Up @@ -50,6 +51,9 @@ const TINY_BAR_BOX_SIZE_PX = 24;
const TINY_BAR_WIDTH_PX = 4;
export const TIMELINE_PADDING_PX = 16;

/** Stable empty reference so the gaps memo doesn't churn when Alt isn't held. */
const EMPTY_GAPS: SpanGap[] = [];

const SEGMENT_CLASSES: Record<SegmentStatus, string> = {
queued: 'bg-gray-400 border border-gray-500',
pending: 'bg-gray-200 border border-gray-500',
Expand Down Expand Up @@ -669,9 +673,13 @@ export function Timeline({
return () => ro.disconnect();
}, []);

// Only needed for the Alt-key delta overlay. Gating on `altHeld` keeps this
// O(spans) pass off the zoom/pan animation path — `viewStart`/`viewEnd` change
// on every frame, so without the guard this recomputes over the whole trace
// each frame even though nothing consumes it unless Alt is held.
const gaps = useMemo(
() => computeSpanGaps(spans, viewStart, viewEnd),
[spans, viewStart, viewEnd]
() => (altHeld ? computeSpanGaps(spans, viewStart, viewEnd) : EMPTY_GAPS),
[altHeld, spans, viewStart, viewEnd]
);

return (
Expand Down
243 changes: 149 additions & 94 deletions packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from 'lucide-react';
import {
type ReactNode,
startTransition,
useCallback,
useDeferredValue,
useEffect,
Expand All @@ -29,6 +30,7 @@ import {
} from '../sidebar/entity-detail-panel';
import { useSidebarData } from '../sidebar/sidebar-data-context';
import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
import { ContextCardProvider } from '../ui/context-card';
import { IconButton } from '../ui/icon-button';
import { Kbd } from '../ui/kbd';
import { Spinner } from '../ui/spinner';
Expand Down Expand Up @@ -98,10 +100,19 @@ function useAnimatedViewport(initial: Viewport) {
}

const from = currentRef.current;
const anim = { raf: 0, from, to: target, start: performance.now() };
// Anchor the easing clock to the first delivered frame, not to call time.
// Selecting a span mounts the detail panel in the same tick, and on large
// production traces that synchronous render can delay the first animation
// frame by tens/hundreds of ms. With a call-time clock, that delay makes
// the zoom skip ahead — or jump straight to the end — which reads exactly
// like "the animation broke / reduced motion is on". Starting the clock on
// the first frame keeps the zoom smooth no matter how late it begins.
const anim = { raf: 0, from, to: target, start: 0 };

const tick = () => {
const t = Math.min((performance.now() - anim.start) / 150, 1);
const now = performance.now();
if (anim.start === 0) anim.start = now;
const t = Math.min((now - anim.start) / 150, 1);
const e = 1 - (1 - t) * (1 - t);
setViewportState({
start: anim.from.start + (anim.to.start - anim.from.start) * e,
Expand Down Expand Up @@ -178,14 +189,21 @@ export function NewTraceViewer({
}: NewTraceViewerProps): ReactNode {
return (
<TooltipProvider delayDuration={300}>
<ActiveSpanProvider spans={trace.spans}>
<NewTraceViewerContent
trace={trace}
onLoadMore={onLoadMore}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
/>
</ActiveSpanProvider>
{/* One shared provider for every timeline-marker TimestampTooltip. Without
it, each marker tick self-mounts its own ContextCardProvider — a
full-screen portal + ResizeObserver + document scroll listener — and
those mount/unmount in bulk as bars cross the tiny↔full threshold on
each zoom frame. Sharing one provider is the design's intended usage. */}
<ContextCardProvider>
<ActiveSpanProvider spans={trace.spans}>
<NewTraceViewerContent
trace={trace}
onLoadMore={onLoadMore}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
/>
</ActiveSpanProvider>
</ContextCardProvider>
</TooltipProvider>
);
}
Expand Down Expand Up @@ -412,8 +430,16 @@ function NewTraceViewerContent({
clearActiveSpan();
return;
}
setActiveSpan(spanId);
// Start the zoom first (urgent), then open the detail panel as a
// low-priority transition. On large production traces the panel's first
// render is heavy; as an urgent update it blocks the animation's opening
// frames (the zoom then jumps instead of easing). As a transition, React
// keeps the rAF-driven viewport updates responsive and renders the panel
// without blocking them.
focusViewportOnSpan(spanId);
startTransition(() => {
setActiveSpan(spanId);
});
},
[
cancelPendingZoom,
Expand Down Expand Up @@ -644,6 +670,116 @@ function NewTraceViewerContent({
nextSpanIdRef.current = nextSpanId;
navigateToSpanRef.current = navigateToSpan;

// The viewport animation (`animateTo`) updates `viewport` state ~60×/sec,
// re-rendering this component on every frame. The event list and the detail
// panel don't depend on the viewport, so we memoize them into stable element
// references — React bails out of reconciling a child subtree when its
// element is referentially identical across renders. Without this, selecting
// a span re-reconciles the entire (recursive) detail-panel JSON tree on every
// zoom frame, which is what made the click-to-zoom feel janky.
const eventList = useMemo(
() => (
<EventList
spans={trace.spans}
activeSpanId={activeSpanId}
searchResult={searchResult}
onSelectSpan={handleSelectSpan}
/>
),
[trace.spans, activeSpanId, searchResult, handleSelectSpan]
);

const detailPanel = useMemo(() => {
if (!activeSpan) return null;
return (
<aside className="flex flex-col h-full max-h-full bg-background-100 border-l border-gray-alpha-400 overflow-auto">
{/* Panel header */}
<div className="flex items-center justify-between gap-2 shrink-0 px-4 pt-3 pb-3">
<span className="text-label-14 font-medium text-gray-1000 truncate block">
{selectedSpanName}
</span>
<div className="flex items-center gap-0.5 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<IconButton
aria-label="Navigate up"
aria-keyshortcuts="K"
onClick={handleSelectPrevSpan}
disabled={!prevSpanId}
>
<ChevronUp className="w-4 h-4" />
</IconButton>
</TooltipTrigger>
{prevSpanId ? (
<TooltipContent>
Navigate up
<Kbd>K</Kbd>
</TooltipContent>
) : null}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
aria-label="Navigate down"
aria-keyshortcuts="J"
onClick={handleSelectNextSpan}
disabled={!nextSpanId}
>
<ChevronDown className="w-4 h-4" />
</IconButton>
</TooltipTrigger>
{nextSpanId ? (
<TooltipContent>
Navigate down
<Kbd>J</Kbd>
</TooltipContent>
) : null}
</Tooltip>
<div aria-hidden className="w-px h-4 bg-gray-alpha-400 mx-1" />
<IconButton
aria-label="Close span details"
aria-keyshortcuts="Escape"
onClick={handleClearActiveSpan}
>
<X className="w-4 h-4" />
</IconButton>
</div>
</div>
{/* Panel body */}
<div className="flex-1 overflow-y-auto">
<ErrorBoundary>
<EntityDetailPanel
run={sidebar.run}
onStreamClick={sidebar.onStreamClick}
onRunClick={sidebar.onRunClick}
spanDetailData={sidebar.spanDetailData}
spanDetailError={sidebar.spanDetailError}
spanDetailLoading={sidebar.spanDetailLoading}
onSpanSelect={sidebar.onSpanSelect}
onWakeUpSleep={sidebar.onWakeUpSleep}
onLoadEventData={sidebar.onLoadEventData}
onResolveHook={sidebar.onResolveHook}
encryptionKey={sidebar.encryptionKey}
onDecrypt={sidebar.onDecrypt}
isDecrypting={sidebar.isDecrypting}
selectedSpan={selectedSpan}
/>
</ErrorBoundary>
</div>
</aside>
);
}, [
activeSpan,
selectedSpanName,
handleSelectPrevSpan,
prevSpanId,
handleSelectNextSpan,
nextSpanId,
handleClearActiveSpan,
sidebar,
selectedSpan,
]);

return (
<div
data-pane="pane-root"
Expand Down Expand Up @@ -695,12 +831,7 @@ function NewTraceViewerContent({
}
>
<div className="block overflow-visible">
<EventList
spans={trace.spans}
activeSpanId={activeSpanId}
searchResult={searchResult}
onSelectSpan={handleSelectSpan}
/>
{eventList}
<div ref={loadMoreSentinelRef} className="flex justify-center">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 py-3 text-sm text-gray-800">
Expand Down Expand Up @@ -761,83 +892,7 @@ function NewTraceViewerContent({
</div>

{/* Detail panel */}
{activeSpan ? (
<aside className="flex flex-col h-full max-h-full bg-background-100 border-l border-gray-alpha-400 overflow-auto">
{/* Panel header */}
<div className="flex items-center justify-between gap-2 shrink-0 px-4 pt-3 pb-3">
<span className="text-label-14 font-medium text-gray-1000 truncate block">
{selectedSpanName}
</span>
<div className="flex items-center gap-0.5 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<IconButton
aria-label="Navigate up"
aria-keyshortcuts="K"
onClick={handleSelectPrevSpan}
disabled={!prevSpanId}
>
<ChevronUp className="w-4 h-4" />
</IconButton>
</TooltipTrigger>
{prevSpanId ? (
<TooltipContent>
Navigate up
<Kbd>K</Kbd>
</TooltipContent>
) : null}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
aria-label="Navigate down"
aria-keyshortcuts="J"
onClick={handleSelectNextSpan}
disabled={!nextSpanId}
>
<ChevronDown className="w-4 h-4" />
</IconButton>
</TooltipTrigger>
{nextSpanId ? (
<TooltipContent>
Navigate down
<Kbd>J</Kbd>
</TooltipContent>
) : null}
</Tooltip>
<div aria-hidden className="w-px h-4 bg-gray-alpha-400 mx-1" />
<IconButton
aria-label="Close span details"
aria-keyshortcuts="Escape"
onClick={handleClearActiveSpan}
>
<X className="w-4 h-4" />
</IconButton>
</div>
</div>
{/* Panel body */}
<div className="flex-1 overflow-y-auto">
<ErrorBoundary>
<EntityDetailPanel
run={sidebar.run}
onStreamClick={sidebar.onStreamClick}
onRunClick={sidebar.onRunClick}
spanDetailData={sidebar.spanDetailData}
spanDetailError={sidebar.spanDetailError}
spanDetailLoading={sidebar.spanDetailLoading}
onSpanSelect={sidebar.onSpanSelect}
onWakeUpSleep={sidebar.onWakeUpSleep}
onLoadEventData={sidebar.onLoadEventData}
onResolveHook={sidebar.onResolveHook}
encryptionKey={sidebar.encryptionKey}
onDecrypt={sidebar.onDecrypt}
isDecrypting={sidebar.isDecrypting}
selectedSpan={selectedSpan}
/>
</ErrorBoundary>
</div>
</aside>
) : null}
{detailPanel}

<TraceShortcutHelper
hasMultipleSpans={trace.spans.length > 1}
Expand Down
Loading