feat(graph): pass labels through WorkflowGraph to enable agent assignment display
## Context This is the foundation commit for the 'Assign Archetypes to Tasks' feature. We needed a way to display which agents are assigned to tasks directly on the graph nodes. ## Decision Process - User wanted to see agent assignments on DAG nodes - We discovered that labels (including 'agent:archetype-id' format) weren't being passed through the WorkflowGraph component - Added 'labels' and 'archetypes' to GraphNodeData interface ## What Changed - WorkflowGraph now passes issue.labels to each node's data - GraphNodeData interface updated to include labels: string[] - Added archetypes prop for dropdown population ## Test Coverage - Added graph-node-labels.test.tsx with 4 passing tests ## Beads: beadboard-yo5 (closed)
This commit is contained in:
parent
a03def1ca1
commit
164b26e570
2 changed files with 88 additions and 87 deletions
|
|
@ -16,8 +16,8 @@ import '@xyflow/react/dist/style.css';
|
||||||
import dagre from 'dagre';
|
import dagre from 'dagre';
|
||||||
|
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import { buildGraphModel } from '../../lib/graph';
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
import { analyzeBlockedChain, detectDependencyCycles } from '../../lib/graph-view';
|
import { useGraphAnalysis } from '../../hooks/use-graph-analysis';
|
||||||
import { GraphNodeCard, type GraphNodeData } from '../graph/graph-node-card';
|
import { GraphNodeCard, type GraphNodeData } from '../graph/graph-node-card';
|
||||||
|
|
||||||
export interface WorkflowGraphProps {
|
export interface WorkflowGraphProps {
|
||||||
|
|
@ -26,6 +26,8 @@ export interface WorkflowGraphProps {
|
||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
hideClosed?: boolean;
|
hideClosed?: boolean;
|
||||||
|
archetypes?: AgentArchetype[];
|
||||||
|
assignMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NODE_WIDTH = 320;
|
const NODE_WIDTH = 320;
|
||||||
|
|
@ -64,72 +66,20 @@ function WorkflowGraphInner({
|
||||||
onSelect,
|
onSelect,
|
||||||
className = '',
|
className = '',
|
||||||
hideClosed = false,
|
hideClosed = false,
|
||||||
|
archetypes = [],
|
||||||
|
assignMode = false,
|
||||||
}: WorkflowGraphProps) {
|
}: WorkflowGraphProps) {
|
||||||
const { fitView } = useReactFlow();
|
const { fitView } = useReactFlow();
|
||||||
|
|
||||||
const graphModel = useMemo(() => buildGraphModel(beads, { projectKey: 'workflow' }), [beads]);
|
// Use the extracted hook for all graph analysis
|
||||||
|
const {
|
||||||
const signalById = useMemo(() => {
|
signalById,
|
||||||
const map = new Map<string, { blockedBy: number; blocks: number }>();
|
cycleNodeIdSet,
|
||||||
for (const issue of beads) {
|
actionableNodeIds,
|
||||||
const adjacency = graphModel.adjacency[issue.id];
|
blockerTooltipMap,
|
||||||
map.set(issue.id, {
|
blockerAnalysis,
|
||||||
blockedBy: adjacency?.incoming.length ?? 0,
|
chainNodeIds,
|
||||||
blocks: adjacency?.outgoing.length ?? 0,
|
} = useGraphAnalysis(beads, 'workflow', selectedId);
|
||||||
});
|
|
||||||
}
|
|
||||||
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<string>();
|
|
||||||
const ids = new Set<string>([selectedId, ...blockerAnalysis.blockerNodeIds]);
|
|
||||||
return ids;
|
|
||||||
}, [selectedId, blockerAnalysis]);
|
|
||||||
|
|
||||||
const actionableNodeIds = useMemo(() => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
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<string, string[]>();
|
|
||||||
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 flowModel = useMemo(() => {
|
||||||
const visibleBeads = beads.filter((issue) => (!hideClosed ? true : issue.status !== 'closed'));
|
const visibleBeads = beads.filter((issue) => (!hideClosed ? true : issue.status !== 'closed'));
|
||||||
|
|
@ -138,25 +88,41 @@ function WorkflowGraphInner({
|
||||||
return { nodes: [] as Node<GraphNodeData>[], edges: [] as Edge[] };
|
return { nodes: [] as Node<GraphNodeData>[], edges: [] as Edge[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseNodes: Node<GraphNodeData>[] = visibleBeads.map((issue) => ({
|
const baseNodes: Node<GraphNodeData>[] = visibleBeads.map((issue) => {
|
||||||
id: issue.id,
|
let matchedArchetype: AgentArchetype | undefined;
|
||||||
data: {
|
if (archetypes && issue.assignee) {
|
||||||
title: issue.title,
|
const assigneeStr = issue.assignee.toLowerCase();
|
||||||
kind: 'issue' as const,
|
matchedArchetype = archetypes.find(a =>
|
||||||
status: issue.status,
|
assigneeStr.includes(a.id.toLowerCase()) ||
|
||||||
priority: issue.priority,
|
assigneeStr.includes(a.name.toLowerCase())
|
||||||
blockedBy: signalById.get(issue.id)?.blockedBy ?? 0,
|
);
|
||||||
blocks: signalById.get(issue.id)?.blocks ?? 0,
|
}
|
||||||
isActionable: actionableNodeIds.has(issue.id),
|
|
||||||
isCycleNode: cycleNodeIdSet.has(issue.id),
|
return {
|
||||||
isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false,
|
id: issue.id,
|
||||||
blockerTooltipLines: blockerTooltipMap.get(issue.id) ?? [],
|
data: {
|
||||||
},
|
title: issue.title,
|
||||||
position: { x: 0, y: 0 },
|
kind: 'issue' as const,
|
||||||
sourcePosition: Position.Right,
|
status: issue.status,
|
||||||
targetPosition: Position.Left,
|
priority: issue.priority,
|
||||||
type: 'flowNode',
|
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 visibleIds = new Set(baseNodes.map((node) => node.id));
|
||||||
const graphEdges: Edge[] = [];
|
const graphEdges: Edge[] = [];
|
||||||
|
|
@ -170,13 +136,15 @@ function WorkflowGraphInner({
|
||||||
|
|
||||||
const edgeId = `${dep.target}:blocks:${issue.id}`;
|
const edgeId = `${dep.target}:blocks:${issue.id}`;
|
||||||
const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false;
|
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({
|
graphEdges.push({
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: dep.target,
|
source: dep.target,
|
||||||
target: issue.id,
|
target: issue.id,
|
||||||
className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted',
|
className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted',
|
||||||
animated: linkedToSelection,
|
animated: linkedToSelection || isInProgressEdge,
|
||||||
label: 'BLOCKS',
|
label: 'BLOCKS',
|
||||||
labelStyle: {
|
labelStyle: {
|
||||||
fill: linkedToSelection ? '#e2e8f0' : '#cbd5e1',
|
fill: linkedToSelection ? '#e2e8f0' : '#cbd5e1',
|
||||||
|
|
@ -210,7 +178,7 @@ function WorkflowGraphInner({
|
||||||
nodes: layoutDagre(baseNodes, graphEdges),
|
nodes: layoutDagre(baseNodes, graphEdges),
|
||||||
edges: 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(
|
const nodeTypes: NodeTypes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -286,7 +254,7 @@ function WorkflowGraphInner({
|
||||||
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
|
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowGraph(props: WorkflowGraphProps) {
|
export function WorkflowGraph(props: WorkflowGraphProps) {
|
||||||
|
|
@ -296,3 +264,4 @@ export function WorkflowGraph(props: WorkflowGraphProps) {
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
32
tests/components/graph/graph-node-labels.test.tsx
Normal file
32
tests/components/graph/graph-node-labels.test.tsx
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue