diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json index 87b6640..0ec2e1e 100644 --- a/packages/workflow-dashboard/package.json +++ b/packages/workflow-dashboard/package.json @@ -13,9 +13,7 @@ "preview": "vite preview" }, "dependencies": { - "@dagrejs/dagre": "^3.0.0", "@xyflow/react": "^12.10.2", - "elkjs": "^0.11.1", "react": "^19.2.6", "react-dom": "^19.2.6", "react-markdown": "^10.1.0", diff --git a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx index a147c3d..1c18864 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx @@ -6,6 +6,42 @@ import { } from "@xyflow/react"; import type { ConditionEdgeData } from "./types.ts"; +// Must match the FEEDBACK_OFFSET_X in use-layout.ts +const FEEDBACK_OFFSET_X = 100; +// Radius for feedback edge corners +const FEEDBACK_RADIUS = 16; + +/** + * Build an SVG path for a feedback (back) edge that routes to the right of the nodes. + * The path goes: source right → arc → vertical up → arc → target right + */ +function feedbackPath( + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, +): string { + const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X; + const r = FEEDBACK_RADIUS; + + // Start from source right side, go right, then up, then left to target right side + const segments = [ + `M ${sourceX} ${sourceY}`, + // Horizontal to the right + `L ${rightX - r} ${sourceY}`, + // Arc turning upward + `Q ${rightX} ${sourceY} ${rightX} ${sourceY - r}`, + // Vertical upward + `L ${rightX} ${targetY + r}`, + // Arc turning left + `Q ${rightX} ${targetY} ${rightX - r} ${targetY}`, + // Horizontal left to target + `L ${targetX} ${targetY}`, + ]; + + return segments.join(" "); +} + export function ConditionEdge(props: EdgeProps) { const { id, @@ -23,25 +59,41 @@ export function ConditionEdge(props: EdgeProps) { const edgeData = data as ConditionEdgeData | undefined; const isFallback = edgeData?.isFallback ?? false; const isSelfLoop = source === target; + const isFeedback = edgeData?.isFeedback ?? false; - const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - borderRadius: isSelfLoop ? 20 : 8, - offset: isSelfLoop ? 50 : undefined, - }); + let path: string; + let defaultLabelX: number; + let defaultLabelY: number; + + if (isFeedback) { + // Custom feedback path routed to the right + path = feedbackPath(sourceX, sourceY, targetX, targetY); + const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X; + defaultLabelX = rightX; + defaultLabelY = (sourceY + targetY) / 2; + } else { + const result = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: isSelfLoop ? 20 : 8, + offset: isSelfLoop ? 50 : undefined, + }); + path = result[0]; + defaultLabelX = result[1]; + defaultLabelY = result[2]; + } const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)"; const strokeDasharray = isFallback ? "5 4" : undefined; const label = edgeData?.condition ?? ""; - // Use ELK-computed label position if available, otherwise fall back to ReactFlow default - const labelX = edgeData?.elkLabelX ?? defaultLabelX; - const labelY = edgeData?.elkLabelY ?? defaultLabelY; + // Use pre-computed label position if available, otherwise fall back to default + const labelX = edgeData?.labelX ?? defaultLabelX; + const labelY = edgeData?.labelY ?? defaultLabelY; return ( <> diff --git a/packages/workflow-dashboard/src/components/workflow-graph/types.ts b/packages/workflow-dashboard/src/components/workflow-graph/types.ts index 01c639a..f0a2a63 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/types.ts +++ b/packages/workflow-dashboard/src/components/workflow-graph/types.ts @@ -21,8 +21,10 @@ export type ConditionEdgeData = { condition: string; conditionDescription: string | null; isFallback: boolean; - elkLabelX: number | null; - elkLabelY: number | null; + isFeedback: boolean; + isSelfLoop: boolean; + labelX: number | null; + labelY: number | null; [key: string]: unknown; }; diff --git a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts index 76fddbb..4edb097 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts +++ b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts @@ -1,6 +1,5 @@ import type { Edge, Node } from "@xyflow/react"; -import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js"; -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import type { WorkflowGraphEdge } from "../../api.ts"; import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts"; @@ -10,6 +9,11 @@ const ROLE_NODE_WIDTH = 180; const ROLE_NODE_HEIGHT = 60; const TERMINAL_NODE_SIZE = 40; +// Vertical gap between nodes in the spine +const LAYER_GAP = 80; +// Horizontal offset for feedback (back) edges routed on the right side +const FEEDBACK_OFFSET_X = 100; + type LayoutInput = { edges: readonly WorkflowGraphEdge[]; roles: Record; @@ -21,15 +25,6 @@ type LayoutResult = { edges: Edge[]; }; -function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set { - const ids = new Set(); - for (const e of edges) { - ids.add(e.from); - ids.add(e.to); - } - return ids; -} - function nodeSize(id: string): { width: number; height: number } { if (id === START_ID || id === END_ID) { return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE }; @@ -41,6 +36,75 @@ function edgeKey(e: WorkflowGraphEdge): string { return `${e.from}->${e.to}::${e.condition}`; } +/** + * Extract the linear spine from the graph using topological ordering. + * Forward edges go from lower rank to higher rank; feedback edges go backwards. + * Self-loops are neither forward nor feedback — they're handled separately. + */ +function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] { + // Collect all node IDs + const ids = new Set(); + for (const e of edges) { + ids.add(e.from); + ids.add(e.to); + } + + // Build adjacency for forward edges only (non-self-loop, non-FALLBACK-back) + // Strategy: BFS from __start__, picking the first non-FALLBACK forward edge, + // or FALLBACK if no other option. + const forwardAdj = new Map(); + for (const e of edges) { + if (e.from === e.to) continue; + const existing = forwardAdj.get(e.from) ?? []; + existing.push(e.to); + forwardAdj.set(e.from, existing); + } + + // Walk the main path: prefer non-FALLBACK edges for the spine ordering + const visited = new Set(); + const spine: string[] = []; + + // Build a set of "primary" next targets per node (non-FALLBACK first) + const primaryNext = new Map(); + const edgesByFrom = new Map(); + for (const e of edges) { + if (e.from === e.to) continue; + const list = edgesByFrom.get(e.from) ?? []; + list.push(e); + edgesByFrom.set(e.from, list); + } + + // For each node, the "primary" next is the first non-FALLBACK target, + // or the FALLBACK target if all edges are FALLBACK + for (const [from, edgeList] of edgesByFrom) { + const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK"); + const fallback = edgeList.find((e) => e.condition === "FALLBACK"); + primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? ""); + } + + // Walk the spine from __start__ + let current: string | null = START_ID; + while (current !== null && !visited.has(current)) { + visited.add(current); + spine.push(current); + const next = primaryNext.get(current); + if (next !== undefined && next !== "" && !visited.has(next)) { + current = next; + } else { + current = null; + } + } + + // Add any remaining nodes not on the main path (shouldn't normally happen) + for (const id of ids) { + if (!visited.has(id)) { + spine.push(id); + } + } + + return spine; +} + function buildRoleNode( id: string, pos: { x: number; y: number }, @@ -72,143 +136,95 @@ function buildTerminalNode( }; } -function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map): Edge { - const isFallback = e.condition === "FALLBACK"; - const key = edgeKey(e); - const elkEdge = elkEdgeMap.get(key); - - // Extract ELK's computed label position - let labelX: number | null = null; - let labelY: number | null = null; - if (elkEdge?.labels && elkEdge.labels.length > 0) { - const label = elkEdge.labels[0]; - if (label.x !== undefined && label.y !== undefined) { - labelX = label.x + (label.width ?? 0) / 2; - labelY = label.y + (label.height ?? 0) / 2; - } +function computeLayout(input: LayoutInput): LayoutResult { + const spine = extractSpine(input.edges); + const rank = new Map(); + for (let i = 0; i < spine.length; i++) { + rank.set(spine[i], i); } - return { - id: key, - source: e.from, - target: e.to, - type: "condition", - data: { - condition: e.condition, - conditionDescription: e.conditionDescription, - isFallback, - elkLabelX: labelX, - elkLabelY: labelY, - }, - }; -} + // Position nodes along a vertical spine, centered horizontally + const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2 + const nodePositions = new Map(); -const elk = new ELK(); - -async function computeLayout(input: LayoutInput): Promise { - const ids = collectNodeIds(input.edges); - - const elkNodes: ElkNode[] = []; - for (const id of ids) { + let y = 0; + for (const id of spine) { const size = nodeSize(id); - elkNodes.push({ id, width: size.width, height: size.height }); - } - - const elkEdges: ElkExtendedEdge[] = input.edges - .filter((e) => e.from !== e.to) - .map((e) => ({ - id: edgeKey(e), - sources: [e.from], - targets: [e.to], - labels: e.condition !== "" - ? [{ text: e.condition, width: Math.max(e.condition.length * 7 + 16, 60), height: 22 }] - : [], - })); - - const graph: ElkNode = { - id: "root", - layoutOptions: { - "elk.algorithm": "layered", - "elk.direction": "DOWN", - // Node spacing - "elk.spacing.nodeNode": "30", - "elk.layered.spacing.nodeNodeBetweenLayers": "50", - // Edge spacing — keep edges apart from each other and from nodes - "elk.spacing.edgeNode": "25", - "elk.spacing.edgeEdge": "15", - "elk.layered.spacing.edgeNodeBetweenLayers": "25", - "elk.layered.spacing.edgeEdgeBetweenLayers": "15", - // Edge routing - "elk.edgeRouting": "ORTHOGONAL", - "elk.layered.mergeEdges": "false", - // Node placement - "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", - // Edge label placement - "elk.edgeLabels.placement": "CENTER", - // Crossing minimization - "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", - // Compaction - "elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH", - // Cycle breaking — keep main flow top-to-bottom - "elk.layered.cycleBreaking.strategy": "DEPTH_FIRST", - }, - children: elkNodes, - edges: elkEdges, - }; - - const laid = await elk.layout(graph); - - // Build map of ELK edge results for label positions - const elkEdgeMap = new Map(); - for (const e of laid.edges ?? []) { - elkEdgeMap.set(e.id, e); + // Center-align all nodes on the spine + const x = centerX - size.width / 2; + nodePositions.set(id, { x, y, w: size.width, h: size.height }); + y += size.height + LAYER_GAP; } + // Build nodes const nodes: Node[] = []; - for (const child of laid.children ?? []) { - const pos = { x: child.x ?? 0, y: child.y ?? 0 }; - const state = input.nodeStates.get(child.id) ?? "default"; - if (child.id === START_ID || child.id === END_ID) { - nodes.push(buildTerminalNode(child.id, pos, state)); + for (const id of spine) { + const pos = nodePositions.get(id); + if (pos === undefined) continue; + const state = input.nodeStates.get(id) ?? "default"; + if (id === START_ID || id === END_ID) { + nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state)); } else { - nodes.push(buildRoleNode(child.id, pos, input.roles, state)); + nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state)); } } - const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap)); + // Build edges with label positions + // For feedback edges (target rank < source rank), we'll compute label at midpoint + // of the right-side arc. The actual SVG path is drawn by ConditionEdge component. + const edges: Edge[] = input.edges.map((e) => { + const isFallback = e.condition === "FALLBACK"; + const isSelfLoop = e.from === e.to; + const sourceRank = rank.get(e.from) ?? 0; + const targetRank = rank.get(e.to) ?? 0; + const isFeedback = !isSelfLoop && targetRank <= sourceRank; + + const sourcePos = nodePositions.get(e.from); + const targetPos = nodePositions.get(e.to); + + let labelX: number | null = null; + let labelY: number | null = null; + + if (sourcePos !== undefined && targetPos !== undefined) { + if (isFeedback) { + // Label on the right side of the feedback arc + const rightX = centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X; + const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2; + labelX = rightX; + labelY = midY; + } else if (!isSelfLoop) { + // Forward edge: label between source bottom and target top + const midX = centerX; + const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2; + labelX = midX; + labelY = midY; + } + // Self-loop: let ReactFlow default handle it + } + + return { + id: edgeKey(e), + source: e.from, + target: e.to, + type: "condition", + data: { + condition: e.condition, + conditionDescription: e.conditionDescription, + isFallback, + isFeedback, + isSelfLoop, + labelX, + labelY, + }, + }; + }); return { nodes, edges }; } -const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] }; - export function useLayout(input: LayoutInput): LayoutResult { - const [layout, setLayout] = useState(EMPTY_LAYOUT); - - const edgeJson = JSON.stringify(input.edges); - const roleJson = JSON.stringify(input.roles); - - useEffect(() => { - let cancelled = false; - const parsed = { - edges: JSON.parse(edgeJson) as readonly WorkflowGraphEdge[], - roles: JSON.parse(roleJson) as Record, - nodeStates: input.nodeStates, - }; - computeLayout(parsed) - .then((result) => { - if (!cancelled) setLayout(result); - }) - .catch((err: unknown) => { - if (!cancelled) { - // biome-ignore lint/suspicious/noConsole: layout error reporting - console.error("ELK layout failed:", err); - } - }); - return () => { - cancelled = true; - }; - }, [edgeJson, roleJson, input.nodeStates]); - - return layout; + return useMemo( + () => computeLayout(input), + [input.edges, input.roles, input.nodeStates], + ); } diff --git a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx index 1fb3889..9c6da85 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx @@ -6,11 +6,9 @@ import { type NodeTypes, type OnNodeClick, ReactFlow, - ReactFlowProvider, - useReactFlow, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts"; import { ConditionEdge } from "./condition-edge.tsx"; import { RoleNode } from "./role-node.tsx"; @@ -39,30 +37,12 @@ function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node onRoleClick(node.id); } -function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) { +export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) { const layout = useLayout({ edges: graph.edges, roles, nodeStates }); - const { fitView } = useReactFlow(); const onNodeClickHandler: OnNodeClick | undefined = onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined; - // Re-fit when layout changes (ELK is async) - // Use requestAnimationFrame + setTimeout to ensure ReactFlow has processed nodes - useEffect(() => { - if (layout.nodes.length > 0) { - let cancelled = false; - requestAnimationFrame(() => { - if (cancelled) return; - setTimeout(() => { - if (!cancelled) fitView({ padding: 0.1, duration: 300 }); - }, 300); - }); - return () => { - cancelled = true; - }; - } - }, [layout.nodes, layout.edges, fitView]); - const styledEdges = useMemo( () => layout.edges.map((e) => ({ @@ -77,25 +57,17 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) { [layout.edges], ); - // Generate a stable key that changes when layout changes, to force ReactFlow remount + fitView - const layoutKey = useMemo( - () => layout.nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","), - [layout.nodes], - ); - return ( ); } - -export function WorkflowGraph(props: Props) { - return ( - - - - ); -}