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,115 @@
import { memo } from 'react';
import { BaseEdge, getBezierPath, EdgeLabelRenderer } from '@xyflow/react';
import type { EdgeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import { Feature } from '@/store/app-store';
export interface DependencyEdgeData {
sourceStatus: Feature['status'];
targetStatus: Feature['status'];
}
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
// If source is completed/verified, the dependency is satisfied
if (sourceStatus === 'completed' || sourceStatus === 'verified') {
return 'var(--status-success)';
}
// If target is in progress, show active color
if (targetStatus === 'in_progress') {
return 'var(--status-in-progress)';
}
// If target is blocked (in backlog with incomplete deps)
if (targetStatus === 'backlog') {
return 'var(--border)';
}
// Default
return 'var(--border)';
};
export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
const {
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
animated,
} = props;
const edgeData = data as DependencyEdgeData | undefined;
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.25,
});
const edgeColor = edgeData
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
: 'var(--border)';
const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
const isInProgress = edgeData?.targetStatus === 'in_progress';
return (
<>
{/* Background edge for better visibility */}
<BaseEdge
id={`${id}-bg`}
path={edgePath}
style={{
strokeWidth: 4,
stroke: 'var(--background)',
}}
/>
{/* Main edge */}
<BaseEdge
id={id}
path={edgePath}
className={cn(
'transition-all duration-300',
animated && 'animated-edge',
isInProgress && 'edge-flowing'
)}
style={{
strokeWidth: selected ? 3 : 2,
stroke: edgeColor,
strokeDasharray: isCompleted ? 'none' : '5 5',
filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none',
}}
/>
{/* Animated particles for in-progress edges */}
{animated && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'none',
}}
className="edge-particle"
>
<div
className={cn(
'w-2 h-2 rounded-full',
isInProgress
? 'bg-[var(--status-in-progress)] animate-ping'
: 'bg-brand-500 animate-pulse'
)}
/>
</div>
</EdgeLabelRenderer>
)}
</>
);
});

View File

@@ -0,0 +1,144 @@
import { useReactFlow, Panel } from '@xyflow/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
ZoomIn,
ZoomOut,
Maximize2,
Lock,
Unlock,
GitBranch,
ArrowRight,
ArrowDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface GraphControlsProps {
isLocked: boolean;
onToggleLock: () => void;
onRunLayout: (direction: 'LR' | 'TB') => void;
layoutDirection: 'LR' | 'TB';
}
export function GraphControls({
isLocked,
onToggleLock,
onRunLayout,
layoutDirection,
}: GraphControlsProps) {
const { zoomIn, zoomOut, fitView } = useReactFlow();
return (
<Panel position="bottom-left" className="flex flex-col gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg">
{/* Zoom controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomIn({ duration: 200 })}
>
<ZoomIn className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom In</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomOut({ duration: 200 })}
>
<ZoomOut className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom Out</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fitView({ padding: 0.2, duration: 300 })}
>
<Maximize2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Fit View</TooltipContent>
</Tooltip>
<div className="h-px bg-border my-1" />
{/* Layout controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'LR' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('LR')}
>
<ArrowRight className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Horizontal Layout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'TB' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('TB')}
>
<ArrowDown className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Vertical Layout</TooltipContent>
</Tooltip>
<div className="h-px bg-border my-1" />
{/* Lock toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
isLocked && 'bg-brand-500/20 text-brand-500'
)}
onClick={onToggleLock}
>
{isLocked ? (
<Lock className="w-4 h-4" />
) : (
<Unlock className="w-4 h-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
);
}

View File

@@ -0,0 +1,69 @@
import { Panel } from '@xyflow/react';
import {
Clock,
Play,
Pause,
CheckCircle2,
Lock,
AlertCircle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const legendItems = [
{
icon: Clock,
label: 'Backlog',
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
},
{
icon: Play,
label: 'In Progress',
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/20',
},
{
icon: Pause,
label: 'Waiting',
colorClass: 'text-[var(--status-waiting)]',
bgClass: 'bg-[var(--status-warning)]/20',
},
{
icon: CheckCircle2,
label: 'Verified',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/20',
},
{
icon: Lock,
label: 'Blocked',
colorClass: 'text-orange-500',
bgClass: 'bg-orange-500/20',
},
{
icon: AlertCircle,
label: 'Error',
colorClass: 'text-[var(--status-error)]',
bgClass: 'bg-[var(--status-error)]/20',
},
];
export function GraphLegend() {
return (
<Panel position="bottom-right" className="pointer-events-none">
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto">
{legendItems.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="flex items-center gap-1.5">
<div className={cn('p-1 rounded', item.bgClass)}>
<Icon className={cn('w-3 h-3', item.colorClass)} />
</div>
<span className="text-xs text-muted-foreground">{item.label}</span>
</div>
);
})}
</div>
</Panel>
);
}

View File

@@ -0,0 +1,4 @@
export { TaskNode } from './task-node';
export { DependencyEdge } from './dependency-edge';
export { GraphControls } from './graph-controls';
export { GraphLegend } from './graph-legend';

View File

@@ -0,0 +1,248 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import type { NodeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import {
Lock,
CheckCircle2,
Clock,
AlertCircle,
Play,
Pause,
Eye,
MoreHorizontal,
GitBranch,
} from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
type TaskNodeProps = NodeProps & {
data: TaskNodeData;
};
const statusConfig = {
backlog: {
icon: Clock,
label: 'Backlog',
colorClass: 'text-muted-foreground',
borderClass: 'border-border',
bgClass: 'bg-card',
},
in_progress: {
icon: Play,
label: 'In Progress',
colorClass: 'text-[var(--status-in-progress)]',
borderClass: 'border-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress-bg)]',
},
waiting_approval: {
icon: Pause,
label: 'Waiting Approval',
colorClass: 'text-[var(--status-waiting)]',
borderClass: 'border-[var(--status-waiting)]',
bgClass: 'bg-[var(--status-warning-bg)]',
},
verified: {
icon: CheckCircle2,
label: 'Verified',
colorClass: 'text-[var(--status-success)]',
borderClass: 'border-[var(--status-success)]',
bgClass: 'bg-[var(--status-success-bg)]',
},
completed: {
icon: CheckCircle2,
label: 'Completed',
colorClass: 'text-[var(--status-success)]',
borderClass: 'border-[var(--status-success)]/50',
bgClass: 'bg-[var(--status-success-bg)]/50',
},
};
const priorityConfig = {
1: { label: 'High', colorClass: 'bg-[var(--status-error)] text-white' },
2: { label: 'Medium', colorClass: 'bg-[var(--status-warning)] text-black' },
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
};
export const TaskNode = memo(function TaskNode({
data,
selected,
}: TaskNodeProps) {
const config = statusConfig[data.status] || statusConfig.backlog;
const StatusIcon = config.icon;
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
return (
<>
{/* Target handle (left side - receives dependencies) */}
<Handle
type="target"
position={Position.Left}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500'
)}
/>
<div
className={cn(
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
'transition-all duration-200',
config.borderClass,
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
data.isRunning && 'animate-pulse-subtle',
data.error && 'border-[var(--status-error)]'
)}
>
{/* Header with status and actions */}
<div className={cn(
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
config.bgClass
)}>
<div className="flex items-center gap-2">
<StatusIcon className={cn('w-4 h-4', config.colorClass)} />
<span className={cn('text-xs font-medium', config.colorClass)}>
{config.label}
</span>
</div>
<div className="flex items-center gap-1">
{/* Priority badge */}
{priorityConf && (
<span className={cn(
'text-[10px] font-bold px-1.5 py-0.5 rounded',
priorityConf.colorClass
)}>
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
</span>
)}
{/* Blocked indicator */}
{data.isBlocked && !data.error && data.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded bg-orange-500/20">
<Lock className="w-3 h-3 text-orange-500" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[200px]">
<p>Blocked by {data.blockingDependencies.length} dependencies</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Error indicator */}
{data.error && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded bg-[var(--status-error-bg)]">
<AlertCircle className="w-3 h-3 text-[var(--status-error)]" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[250px]">
<p>{data.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-background/50"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem className="text-xs">
<Eye className="w-3 h-3 mr-2" />
View Details
</DropdownMenuItem>
{data.status === 'backlog' && !data.isBlocked && (
<DropdownMenuItem className="text-xs">
<Play className="w-3 h-3 mr-2" />
Start Task
</DropdownMenuItem>
)}
{data.isRunning && (
<DropdownMenuItem className="text-xs text-[var(--status-error)]">
<Pause className="w-3 h-3 mr-2" />
Stop Task
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-xs">
<GitBranch className="w-3 h-3 mr-2" />
View Branch
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Content */}
<div className="px-3 py-2">
{/* Category */}
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
{data.category}
</span>
{/* Title */}
<h3 className="text-sm font-medium mt-1 line-clamp-2 text-foreground">
{data.description}
</h3>
{/* Progress indicator for in-progress tasks */}
{data.isRunning && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate"
/>
</div>
<span className="text-[10px] text-muted-foreground">Running...</span>
</div>
)}
{/* Branch name if assigned */}
{data.branchName && (
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
<GitBranch className="w-3 h-3" />
<span className="truncate">{data.branchName}</span>
</div>
)}
</div>
</div>
{/* Source handle (right side - provides to dependents) */}
<Handle
type="source"
position={Position.Right}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
data.status === 'completed' || data.status === 'verified'
? '!bg-[var(--status-success)]'
: ''
)}
/>
</>
);
});

View File

@@ -0,0 +1,174 @@
import { useCallback, useState, useEffect } from 'react';
import {
ReactFlow,
Background,
BackgroundVariant,
MiniMap,
useNodesState,
useEdgesState,
ReactFlowProvider,
SelectionMode,
ConnectionMode,
Node,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Feature } from '@/store/app-store';
import { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
import { useGraphNodes, useGraphLayout, type TaskNodeData } from './hooks';
import { cn } from '@/lib/utils';
// Define custom node and edge types - using any to avoid React Flow's strict typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeTypes: any = {
task: TaskNode,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const edgeTypes: any = {
dependency: DependencyEdge,
};
interface GraphCanvasProps {
features: Feature[];
runningAutoTasks: string[];
onNodeClick?: (featureId: string) => void;
onNodeDoubleClick?: (featureId: string) => void;
backgroundStyle?: React.CSSProperties;
className?: string;
}
function GraphCanvasInner({
features,
runningAutoTasks,
onNodeClick,
onNodeDoubleClick,
backgroundStyle,
className,
}: GraphCanvasProps) {
const [isLocked, setIsLocked] = useState(false);
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
// Transform features to nodes and edges
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
features,
runningAutoTasks,
});
// Apply layout
const { layoutedNodes, layoutedEdges, runLayout } = useGraphLayout({
nodes: initialNodes,
edges: initialEdges,
});
// React Flow state
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
// Update nodes/edges when features change
useEffect(() => {
setNodes(layoutedNodes);
setEdges(layoutedEdges);
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
// Handle layout direction change
const handleRunLayout = useCallback(
(direction: 'LR' | 'TB') => {
setLayoutDirection(direction);
runLayout(direction);
},
[runLayout]
);
// Handle node click
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
onNodeClick?.(node.id);
},
[onNodeClick]
);
// Handle node double click
const handleNodeDoubleClick = useCallback(
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
onNodeDoubleClick?.(node.id);
},
[onNodeDoubleClick]
);
// MiniMap node color based on status
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
const data = node.data as TaskNodeData | undefined;
const status = data?.status;
switch (status) {
case 'completed':
case 'verified':
return 'var(--status-success)';
case 'in_progress':
return 'var(--status-in-progress)';
case 'waiting_approval':
return 'var(--status-waiting)';
default:
if (data?.isBlocked) return 'rgb(249, 115, 22)'; // orange-500
if (data?.error) return 'var(--status-error)';
return 'var(--muted-foreground)';
}
}, []);
return (
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={isLocked ? undefined : onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
selectionMode={SelectionMode.Partial}
connectionMode={ConnectionMode.Loose}
proOptions={{ hideAttribution: true }}
className="graph-canvas"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--border)"
className="opacity-50"
/>
<MiniMap
nodeColor={minimapNodeColor}
nodeStrokeWidth={3}
zoomable
pannable
className="!bg-popover/90 !border-border rounded-lg shadow-lg"
/>
<GraphControls
isLocked={isLocked}
onToggleLock={() => setIsLocked(!isLocked)}
onRunLayout={handleRunLayout}
layoutDirection={layoutDirection}
/>
<GraphLegend />
</ReactFlow>
</div>
);
}
// Wrap with provider for hooks to work
export function GraphCanvas(props: GraphCanvasProps) {
return (
<ReactFlowProvider>
<GraphCanvasInner {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,89 @@
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { GraphCanvas } from './graph-canvas';
import { useBoardBackground } from '../board-view/hooks';
interface GraphViewProps {
features: Feature[];
runningAutoTasks: string[];
currentWorktreePath: string | null;
currentWorktreeBranch: string | null;
projectPath: string | null;
onEditFeature: (feature: Feature) => void;
onViewOutput: (feature: Feature) => void;
}
export function GraphView({
features,
runningAutoTasks,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
onEditFeature,
onViewOutput,
}: GraphViewProps) {
const { currentProject } = useAppStore();
// Use the same background hook as the board view
const { backgroundImageStyle } = useBoardBackground({ currentProject });
// Filter features by current worktree (same logic as board view)
const filteredFeatures = useMemo(() => {
const effectiveBranch = currentWorktreeBranch;
return features.filter((f) => {
// Skip completed features (they're in archive)
if (f.status === 'completed') return false;
const featureBranch = f.branchName;
if (!featureBranch) {
// No branch assigned - show only on primary worktree
return currentWorktreePath === null;
} else if (effectiveBranch === null) {
// Viewing main but branch not initialized
return projectPath
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
: false;
} else {
// Match by branch name
return featureBranch === effectiveBranch;
}
});
}, [features, currentWorktreePath, currentWorktreeBranch, projectPath]);
// Handle node click - view details
const handleNodeClick = useCallback(
(featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onViewOutput(feature);
}
},
[features, onViewOutput]
);
// Handle node double click - edit
const handleNodeDoubleClick = useCallback(
(featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onEditFeature(feature);
}
},
[features, onEditFeature]
);
return (
<div className="flex-1 overflow-hidden relative">
<GraphCanvas
features={filteredFeatures}
runningAutoTasks={runningAutoTasks}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
backgroundStyle={backgroundImageStyle}
className="h-full"
/>
</div>
);
}

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 };
}

View File

@@ -0,0 +1,4 @@
export { GraphView } from './graph-view';
export { GraphCanvas } from './graph-canvas';
export { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
export { useGraphNodes, useGraphLayout, type TaskNode as TaskNodeType, type DependencyEdge as DependencyEdgeType } from './hooks';