diff --git a/src/components/shared/workflow-graph.tsx b/src/components/shared/workflow-graph.tsx new file mode 100644 index 0000000..08c05b5 --- /dev/null +++ b/src/components/shared/workflow-graph.tsx @@ -0,0 +1,301 @@ +'use client'; + +import { useCallback, useEffect, useMemo } from 'react'; +import { + Background, + MarkerType, + Position, + ReactFlow, + ReactFlowProvider, + useReactFlow, + type Edge, + type Node, + type NodeTypes, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import dagre from 'dagre'; + +import type { BeadIssue } from '../../lib/types'; +import { buildGraphModel } from '../../lib/graph'; +import { analyzeBlockedChain, detectDependencyCycles } from '../../lib/graph-view'; +import { GraphNodeCard, type GraphNodeData } from '../graph/graph-node-card'; + +export interface WorkflowGraphProps { + beads: BeadIssue[]; + selectedId?: string; + onSelect?: (id: string) => void; + className?: string; + hideClosed?: boolean; +} + +const NODE_WIDTH = 320; +const NODE_HEIGHT = 150; + +function layoutDagre(nodes: Node[], edges: Edge[]): Node[] { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + dagreGraph.setGraph({ rankdir: 'LR' }); + + for (const node of nodes) { + dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + + for (const edge of edges) { + dagreGraph.setEdge(edge.source, edge.target); + } + + dagre.layout(dagreGraph); + + return nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + return { + ...node, + position: { + x: nodeWithPosition.x - NODE_WIDTH / 2, + y: nodeWithPosition.y - NODE_HEIGHT / 2, + }, + }; + }); +} + +function WorkflowGraphInner({ + beads, + selectedId, + onSelect, + className = '', + hideClosed = false, +}: WorkflowGraphProps) { + const { fitView } = useReactFlow(); + + const graphModel = useMemo(() => buildGraphModel(beads, { projectKey: 'workflow' }), [beads]); + + const signalById = useMemo(() => { + const map = new Map(); + for (const issue of beads) { + const adjacency = graphModel.adjacency[issue.id]; + map.set(issue.id, { + blockedBy: adjacency?.incoming.length ?? 0, + blocks: adjacency?.outgoing.length ?? 0, + }); + } + return map; + }, [graphModel.adjacency, beads]); + + const cycleAnalysis = useMemo(() => detectDependencyCycles(graphModel), [graphModel]); + const cycleNodeIdSet = useMemo(() => new Set(cycleAnalysis.cycleNodeIds), [cycleAnalysis]); + + const blockerAnalysis = useMemo(() => { + if (!selectedId) return null; + return analyzeBlockedChain(graphModel, { focusId: selectedId }); + }, [graphModel, selectedId]); + + const chainNodeIds = useMemo(() => { + if (!selectedId || !blockerAnalysis) return new Set(); + const ids = new Set([selectedId, ...blockerAnalysis.blockerNodeIds]); + return ids; + }, [selectedId, blockerAnalysis]); + + const actionableNodeIds = useMemo(() => { + const ids = new Set(); + for (const issue of beads) { + if (issue.status === 'closed') continue; + const adjacency = graphModel.adjacency[issue.id]; + if (!adjacency) continue; + const hasOpenBlocker = adjacency.incoming.some((edge) => { + if (edge.type !== 'blocks') return false; + const sourceNode = beads.find((i) => i.id === edge.source); + return sourceNode ? sourceNode.status !== 'closed' : false; + }); + if (!hasOpenBlocker) { + ids.add(issue.id); + } + } + return ids; + }, [graphModel.adjacency, beads]); + + const blockerTooltipMap = useMemo(() => { + const map = new Map(); + for (const issue of beads) { + const adjacency = graphModel.adjacency[issue.id]; + if (!adjacency) continue; + const lines: string[] = []; + for (const edge of adjacency.incoming) { + if (edge.type !== 'blocks') continue; + const source = beads.find((i) => i.id === edge.source); + if (source && source.status !== 'closed') { + lines.push(`${source.id} (${source.status}) - "${source.title}"`); + } + } + map.set(issue.id, lines); + } + return map; + }, [graphModel.adjacency, beads]); + + const flowModel = useMemo(() => { + const visibleBeads = beads.filter((issue) => (!hideClosed ? true : issue.status !== 'closed')); + + if (visibleBeads.length === 0) { + return { nodes: [] as Node[], edges: [] as Edge[] }; + } + + const baseNodes: Node[] = visibleBeads.map((issue) => ({ + id: issue.id, + data: { + title: issue.title, + kind: 'issue' as const, + status: issue.status, + priority: issue.priority, + blockedBy: signalById.get(issue.id)?.blockedBy ?? 0, + blocks: signalById.get(issue.id)?.blocks ?? 0, + isActionable: actionableNodeIds.has(issue.id), + isCycleNode: cycleNodeIdSet.has(issue.id), + isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false, + blockerTooltipLines: blockerTooltipMap.get(issue.id) ?? [], + }, + position: { x: 0, y: 0 }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + type: 'flowNode', + })); + + const visibleIds = new Set(baseNodes.map((node) => node.id)); + const graphEdges: Edge[] = []; + + for (const issue of beads) { + for (const dep of issue.dependencies) { + if (!visibleIds.has(issue.id) && !visibleIds.has(dep.target)) continue; + if (!visibleIds.has(issue.id) || !visibleIds.has(dep.target)) continue; + if (dep.type !== 'blocks') continue; + if (issue.id === dep.target) continue; + + const edgeId = `${dep.target}:blocks:${issue.id}`; + const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false; + + graphEdges.push({ + id: edgeId, + source: dep.target, + target: issue.id, + className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted', + animated: linkedToSelection, + label: 'BLOCKS', + labelStyle: { + fill: linkedToSelection ? '#e2e8f0' : '#cbd5e1', + fontSize: 10, + fontWeight: 700, + letterSpacing: '0.08em', + }, + labelBgPadding: [6, 3], + labelBgBorderRadius: 999, + labelBgStyle: { + fill: 'rgba(2, 6, 23, 0.92)', + stroke: linkedToSelection ? 'rgba(125, 211, 252, 0.35)' : 'rgba(251, 191, 36, 0.25)', + strokeWidth: 1, + }, + style: { + stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24', + strokeWidth: linkedToSelection ? 2.8 : 2.1, + opacity: linkedToSelection ? 1 : 0.78, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: linkedToSelection ? '#7dd3fc' : '#fbbf24', + width: 14, + height: 14, + }, + }); + } + } + + return { + nodes: layoutDagre(baseNodes, graphEdges), + edges: graphEdges, + }; + }, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap]); + + const nodeTypes: NodeTypes = useMemo( + () => ({ + flowNode: GraphNodeCard as NodeTypes['flowNode'], + }), + [], + ); + + const defaultEdgeOptions = useMemo( + () => ({ + type: 'smoothstep' as const, + zIndex: 40, + interactionWidth: 24, + }), + [], + ); + + const handleNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + onSelect?.(node.id); + }, + [onSelect], + ); + + useEffect(() => { + const timeout = setTimeout(() => { + fitView({ padding: 0.3, duration: 200 }); + }, 50); + return () => clearTimeout(timeout); + }, [fitView, flowModel.nodes.length]); + + return ( +
+
+

+ Legend{' '} + {!hideClosed ? ( + <> + Done + {' \u2192 '} + + ) : null} + In Progress + {' \u2192 '} + Ready + {' \u2192 '} + Blocked +

+ {blockerAnalysis ? ( +

+ Open blockers: {blockerAnalysis.openBlockerCount} +

+ ) : null} +
+ + + +
+ ); +} + +export function WorkflowGraph(props: WorkflowGraphProps) { + return ( + + + + ); +}