feat: add dependency graph view for task visualization

Add a new interactive graph view alongside the kanban board for visualizing
task dependencies. The graph view uses React Flow with dagre auto-layout to
display tasks as nodes connected by dependency edges.

Key features:
- Toggle between kanban and graph view via new control buttons
- Custom TaskNode component matching existing card styling/themes
- Animated edges that flow when tasks are in progress
- Status-aware node colors (backlog, in-progress, waiting, verified)
- Blocked tasks show lock icon with dependency count tooltip
- MiniMap for navigation in large graphs
- Zoom, pan, fit-view, and lock controls
- Horizontal/vertical layout options via dagre
- Click node to view details, double-click to edit
- Respects all 32 themes via CSS variables
- Reduced motion support for animations

New dependencies: @xyflow/react, dagre
This commit is contained in:
Claude
2025-12-22 19:10:32 +00:00
parent a85dec6dbb
commit b930091c42
17 changed files with 1540 additions and 35 deletions

View File

@@ -0,0 +1,2 @@
export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes';
export { useGraphLayout } from './use-graph-layout';

View File

@@ -0,0 +1,93 @@
import { useCallback, useMemo } from 'react';
import dagre from 'dagre';
import { Node, Edge, useReactFlow } from '@xyflow/react';
import { TaskNode, DependencyEdge } from './use-graph-nodes';
const NODE_WIDTH = 280;
const NODE_HEIGHT = 120;
interface UseGraphLayoutProps {
nodes: TaskNode[];
edges: DependencyEdge[];
}
/**
* Applies dagre layout to position nodes in a hierarchical DAG
* Dependencies flow left-to-right
*/
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
const { fitView, setNodes } = useReactFlow();
const getLayoutedElements = useCallback(
(
inputNodes: TaskNode[],
inputEdges: DependencyEdge[],
direction: 'LR' | 'TB' = 'LR'
): { nodes: TaskNode[]; edges: DependencyEdge[] } => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const isHorizontal = direction === 'LR';
dagreGraph.setGraph({
rankdir: direction,
nodesep: 50,
ranksep: 100,
marginx: 50,
marginy: 50,
});
inputNodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
inputEdges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = inputNodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
} as TaskNode;
});
return { nodes: layoutedNodes, edges: inputEdges };
},
[]
);
// Initial layout
const layoutedElements = useMemo(() => {
if (nodes.length === 0) {
return { nodes: [], edges: [] };
}
return getLayoutedElements(nodes, edges, 'LR');
}, [nodes, edges, getLayoutedElements]);
// Manual re-layout function
const runLayout = useCallback(
(direction: 'LR' | 'TB' = 'LR') => {
const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, direction);
setNodes(layoutedNodes);
// Fit view after layout with a small delay to allow DOM updates
setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 50);
},
[nodes, edges, getLayoutedElements, setNodes, fitView]
);
return {
layoutedNodes: layoutedElements.nodes,
layoutedEdges: layoutedElements.edges,
runLayout,
};
}

View File

@@ -0,0 +1,79 @@
import { useMemo } from 'react';
import { Node, Edge } from '@xyflow/react';
import { Feature } from '@/store/app-store';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
export interface TaskNodeData extends Feature {
isBlocked: boolean;
isRunning: boolean;
blockingDependencies: string[];
}
export type TaskNode = Node<TaskNodeData, 'task'>;
export type DependencyEdge = Edge<{ sourceStatus: Feature['status']; targetStatus: Feature['status'] }>;
interface UseGraphNodesProps {
features: Feature[];
runningAutoTasks: string[];
}
/**
* Transforms features into React Flow nodes and edges
* Creates dependency edges based on feature.dependencies array
*/
export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = [];
const featureMap = new Map<string, Feature>();
// Create feature map for quick lookups
features.forEach((f) => featureMap.set(f.id, f));
// Create nodes
features.forEach((feature) => {
const isRunning = runningAutoTasks.includes(feature.id);
const blockingDeps = getBlockingDependencies(feature, features);
const node: TaskNode = {
id: feature.id,
type: 'task',
position: { x: 0, y: 0 }, // Will be set by layout
data: {
...feature,
isBlocked: blockingDeps.length > 0,
isRunning,
blockingDependencies: blockingDeps,
},
};
nodeList.push(node);
// Create edges for dependencies
if (feature.dependencies && feature.dependencies.length > 0) {
feature.dependencies.forEach((depId: string) => {
// Only create edge if the dependency exists in current view
if (featureMap.has(depId)) {
const sourceFeature = featureMap.get(depId)!;
const edge: DependencyEdge = {
id: `${depId}->${feature.id}`,
source: depId,
target: feature.id,
type: 'dependency',
animated: isRunning || runningAutoTasks.includes(depId),
data: {
sourceStatus: sourceFeature.status,
targetStatus: feature.status,
},
};
edgeList.push(edge);
}
});
}
});
return { nodes: nodeList, edges: edgeList };
}, [features, runningAutoTasks]);
return { nodes, edges };
}