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

@@ -63,9 +63,11 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.562.0",
@@ -94,6 +96,7 @@
"@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.7",
"@types/dagre": "^0.7.53",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

View File

@@ -21,6 +21,7 @@ import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import {
AddFeatureDialog,
AgentOutputModal,
@@ -69,6 +70,8 @@ export function BoardView() {
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardViewMode,
setBoardViewMode,
specCreatingForProject,
setSpecCreatingForProject,
pendingPlanApproval,
@@ -989,40 +992,54 @@ export function BoardView() {
completedCount={completedFeatures.length}
kanbanCardDetailLevel={kanbanCardDetailLevel}
onDetailLevelChange={setKanbanCardDetailLevel}
boardViewMode={boardViewMode}
onBoardViewModeChange={setBoardViewMode}
/>
</div>
{/* Kanban Columns */}
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
{/* View Content - Kanban or Graph */}
{boardViewMode === 'kanban' ? (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
) : (
<GraphView
features={hookFeatures}
runningAutoTasks={runningAutoTasks}
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
/>
)}
</div>
{/* Board Background Modal */}

View File

@@ -1,7 +1,8 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react';
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BoardViewMode } from '@/store/app-store';
interface BoardControlsProps {
isMounted: boolean;
@@ -10,6 +11,8 @@ interface BoardControlsProps {
completedCount: number;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
boardViewMode: BoardViewMode;
onBoardViewModeChange: (mode: BoardViewMode) => void;
}
export function BoardControls({
@@ -19,12 +22,59 @@ export function BoardControls({
completedCount,
kanbanCardDetailLevel,
onDetailLevelChange,
boardViewMode,
onBoardViewModeChange,
}: BoardControlsProps) {
if (!isMounted) return null;
return (
<TooltipProvider>
<div className="flex items-center gap-2 ml-4">
{/* View Mode Toggle - Kanban / Graph */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="view-mode-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('kanban')}
className={cn(
'p-2 rounded-l-lg transition-colors',
boardViewMode === 'kanban'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-kanban"
>
<Columns3 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Kanban Board View</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('graph')}
className={cn(
'p-2 rounded-r-lg transition-colors',
boardViewMode === 'graph'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-graph"
>
<Network className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Dependency Graph View</p>
</TooltipContent>
</Tooltip>
</div>
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>

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

View File

@@ -51,6 +51,8 @@ export type ThemeMode =
export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
export type BoardViewMode = 'kanban' | 'graph';
export interface ApiKeys {
anthropic: string;
google: string;
@@ -450,6 +452,7 @@ export interface AppState {
// Kanban Card Display Settings
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
// Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features
@@ -713,6 +716,7 @@ export interface AppActions {
// Kanban Card Settings actions
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
setBoardViewMode: (mode: BoardViewMode) => void;
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
@@ -916,6 +920,7 @@ const initialState: AppState = {
autoModeActivityLog: [],
maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: 'standard', // Default to standard detail level
boardViewMode: 'kanban', // Default to kanban view
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
useWorktrees: false, // Default to disabled (worktree feature is experimental)
@@ -1466,6 +1471,7 @@ export const useAppStore = create<AppState & AppActions>()(
// Kanban Card Settings actions
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
@@ -2673,6 +2679,7 @@ export const useAppStore = create<AppState & AppActions>()(
sidebarOpen: state.sidebarOpen,
chatHistoryOpen: state.chatHistoryOpen,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
boardViewMode: state.boardViewMode,
// Settings
apiKeys: state.apiKeys,
maxConcurrency: state.maxConcurrency,

View File

@@ -889,3 +889,162 @@
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* ========================================
DEPENDENCY GRAPH STYLES
Theme-aware styling for React Flow graph
======================================== */
/* React Flow base theme overrides */
.graph-canvas {
--xy-background-color: transparent;
--xy-node-background-color: var(--card);
--xy-node-border-color: var(--border);
--xy-node-border-radius: 0.75rem;
--xy-edge-stroke-default: var(--border);
--xy-edge-stroke-selected: var(--brand-500);
--xy-minimap-background-color: var(--popover);
--xy-minimap-mask-background-color: rgba(0, 0, 0, 0.2);
--xy-controls-background-color: var(--popover);
--xy-controls-border-color: var(--border);
}
/* MiniMap styling */
.graph-canvas .react-flow__minimap {
background-color: var(--popover) !important;
border: 1px solid var(--border) !important;
border-radius: 0.5rem;
}
.graph-canvas .react-flow__minimap-mask {
fill: var(--background);
fill-opacity: 0.8;
}
/* Edge animations */
@keyframes flow-dash {
to {
stroke-dashoffset: -20;
}
}
@keyframes edge-glow {
0%, 100% {
filter: drop-shadow(0 0 2px var(--status-in-progress));
}
50% {
filter: drop-shadow(0 0 6px var(--status-in-progress));
}
}
.graph-canvas .animated-edge path {
animation: flow-dash 0.5s linear infinite;
}
.graph-canvas .edge-flowing path {
animation:
flow-dash 0.5s linear infinite,
edge-glow 2s ease-in-out infinite;
}
/* Edge particle animation */
.edge-particle {
pointer-events: none;
}
/* Node animations */
@keyframes pulse-subtle {
0%, 100% {
box-shadow: 0 0 0 0 var(--status-in-progress);
}
50% {
box-shadow: 0 0 15px 3px var(--status-in-progress);
}
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Progress bar indeterminate animation */
@keyframes progress-indeterminate {
0% {
transform: translateX(-100%);
width: 50%;
}
50% {
transform: translateX(50%);
width: 30%;
}
100% {
transform: translateX(200%);
width: 50%;
}
}
.animate-progress-indeterminate {
animation: progress-indeterminate 1.5s ease-in-out infinite;
}
/* Handle styling */
.graph-canvas .react-flow__handle {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--border);
border: 2px solid var(--background);
transition: all 0.2s ease;
}
.graph-canvas .react-flow__handle:hover {
background-color: var(--brand-500);
transform: scale(1.2);
}
.graph-canvas .react-flow__handle-left {
left: -6px;
}
.graph-canvas .react-flow__handle-right {
right: -6px;
}
/* Selection styles */
.graph-canvas .react-flow__node.selected {
outline: none;
}
.graph-canvas .react-flow__edge.selected path {
stroke: var(--brand-500);
stroke-width: 3;
}
/* Attribution removal (requires pro license) */
.graph-canvas .react-flow__attribution {
display: none;
}
/* Panel styling */
.graph-canvas .react-flow__panel {
margin: 12px;
}
/* Retro theme overrides */
.retro .graph-canvas .react-flow__handle,
.retro .graph-canvas .react-flow__minimap {
border-radius: 0 !important;
}
.retro .graph-canvas .react-flow__node {
border-radius: 0 !important;
}
/* Reduce motion preference */
@media (prefers-reduced-motion: reduce) {
.graph-canvas .animated-edge path,
.graph-canvas .edge-flowing path,
.animate-pulse-subtle,
.animate-progress-indeterminate {
animation: none;
}
}

252
package-lock.json generated
View File

@@ -99,9 +99,11 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.562.0",
@@ -119,6 +121,7 @@
"@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.7",
"@types/dagre": "^0.7.53",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -1207,7 +1210,7 @@
},
"node_modules/@electron/node-gyp": {
"version": "10.2.0-electron.1",
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
"dev": true,
"license": "MIT",
@@ -5738,6 +5741,62 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.53",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -6527,6 +6586,66 @@
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.74",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.74",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
@@ -7649,6 +7768,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@@ -8035,6 +8160,121 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -9797,6 +10037,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -11077,7 +11326,6 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {