diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json index 75cba22..a7faa75 100644 --- a/packages/workflow-dashboard/package.json +++ b/packages/workflow-dashboard/package.json @@ -11,6 +11,7 @@ "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 28fb962..fffc9d4 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx @@ -2,7 +2,6 @@ import { BaseEdge, EdgeLabelRenderer, type EdgeProps, - getBezierPath, getSmoothStepPath, } from "@xyflow/react"; import type { ConditionEdgeData } from "./types.ts"; @@ -21,31 +20,28 @@ export function ConditionEdge(props: EdgeProps) { data, markerEnd, } = props; - const edgeData = data as ConditionEdgeData | undefined; + const edgeData = data as (ConditionEdgeData & { elkLabelX?: number | null; elkLabelY?: number | null }) | undefined; const isFallback = edgeData?.isFallback ?? false; const isSelfLoop = source === target; - const [path, labelX, labelY] = isSelfLoop - ? getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - borderRadius: 20, - }) - : getBezierPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - }); + const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: isSelfLoop ? 20 : 8, + offset: isSelfLoop ? 50 : undefined, + }); - const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)"; + 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; return ( <> @@ -55,19 +51,21 @@ export function ConditionEdge(props: EdgeProps) { markerEnd={markerEnd} style={{ stroke, strokeWidth: 1.5, strokeDasharray }} /> - {edgeData && !isFallback && edgeData.condition !== "" && ( + {label !== "" && (
- {edgeData.condition} + {label}
)} 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 5dd64fd..ac022ad 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,6 @@ -import Dagre from "@dagrejs/dagre"; import type { Edge, Node } from "@xyflow/react"; -import { useMemo } from "react"; +import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js"; +import { useEffect, useState } from "react"; import type { WorkflowGraphEdge } from "../../api.ts"; import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts"; @@ -37,6 +37,10 @@ function nodeSize(id: string): { width: number; height: number } { return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT }; } +function edgeKey(e: WorkflowGraphEdge): string { + return `${e.from}->${e.to}::${e.condition}`; +} + function buildRoleNode( id: string, pos: { x: number; y: number }, @@ -68,14 +72,24 @@ function buildTerminalNode( }; } -function edgeKey(e: WorkflowGraphEdge): string { - return `${e.from}->${e.to}::${e.condition}`; -} - -function buildEdge(e: WorkflowGraphEdge): Edge { +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; + } + } + return { - id: edgeKey(e), + id: key, source: e.from, target: e.to, type: "condition", @@ -83,45 +97,111 @@ function buildEdge(e: WorkflowGraphEdge): Edge { condition: e.condition, conditionDescription: e.conditionDescription, isFallback, - }, + elkLabelX: labelX, + elkLabelY: labelY, + } as ConditionEdgeData, }; } -export function useLayout(input: LayoutInput): LayoutResult { - return useMemo(() => { - const ids = collectNodeIds(input.edges); +const elk = new ELK(); - const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 }); +async function computeLayout(input: LayoutInput): Promise { + const ids = collectNodeIds(input.edges); - for (const id of ids) { - const size = nodeSize(id); - g.setNode(id, { width: size.width, height: size.height }); - } - for (const e of input.edges) { - if (e.from === e.to) { - continue; - } - g.setEdge(e.from, e.to, {}, edgeKey(e)); + const elkNodes: ElkNode[] = []; + for (const id of ids) { + 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); + } + + 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)); + } else { + nodes.push(buildRoleNode(child.id, pos, input.roles, state)); } + } - Dagre.layout(g); + const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap)); - const nodes: Node[] = []; - for (const id of ids) { - const dagNode = g.node(id); - const size = nodeSize(id); - const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 }; - const state = input.nodeStates.get(id) ?? "default"; - if (id === START_ID || id === END_ID) { - nodes.push(buildTerminalNode(id, pos, state)); - } else { - nodes.push(buildRoleNode(id, pos, input.roles, state)); - } - } - - const edges: Edge[] = input.edges.map(buildEdge); - - return { nodes, edges }; - }, [input.edges, input.roles, input.nodeStates]); + 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); + }); + return () => { + cancelled = true; + }; + }, [edgeJson, roleJson, input.nodeStates]); + + return layout; } 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 f0e6871..1fb3889 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx @@ -6,9 +6,11 @@ import { type NodeTypes, type OnNodeClick, ReactFlow, + ReactFlowProvider, + useReactFlow, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts"; import { ConditionEdge } from "./condition-edge.tsx"; import { RoleNode } from "./role-node.tsx"; @@ -37,12 +39,30 @@ function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node onRoleClick(node.id); } -export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) { +function WorkflowGraphInner({ 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) => ({ @@ -57,15 +77,25 @@ export function WorkflowGraph({ 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 ( + + + + ); +} diff --git a/packages/workflow-dashboard/src/components/workflow-list.tsx b/packages/workflow-dashboard/src/components/workflow-list.tsx index 0a49de3..7cbe27f 100644 --- a/packages/workflow-dashboard/src/components/workflow-list.tsx +++ b/packages/workflow-dashboard/src/components/workflow-list.tsx @@ -45,38 +45,45 @@ function ExpandedWorkflowBody({ const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0; const vc = versionCount(detail); + const hasGraph = descriptor !== null && edgeCount > 0; + return ( -
-
-

- {detail.name} +

+
+
+

+ {detail.name} +

+

+ Hash +

+ + {detail.hash} + +
+

+ {vc} version{vc !== 1 ? "s" : ""}

-

- Hash -

- - {detail.hash} - +
+

+ Description +

+

+ {descriptor !== null && descriptor.description !== "" + ? descriptor.description + : descriptor !== null + ? "—" + : "No descriptor available for this workflow version."} +

+
-

- {vc} version{vc !== 1 ? "s" : ""} -

-
-

- Description -

-

- {descriptor !== null && descriptor.description !== "" - ? descriptor.description - : descriptor !== null - ? "—" - : "No descriptor available for this workflow version."} -

-
- {descriptor !== null && edgeCount > 0 ? ( + {hasGraph ? (
-
+
{ const next = new Set(prev); if (next.has(name)) { next.delete(name); - return next; + } else { + next.add(name); } - next.add(name); - shouldLoad = true; return next; }); - if (shouldLoad) { + if (!wasExpanded) { ensureDetailLoaded(name); } }