diff --git a/packages/web-shared/src/components/index.ts b/packages/web-shared/src/components/index.ts index ee49551d80..dcf77a8842 100644 --- a/packages/web-shared/src/components/index.ts +++ b/packages/web-shared/src/components/index.ts @@ -15,6 +15,10 @@ export { } from './hook-actions'; 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, diff --git a/packages/web-shared/src/components/new-trace-viewer/components/split-pane.tsx b/packages/web-shared/src/components/new-trace-viewer/components/split-pane.tsx index 545f87fc3f..ce5741450a 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/split-pane.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/split-pane.tsx @@ -2,12 +2,9 @@ import { Children, - type Dispatch, - type KeyboardEvent, type ReactNode, type PointerEvent as ReactPointerEvent, - type RefObject, - type SetStateAction, + useCallback, useEffect, useRef, useState, @@ -16,6 +13,8 @@ import { cn } from '../../../lib/utils'; /** Wide enough for comfortable dragging; visual line stays 1px centered. */ const GUTTER_PX = 9; +const MIN_PX = 50; +const DEFAULT_START_PX = 340; export function Divider() { return
; @@ -24,70 +23,18 @@ export function Divider() { export interface SplitPaneProps { children: ReactNode; className?: string; - /** Initial width fraction for the first pane (0.15–0.85). */ - defaultRatio?: number; + /** Fixed pixel width for the start (left) pane. Default 220. */ + defaultStartWidth?: number; /** Fixed (non-scrolling) header rendered above the start pane. */ startHeader?: ReactNode; /** Fixed (non-scrolling) header rendered above the end pane. */ endHeader?: ReactNode; } -function clampRatio(v: number) { - return Math.min(0.85, Math.max(0.15, v)); -} - -function markUndoBaseline( - initialRatioRef: RefObject, - splitRatio: number -) { - if (initialRatioRef.current === null) initialRatioRef.current = splitRatio; -} - -function handleSplitKeyboard( - e: KeyboardEvent, - splitRatio: number, - initialRatioRef: RefObject, - setSplitRatio: Dispatch> -): void { - const step = e.shiftKey ? 0.1 : 0.02; - - if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - e.preventDefault(); - markUndoBaseline(initialRatioRef, splitRatio); - setSplitRatio((r) => clampRatio(r - step)); - return; - } - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { - e.preventDefault(); - markUndoBaseline(initialRatioRef, splitRatio); - setSplitRatio((r) => clampRatio(r + step)); - return; - } - if (e.key === 'Home') { - e.preventDefault(); - markUndoBaseline(initialRatioRef, splitRatio); - setSplitRatio(0.15); - return; - } - if (e.key === 'End') { - e.preventDefault(); - markUndoBaseline(initialRatioRef, splitRatio); - setSplitRatio(0.85); - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - if (initialRatioRef.current !== null) { - setSplitRatio(initialRatioRef.current); - initialRatioRef.current = null; - } - } -} - export function SplitPane({ children, className, - defaultRatio = 0.45, + defaultStartWidth = DEFAULT_START_PX, startHeader, endHeader, }: SplitPaneProps) { @@ -97,14 +44,13 @@ export function SplitPane({ } const [start, end] = parts; - const [splitRatio, setSplitRatio] = useState(defaultRatio); - const [isDraggingSplit, setIsDraggingSplit] = useState(false); - const innerGridRef = useRef(null); + const [startPx, setStartPx] = useState(defaultStartWidth); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); const gutterRef = useRef(null); const rafRef = useRef(null); - const pendingRatio = useRef(defaultRatio); + const pendingPx = useRef(defaultStartWidth); const pointerIdRef = useRef(null); - const initialRatioRef = useRef(null); useEffect(() => { return () => { @@ -112,19 +58,26 @@ export function SplitPane({ }; }, []); + const clampPx = useCallback((px: number) => { + const el = containerRef.current; + if (!el) return px; + const maxPx = el.getBoundingClientRect().width - MIN_PX - GUTTER_PX; + return Math.min(maxPx, Math.max(MIN_PX, px)); + }, []); + useEffect(() => { - if (!isDraggingSplit) return; + if (!isDragging) return; const onPointerMove = (e: globalThis.PointerEvent) => { if (e.pointerId !== pointerIdRef.current) return; - const container = innerGridRef.current; + const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); - pendingRatio.current = clampRatio((e.clientX - rect.left) / rect.width); + pendingPx.current = clampPx(e.clientX - rect.left); if (!rafRef.current) { rafRef.current = requestAnimationFrame(() => { rafRef.current = null; - setSplitRatio(pendingRatio.current); + setStartPx(pendingPx.current); }); } }; @@ -140,7 +93,7 @@ export function SplitPane({ rafRef.current = null; } pointerIdRef.current = null; - setIsDraggingSplit(false); + setIsDragging(false); }; document.addEventListener('pointermove', onPointerMove); @@ -152,48 +105,32 @@ export function SplitPane({ document.removeEventListener('pointerup', onPointerUp); document.removeEventListener('pointercancel', onPointerUp); }; - }, [isDraggingSplit]); + }, [isDragging, clampPx]); - const handleSplitPointerDown = (e: ReactPointerEvent) => { + const handlePointerDown = (e: ReactPointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); pointerIdRef.current = e.pointerId; - setIsDraggingSplit(true); + setIsDragging(true); }; const handleLostPointerCapture = () => { pointerIdRef.current = null; - setIsDraggingSplit(false); + setIsDragging(false); if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; - const handleSplitKeyDown = (e: KeyboardEvent) => { - handleSplitKeyboard(e, splitRatio, initialRatioRef, setSplitRatio); - }; - - const ratioPercent = Math.round(splitRatio * 100); - const colTemplate = `minmax(50px, ${splitRatio * 100}%) ${GUTTER_PX}px minmax(50px, ${(1 - splitRatio) * 100}%)`; - + const colTemplate = `${startPx}px ${GUTTER_PX}px minmax(${MIN_PX}px, 1fr)`; const hasHeaders = startHeader != null || endHeader != null; const gutter = (
{endHeader}
{start} {gutter} @@ -236,17 +170,13 @@ export function SplitPane({ return (
{start} {gutter} diff --git a/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx b/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx index 6f17540cce..5e45d0571a 100644 --- a/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx @@ -1,7 +1,8 @@ 'use client'; import * as TooltipPrimitive from '@radix-ui/react-tooltip'; -import { Search, X } from 'lucide-react'; +import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; +import { Copy, Search, X } from 'lucide-react'; import { type ReactNode, useCallback, @@ -10,6 +11,12 @@ import { useRef, useState, } from 'react'; +import { ErrorBoundary } from '../error-boundary'; +import { + EntityDetailPanel, + type SelectedSpanInfo, +} from '../sidebar/entity-detail-panel'; +import { useSidebarDataOptional } from '../sidebar/sidebar-data-context'; import type { Trace } from '../trace-viewer/types'; import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing'; import { SplitPane } from './components/split-pane'; @@ -94,6 +101,35 @@ function useAnimatedViewport(initial: Viewport) { return { viewport, setViewport, animateTo }; } +// --------------------------------------------------------------------------- +// Hook: bridge ActiveSpanContext + SidebarDataContext → SelectedSpanInfo +// --------------------------------------------------------------------------- + +function useSelectedSpanInfo(): SelectedSpanInfo | null { + const { activeSpan } = useActiveSpan(); + const sidebar = useSidebarDataOptional(); + + return useMemo(() => { + if (!activeSpan || !sidebar) return null; + + const correlationId = activeSpan.spanId; + const rawEvents = correlationId + ? sidebar.events.filter((e) => e.correlationId === correlationId) + : []; + + return { + data: activeSpan.attributes?.data, + resource: activeSpan.attributes?.resource as string | undefined, + spanId: activeSpan.spanId, + rawEvents, + }; + }, [activeSpan, sidebar?.events]); +} + +// --------------------------------------------------------------------------- +// Root component +// --------------------------------------------------------------------------- + export function NewTraceViewer({ trace }: NewTraceViewerProps): ReactNode { return ( @@ -108,6 +144,9 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode { const { activeSpan, activeSpanId, setActiveSpan, clearActiveSpan } = useActiveSpan(); + const sidebar = useSidebarDataOptional(); + const selectedSpan = useSelectedSpanInfo(); + const [searchQuery, setSearchQuery] = useState(''); const filteredSpans = useMemo(() => { @@ -167,6 +206,10 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode { const handleSelectSpan = useCallback( (spanId: string) => { + if (spanId === activeSpanId) { + clearActiveSpan(); + return; + } setActiveSpan(spanId); const span = trace.spans.find((s) => s.spanId === spanId); @@ -209,7 +252,15 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode { animateTo({ start: newStart, end: newEnd }); }, - [animateTo, setActiveSpan, trace.spans, root.startTime, root.duration] + [ + animateTo, + setActiveSpan, + clearActiveSpan, + activeSpanId, + trace.spans, + root.startTime, + root.duration, + ] ); const [altHeld, setAltHeld] = useState(false); @@ -352,11 +403,39 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode { return () => el.removeEventListener('wheel', onWheel); }, [root.startTime, root.duration]); + // Derive the selected span name and metadata for the panel header + const selectedSpanName = useMemo(() => { + if (!selectedSpan?.data) return 'Details'; + const data = selectedSpan.data as Record; + const stepName = data.stepName as string | undefined; + const workflowName = data.workflowName as string | undefined; + return ( + (stepName ? parseStepName(stepName)?.shortName : undefined) ?? + (workflowName ? parseWorkflowName(workflowName)?.shortName : undefined) ?? + stepName ?? + workflowName ?? + (data.hookId as string) ?? + 'Details' + ); + }, [selectedSpan?.data]); + + const selectedResource = selectedSpan?.resource as string | undefined; + const selectedResourceId = useMemo(() => { + if (!selectedSpan?.data) return undefined; + const data = selectedSpan.data as Record; + return ( + (data.stepId as string) ?? + (data.runId as string) ?? + (data.hookId as string) ?? + selectedSpan.spanId + ); + }, [selectedSpan?.data, selectedSpan?.spanId]); + return (
- {activeSpan ? ( + + {/* Detail panel */} + {activeSpan && sidebar ? ( + + ) : activeSpan ? ( { * Display names for attributes that should render differently from their key. */ const attributeDisplayNames: Partial> = { + moduleSpecifier: 'Module', + workflowName: 'Workflow Name', + stepName: 'Step Name', + stepId: 'Step ID', + hookId: 'Hook ID', + attempt: 'Attempts', + eventId: 'Event ID', + runId: 'Run ID', + eventType: 'Event Type', + correlationId: 'Correlation ID', + deploymentId: 'Deployment ID', + specVersion: 'Spec Version', workflowCoreVersion: '@workflow/core version', - receivedCount: 'times resolved', + createdAt: 'Created At', + startedAt: 'Started At', + updatedAt: 'Updated At', + completedAt: 'Completed At', + expiredAt: 'Expired At', + retryAfter: 'Retry After', + resumeAt: 'Resume At', + lastReceivedAt: 'Last Received At', + disposedAt: 'Disposed At', + receivedCount: 'Times Resolved', }; /** @@ -374,17 +395,16 @@ const attributeToDisplayFn: Record< (value: unknown, context?: DisplayContext) => null | string | ReactNode > = { // Names that need pretty-printing - workflowName: (value: unknown) => - parseWorkflowName(String(value))?.shortName ?? '?', + workflowName: (_value: unknown) => null, moduleSpecifier: (value: unknown) => getModuleSpecifierFromName(value), - stepName: (value: unknown) => parseStepName(String(value))?.shortName ?? '?', + stepName: (_value: unknown) => null, // IDs - runId: (value: unknown) => String(value), - stepId: (value: unknown) => String(value), + runId: (_value: unknown) => null, + stepId: (_value: unknown) => null, hookId: (value: unknown) => String(value), eventId: (value: unknown) => String(value), // Run/step details - status: (value: unknown) => String(value), + status: (_value: unknown) => null, attempt: (value: unknown) => String(value), // Hook details token: (value: unknown) => String(value), @@ -409,7 +429,7 @@ const attributeToDisplayFn: Record< startedAt: timestampWithTooltipOrNull, updatedAt: timestampWithTooltipOrNull, completedAt: timestampWithTooltipOrNull, - expiredAt: timestampWithTooltipOrNull, + expiredAt: (_value: unknown) => null, retryAfter: timestampWithTooltipOrNull, resumeAt: timestampWithTooltipOrNull, // Resolved attributes, won't actually use this function @@ -463,7 +483,7 @@ const attributeToDisplayFn: Record< ); } @@ -472,7 +492,7 @@ const attributeToDisplayFn: Record< <> {Array.isArray(args) @@ -503,14 +523,14 @@ const attributeToDisplayFn: Record< ); } return ( {Array.isArray(value) @@ -530,7 +550,7 @@ const attributeToDisplayFn: Record< return ( {JsonBlock(value)} @@ -548,7 +568,7 @@ const attributeToDisplayFn: Record< return ( @@ -559,7 +579,7 @@ const attributeToDisplayFn: Record< return ( {JsonBlock(value)} @@ -617,10 +637,7 @@ export const AttributeBlock = ({
- + {attribute} @@ -668,10 +685,7 @@ export const AttributeBlock = ({ key={attribute} className={`my-2 flex flex-col ${attribute === 'input' || attribute === 'output' || attribute === 'error' ? 'gap-2 my-3.5' : 'gap-0'}`} > - + {attribute} @@ -801,13 +815,8 @@ export const AttributePanel = ({
{/* Basic attributes in a vertical layout with border */} {visibleBasicAttributes.length > 0 && ( -
- {orderedBasicAttributes.map((attribute, index) => { +
+ {orderedBasicAttributes.map((attribute) => { const displayValue = attributeToDisplayFn[ attribute as keyof typeof attributeToDisplayFn ]?.(displayData[attribute as keyof typeof displayData]); @@ -816,24 +825,11 @@ export const AttributePanel = ({ typeof displayValue === 'string' ? displayValue : String(displayValue ?? displayData.moduleSpecifier ?? ''); - const shouldCapitalizeLabel = attribute !== 'workflowCoreVersion'; - const showResumeAtSkeleton = - isLoading && resource === 'sleep' && !displayData.resumeAt; - const showDivider = - index < orderedBasicAttributes.length - 1 || - showResumeAtSkeleton; return ( -
-
- + +
+ {getAttributeDisplayName(attribute)} {isModuleSpecifier ? ( @@ -854,21 +850,16 @@ export const AttributePanel = ({ {moduleSpecifierValue} ) : ( - + {displayValue} )}
- {showDivider ? ( -
- ) : null} -
+