feat: add iteration stepper to compound nodes and thread overlay props

Add a left/right stepper widget to compound node headers (LOOP, SPLIT,
MULTICAST) when iteration overlay data is present. Thread executionOverlay,
overlayActive, iterationState, and onIterationChange props through
ProcessDiagram -> CompoundNode -> children and ProcessDiagram ->
ErrorSection -> children so leaf DiagramNode instances render with
execution state (green/red badges, dimming for skipped nodes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 18:52:32 +01:00
parent 3029704051
commit 1984c597de
4 changed files with 94 additions and 2 deletions

View File

@@ -1,9 +1,10 @@
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
import { colorForType, isCompoundType } from './node-colors';
import { DiagramNode } from './DiagramNode';
import { DiagramEdge } from './DiagramEdge';
import styles from './ProcessDiagram.module.css';
const HEADER_HEIGHT = 22;
const CORNER_RADIUS = 4;
@@ -20,6 +21,12 @@ interface CompoundNodeProps {
nodeConfigs?: Map<string, NodeConfig>;
/** Execution overlay for edge traversal coloring */
executionOverlay?: Map<string, NodeExecutionState>;
/** Whether an execution overlay is active (enables dimming of skipped nodes) */
overlayActive?: boolean;
/** Per-compound iteration state */
iterationState?: Map<string, IterationInfo>;
/** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
onNodeClick: (nodeId: string) => void;
onNodeDoubleClick?: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
@@ -29,6 +36,7 @@ interface CompoundNodeProps {
export function CompoundNode({
node, edges, parentX = 0, parentY = 0,
selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) {
const x = (node.x ?? 0) - parentX;
@@ -40,6 +48,8 @@ export function CompoundNode({
const color = colorForType(node.type);
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const label = node.label ? `${typeName}: ${node.label}` : typeName;
const iterationInfo = node.id ? iterationState?.get(node.id) : undefined;
const headerWidth = w;
// Collect all descendant node IDs to filter edges that belong inside this compound
const descendantIds = new Set<string>();
@@ -79,6 +89,27 @@ export function CompoundNode({
{label}
</text>
{/* Iteration stepper (for LOOP, SPLIT, MULTICAST with overlay data) */}
{iterationInfo && (
<foreignObject x={headerWidth - 80} y={1} width={75} height={20}>
<div className={styles.iterationStepper}>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current - 1); }}
disabled={iterationInfo.current <= 0}
>
&lsaquo;
</button>
<span>{iterationInfo.current + 1} / {iterationInfo.total}</span>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current + 1); }}
disabled={iterationInfo.current >= iterationInfo.total - 1}
>
&rsaquo;
</button>
</div>
</foreignObject>
)}
{/* Internal edges (rendered after background, before children) */}
<g className="edges">
{internalEdges.map((edge, i) => {
@@ -112,6 +143,9 @@ export function CompoundNode({
hoveredNodeId={hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeEnter={onNodeEnter}
@@ -130,6 +164,8 @@ export function CompoundNode({
isHovered={hoveredNodeId === child.id}
isSelected={selectedNodeId === child.id}
config={child.id ? nodeConfigs?.get(child.id) : undefined}
executionState={executionOverlay?.get(child.id ?? '')}
overlayActive={overlayActive}
onClick={() => child.id && onNodeClick(child.id)}
onDoubleClick={() => child.id && onNodeDoubleClick?.(child.id)}
onMouseEnter={() => child.id && onNodeEnter(child.id)}

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import type { DiagramSection } from './types';
import type { NodeConfig } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import { DiagramEdge } from './DiagramEdge';
import { DiagramNode } from './DiagramNode';
@@ -19,6 +19,12 @@ interface ErrorSectionProps {
nodeConfigs?: Map<string, NodeConfig>;
/** Execution overlay for edge traversal coloring */
executionOverlay?: Map<string, NodeExecutionState>;
/** Whether an execution overlay is active (enables dimming of skipped nodes) */
overlayActive?: boolean;
/** Per-compound iteration state */
iterationState?: Map<string, IterationInfo>;
/** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
onNodeClick: (nodeId: string) => void;
onNodeDoubleClick?: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
@@ -32,6 +38,7 @@ const VARIANT_COLORS: Record<string, string> = {
export function ErrorSection({
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: ErrorSectionProps) {
const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error;
@@ -108,6 +115,9 @@ export function ErrorSection({
hoveredNodeId={hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeEnter={onNodeEnter}
@@ -122,6 +132,8 @@ export function ErrorSection({
isHovered={hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
executionState={executionOverlay?.get(node.id ?? '')}
overlayActive={overlayActive}
onClick={() => node.id && onNodeClick(node.id)}
onDoubleClick={() => node.id && onNodeDoubleClick?.(node.id)}
onMouseEnter={() => node.id && onNodeEnter(node.id)}

View File

@@ -168,3 +168,36 @@
background: var(--bg-hover, #F5F0EA);
color: var(--text-primary, #1A1612);
}
.iterationStepper {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
padding: 1px 3px;
font-size: 10px;
color: white;
font-family: inherit;
}
.iterationStepper button {
width: 16px;
height: 16px;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
border-radius: 2px;
cursor: pointer;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-family: inherit;
}
.iterationStepper button:disabled {
opacity: 0.3;
cursor: default;
}

View File

@@ -54,7 +54,10 @@ export function ProcessDiagram({
className,
diagramLayout,
executionOverlay,
iterationState,
onIterationChange,
}: ProcessDiagramProps) {
const overlayActive = !!executionOverlay;
// Route stack for drill-down navigation
const [routeStack, setRouteStack] = useState<string[]>([routeId]);
@@ -248,6 +251,9 @@ export function ProcessDiagram({
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}
@@ -262,6 +268,8 @@ export function ProcessDiagram({
isHovered={toolbar.hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
executionState={executionOverlay?.get(node.id ?? '')}
overlayActive={overlayActive}
onClick={() => node.id && handleNodeClick(node.id)}
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
@@ -283,6 +291,9 @@ export function ProcessDiagram({
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}