mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
✨ 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:
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { TaskNode } from './task-node';
|
||||
export { DependencyEdge } from './dependency-edge';
|
||||
export { GraphControls } from './graph-controls';
|
||||
export { GraphLegend } from './graph-legend';
|
||||
248
apps/ui/src/components/views/graph-view/components/task-node.tsx
Normal file
248
apps/ui/src/components/views/graph-view/components/task-node.tsx
Normal 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)]'
|
||||
: ''
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
174
apps/ui/src/components/views/graph-view/graph-canvas.tsx
Normal file
174
apps/ui/src/components/views/graph-view/graph-canvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/ui/src/components/views/graph-view/graph-view.tsx
Normal file
89
apps/ui/src/components/views/graph-view/graph-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
apps/ui/src/components/views/graph-view/hooks/index.ts
Normal file
2
apps/ui/src/components/views/graph-view/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes';
|
||||
export { useGraphLayout } from './use-graph-layout';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
4
apps/ui/src/components/views/graph-view/index.ts
Normal file
4
apps/ui/src/components/views/graph-view/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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
252
package-lock.json
generated
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user