diff --git a/src/components/shared/workflow-graph.tsx b/src/components/shared/workflow-graph.tsx index 40fdea8..7e2f2d5 100644 --- a/src/components/shared/workflow-graph.tsx +++ b/src/components/shared/workflow-graph.tsx @@ -16,8 +16,8 @@ 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 type { AgentArchetype } from '../../lib/types-swarm'; +import { useGraphAnalysis } from '../../hooks/use-graph-analysis'; import { GraphNodeCard, type GraphNodeData } from '../graph/graph-node-card'; export interface WorkflowGraphProps { @@ -26,6 +26,8 @@ export interface WorkflowGraphProps { onSelect?: (id: string) => void; className?: string; hideClosed?: boolean; + archetypes?: AgentArchetype[]; + assignMode?: boolean; } const NODE_WIDTH = 320; @@ -64,72 +66,20 @@ function WorkflowGraphInner({ onSelect, className = '', hideClosed = false, + archetypes = [], + assignMode = 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]); + // Use the extracted hook for all graph analysis + const { + signalById, + cycleNodeIdSet, + actionableNodeIds, + blockerTooltipMap, + blockerAnalysis, + chainNodeIds, + } = useGraphAnalysis(beads, 'workflow', selectedId); const flowModel = useMemo(() => { const visibleBeads = beads.filter((issue) => (!hideClosed ? true : issue.status !== 'closed')); @@ -138,25 +88,41 @@ function WorkflowGraphInner({ 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 baseNodes: Node[] = visibleBeads.map((issue) => { + let matchedArchetype: AgentArchetype | undefined; + if (archetypes && issue.assignee) { + const assigneeStr = issue.assignee.toLowerCase(); + matchedArchetype = archetypes.find(a => + assigneeStr.includes(a.id.toLowerCase()) || + assigneeStr.includes(a.name.toLowerCase()) + ); + } + + return { + 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) ?? [], + assignee: issue.assignee, + archetype: matchedArchetype, + isAssignMode: assignMode, + labels: issue.labels, + archetypes: archetypes, + }, + 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[] = []; @@ -170,13 +136,15 @@ function WorkflowGraphInner({ const edgeId = `${dep.target}:blocks:${issue.id}`; const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false; + const sourceIssue = beads.find((i) => i.id === dep.target); + const isInProgressEdge = issue.status === 'in_progress' || sourceIssue?.status === 'in_progress'; graphEdges.push({ id: edgeId, source: dep.target, target: issue.id, className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted', - animated: linkedToSelection, + animated: linkedToSelection || isInProgressEdge, label: 'BLOCKS', labelStyle: { fill: linkedToSelection ? '#e2e8f0' : '#cbd5e1', @@ -210,7 +178,7 @@ function WorkflowGraphInner({ nodes: layoutDagre(baseNodes, graphEdges), edges: graphEdges, }; - }, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap]); + }, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode]); const nodeTypes: NodeTypes = useMemo( () => ({ @@ -286,7 +254,7 @@ function WorkflowGraphInner({ - ); + ); } export function WorkflowGraph(props: WorkflowGraphProps) { @@ -296,3 +264,4 @@ export function WorkflowGraph(props: WorkflowGraphProps) { ); } + diff --git a/tests/components/graph/graph-node-labels.test.tsx b/tests/components/graph/graph-node-labels.test.tsx new file mode 100644 index 0000000..7e7b909 --- /dev/null +++ b/tests/components/graph/graph-node-labels.test.tsx @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs/promises'; +import path from 'path'; + +// Test that GraphNodeData interface includes labels field +test('GraphNodeData interface includes labels field', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8'); + // Check for labels in the interface + assert.ok(fileContent.includes('labels') && fileContent.includes('GraphNodeData'), 'GraphNodeData interface should include labels field'); +}); + +// Test that GraphNodeData labels is typed as string[] +test('GraphNodeData labels is typed as string array', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8'); + // Check for labels: string[] in the interface + assert.ok(/labels:\s*string\[\]/.test(fileContent), 'GraphNodeData labels should be typed as string[]'); +}); + +// Test that WorkflowGraph passes issue.labels to node data +test('WorkflowGraph passes issue.labels to node data', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/workflow-graph.tsx'), 'utf-8'); + // Check that issue.labels is passed to node data + assert.ok(fileContent.includes('labels: issue.labels'), 'WorkflowGraph should pass issue.labels to node data'); +}); + +// Test that WorkflowGraph uses labels from the issue object +test('WorkflowGraph uses labels from issue in node mapping', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/workflow-graph.tsx'), 'utf-8'); + // Check that labels is included in the data object for nodes + assert.ok(/data:\s*\{[^}]*labels/.test(fileContent), 'WorkflowGraph should include labels in node data object'); +});