mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +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-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -94,6 +96,7 @@
|
|||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@tanstack/router-plugin": "^1.141.7",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@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 { BoardSearchBar } from './board-view/board-search-bar';
|
||||||
import { BoardControls } from './board-view/board-controls';
|
import { BoardControls } from './board-view/board-controls';
|
||||||
import { KanbanBoard } from './board-view/kanban-board';
|
import { KanbanBoard } from './board-view/kanban-board';
|
||||||
|
import { GraphView } from './graph-view';
|
||||||
import {
|
import {
|
||||||
AddFeatureDialog,
|
AddFeatureDialog,
|
||||||
AgentOutputModal,
|
AgentOutputModal,
|
||||||
@@ -69,6 +70,8 @@ export function BoardView() {
|
|||||||
aiProfiles,
|
aiProfiles,
|
||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
setKanbanCardDetailLevel,
|
setKanbanCardDetailLevel,
|
||||||
|
boardViewMode,
|
||||||
|
setBoardViewMode,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
pendingPlanApproval,
|
pendingPlanApproval,
|
||||||
@@ -989,40 +992,54 @@ export function BoardView() {
|
|||||||
completedCount={completedFeatures.length}
|
completedCount={completedFeatures.length}
|
||||||
kanbanCardDetailLevel={kanbanCardDetailLevel}
|
kanbanCardDetailLevel={kanbanCardDetailLevel}
|
||||||
onDetailLevelChange={setKanbanCardDetailLevel}
|
onDetailLevelChange={setKanbanCardDetailLevel}
|
||||||
|
boardViewMode={boardViewMode}
|
||||||
|
onBoardViewModeChange={setBoardViewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Kanban Columns */}
|
{/* View Content - Kanban or Graph */}
|
||||||
<KanbanBoard
|
{boardViewMode === 'kanban' ? (
|
||||||
sensors={sensors}
|
<KanbanBoard
|
||||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
sensors={sensors}
|
||||||
onDragStart={handleDragStart}
|
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||||
onDragEnd={handleDragEnd}
|
onDragStart={handleDragStart}
|
||||||
activeFeature={activeFeature}
|
onDragEnd={handleDragEnd}
|
||||||
getColumnFeatures={getColumnFeatures}
|
activeFeature={activeFeature}
|
||||||
backgroundImageStyle={backgroundImageStyle}
|
getColumnFeatures={getColumnFeatures}
|
||||||
backgroundSettings={backgroundSettings}
|
backgroundImageStyle={backgroundImageStyle}
|
||||||
onEdit={(feature) => setEditingFeature(feature)}
|
backgroundSettings={backgroundSettings}
|
||||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
onEdit={(feature) => setEditingFeature(feature)}
|
||||||
onViewOutput={handleViewOutput}
|
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||||
onVerify={handleVerifyFeature}
|
onViewOutput={handleViewOutput}
|
||||||
onResume={handleResumeFeature}
|
onVerify={handleVerifyFeature}
|
||||||
onForceStop={handleForceStopFeature}
|
onResume={handleResumeFeature}
|
||||||
onManualVerify={handleManualVerify}
|
onForceStop={handleForceStopFeature}
|
||||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
onManualVerify={handleManualVerify}
|
||||||
onFollowUp={handleOpenFollowUp}
|
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||||
onCommit={handleCommitFeature}
|
onFollowUp={handleOpenFollowUp}
|
||||||
onComplete={handleCompleteFeature}
|
onCommit={handleCommitFeature}
|
||||||
onImplement={handleStartImplementation}
|
onComplete={handleCompleteFeature}
|
||||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
onImplement={handleStartImplementation}
|
||||||
onApprovePlan={handleOpenApprovalDialog}
|
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||||
featuresWithContext={featuresWithContext}
|
onApprovePlan={handleOpenApprovalDialog}
|
||||||
runningAutoTasks={runningAutoTasks}
|
featuresWithContext={featuresWithContext}
|
||||||
shortcuts={shortcuts}
|
runningAutoTasks={runningAutoTasks}
|
||||||
onStartNextFeatures={handleStartNextFeatures}
|
shortcuts={shortcuts}
|
||||||
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
onStartNextFeatures={handleStartNextFeatures}
|
||||||
suggestionsCount={suggestionsCount}
|
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(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>
|
</div>
|
||||||
|
|
||||||
{/* Board Background Modal */}
|
{/* Board Background Modal */}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { cn } from '@/lib/utils';
|
||||||
|
import { BoardViewMode } from '@/store/app-store';
|
||||||
|
|
||||||
interface BoardControlsProps {
|
interface BoardControlsProps {
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
@@ -10,6 +11,8 @@ interface BoardControlsProps {
|
|||||||
completedCount: number;
|
completedCount: number;
|
||||||
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
|
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
|
||||||
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
|
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
|
||||||
|
boardViewMode: BoardViewMode;
|
||||||
|
onBoardViewModeChange: (mode: BoardViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardControls({
|
export function BoardControls({
|
||||||
@@ -19,12 +22,59 @@ export function BoardControls({
|
|||||||
completedCount,
|
completedCount,
|
||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
onDetailLevelChange,
|
onDetailLevelChange,
|
||||||
|
boardViewMode,
|
||||||
|
onBoardViewModeChange,
|
||||||
}: BoardControlsProps) {
|
}: BoardControlsProps) {
|
||||||
if (!isMounted) return null;
|
if (!isMounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<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 */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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 KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
|
||||||
|
|
||||||
|
export type BoardViewMode = 'kanban' | 'graph';
|
||||||
|
|
||||||
export interface ApiKeys {
|
export interface ApiKeys {
|
||||||
anthropic: string;
|
anthropic: string;
|
||||||
google: string;
|
google: string;
|
||||||
@@ -450,6 +452,7 @@ export interface AppState {
|
|||||||
|
|
||||||
// Kanban Card Display Settings
|
// Kanban Card Display Settings
|
||||||
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
|
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
|
||||||
|
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
|
||||||
|
|
||||||
// Feature Default Settings
|
// Feature Default Settings
|
||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||||
@@ -713,6 +716,7 @@ export interface AppActions {
|
|||||||
|
|
||||||
// Kanban Card Settings actions
|
// Kanban Card Settings actions
|
||||||
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
|
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
|
||||||
|
setBoardViewMode: (mode: BoardViewMode) => void;
|
||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
@@ -916,6 +920,7 @@ const initialState: AppState = {
|
|||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||||
kanbanCardDetailLevel: 'standard', // Default to standard detail level
|
kanbanCardDetailLevel: 'standard', // Default to standard detail level
|
||||||
|
boardViewMode: 'kanban', // Default to kanban view
|
||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||||
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
||||||
@@ -1466,6 +1471,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
// Kanban Card Settings actions
|
// Kanban Card Settings actions
|
||||||
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
|
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
|
||||||
|
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
|
||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
||||||
@@ -2673,6 +2679,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
sidebarOpen: state.sidebarOpen,
|
sidebarOpen: state.sidebarOpen,
|
||||||
chatHistoryOpen: state.chatHistoryOpen,
|
chatHistoryOpen: state.chatHistoryOpen,
|
||||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||||
|
boardViewMode: state.boardViewMode,
|
||||||
// Settings
|
// Settings
|
||||||
apiKeys: state.apiKeys,
|
apiKeys: state.apiKeys,
|
||||||
maxConcurrency: state.maxConcurrency,
|
maxConcurrency: state.maxConcurrency,
|
||||||
|
|||||||
@@ -889,3 +889,162 @@
|
|||||||
.xterm-viewport::-webkit-scrollbar-thumb:hover {
|
.xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--muted-foreground);
|
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-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -119,6 +121,7 @@
|
|||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@tanstack/router-plugin": "^1.141.7",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -1207,7 +1210,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@electron/node-gyp": {
|
"node_modules/@electron/node-gyp": {
|
||||||
"version": "10.2.0-electron.1",
|
"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==",
|
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5738,6 +5741,62 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -6527,6 +6586,66 @@
|
|||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/7zip-bin": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||||
@@ -7649,6 +7768,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"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": {
|
"node_modules/clean-stack": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||||
@@ -8035,6 +8160,121 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -9797,6 +10037,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -11077,7 +11326,6 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
|
|||||||
Reference in New Issue
Block a user