From d7166b6d0ac3b1ef294399747949ea93e160f9aa Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:51:00 +0100 Subject: [PATCH] feat: Jump to Error centers the failed node in the viewport Added centerOnNodeId prop to ProcessDiagram. When set, the diagram pans to center the specified node in the viewport. Jump to Error now selects the failed processor AND centers the viewport on it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ExecutionDiagram/ExecutionDiagram.tsx | 9 +++-- .../ProcessDiagram/ProcessDiagram.tsx | 35 +++++++++++++++++++ ui/src/components/ProcessDiagram/types.ts | 2 ++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx index e19ddf7e..fc5378c2 100644 --- a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx @@ -80,8 +80,9 @@ export function ExecutionDiagram({ // 4. Compute overlay const overlay = useExecutionOverlay(detail?.processors, iterationState); - // 5. Manage selection + // 5. Manage selection + center-on-node const [selectedProcessorId, setSelectedProcessorId] = useState(''); + const [centerOnNodeId, setCenterOnNodeId] = useState(''); // 6. Resizable splitter state const [splitPercent, setSplitPercent] = useState(60); @@ -105,12 +106,15 @@ export function ExecutionDiagram({ document.addEventListener('pointerup', onUp); }, []); - // Jump to error: find first FAILED processor and select it + // Jump to error: find first FAILED processor, select it, and center the viewport const handleJumpToError = useCallback(() => { if (!detail?.processors) return; const failed = findFailedProcessor(detail.processors); if (failed?.processorId) { setSelectedProcessorId(failed.processorId); + // Use a unique value to re-trigger centering even if the same node + setCenterOnNodeId(''); + requestAnimationFrame(() => setCenterOnNodeId(failed.processorId)); } }, [detail?.processors]); @@ -187,6 +191,7 @@ export function ExecutionDiagram({ executionOverlay={overlay} iterationState={iterationState} onIterationChange={setIteration} + centerOnNodeId={centerOnNodeId} /> diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index d1b33c00..382c22fb 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -56,6 +56,7 @@ export function ProcessDiagram({ executionOverlay, iterationState, onIterationChange, + centerOnNodeId, }: ProcessDiagramProps) { // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); @@ -106,6 +107,33 @@ export function ProcessDiagram({ } }, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps + // Center on a specific node when centerOnNodeId changes + useEffect(() => { + if (!centerOnNodeId || sections.length === 0) return; + const node = findNodeById(sections, centerOnNodeId); + if (!node) return; + const container = zoom.containerRef.current; + if (!container) return; + // Compute the node center in diagram coordinates + const nodeX = (node.x ?? 0) + (node.width ?? 160) / 2; + const nodeY = (node.y ?? 0) + (node.height ?? 40) / 2; + // Find which section the node is in to add its offsetY + let sectionOffsetY = 0; + for (const section of sections) { + const found = findNodeInSection(section.nodes, centerOnNodeId); + if (found) { sectionOffsetY = section.offsetY; break; } + } + const adjustedY = nodeY + sectionOffsetY; + // Pan so the node center is at the viewport center + const cw = container.clientWidth; + const ch = container.clientHeight; + const scale = zoom.state.scale; + zoom.panTo( + cw / 2 - nodeX * scale, + ch / 2 - adjustedY * scale, + ); + }, [centerOnNodeId]); // eslint-disable-line react-hooks/exhaustive-deps + // Resolve execution state for a node. ENDPOINT nodes (the route's "from:") // don't appear in the processor execution tree, but should be marked as // COMPLETED when the route executed (i.e., overlay has any entries). @@ -415,6 +443,13 @@ function findInChildren( return undefined; } +function findNodeInSection( + nodes: DiagramNodeType[], + nodeId: string, +): boolean { + return !!findInChildren(nodes, nodeId) || nodes.some(n => n.id === nodeId); +} + function topLevelEdge( edge: import('../../api/queries/diagrams').DiagramEdge, nodes: DiagramNodeType[], diff --git a/ui/src/components/ProcessDiagram/types.ts b/ui/src/components/ProcessDiagram/types.ts index 01d3776d..e7a6e516 100644 --- a/ui/src/components/ProcessDiagram/types.ts +++ b/ui/src/components/ProcessDiagram/types.ts @@ -35,4 +35,6 @@ export interface ProcessDiagramProps { iterationState?: Map; /** Called when user changes iteration on a compound stepper */ onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; + /** When set, the diagram pans to center this node in the viewport */ + centerOnNodeId?: string; }