mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
feat: implement backlog plan management and UI enhancements
- Added functionality to save, clear, and load backlog plans within the application. - Introduced a new API endpoint for clearing saved backlog plans. - Enhanced the backlog plan dialog to allow users to review and apply changes to their features. - Integrated dependency management features in the UI, allowing users to select parent and child dependencies for features. - Improved the graph view with options to manage plans and visualize dependencies effectively. - Updated the sidebar and settings to include provider visibility toggles for better user control over model selection. These changes aim to enhance the user experience by providing robust backlog management capabilities and improving the overall UI for feature planning.
This commit is contained in:
@@ -74,7 +74,16 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
edgeData?.onDeleteDependency?.(source, target);
|
||||
console.log('Edge delete button clicked', {
|
||||
source,
|
||||
target,
|
||||
hasCallback: !!edgeData?.onDeleteDependency,
|
||||
});
|
||||
if (edgeData?.onDeleteDependency) {
|
||||
edgeData.onDeleteDependency(source, target);
|
||||
} else {
|
||||
console.error('onDeleteDependency callback is not defined on edge data');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Panel } from '@xyflow/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -43,6 +45,8 @@ interface GraphFilterControlsProps {
|
||||
filterState: GraphFilterState;
|
||||
availableCategories: string[];
|
||||
hasActiveFilter: boolean;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onCategoriesChange: (categories: string[]) => void;
|
||||
onStatusesChange: (statuses: string[]) => void;
|
||||
onNegativeFilterChange: (isNegative: boolean) => void;
|
||||
@@ -53,6 +57,8 @@ export function GraphFilterControls({
|
||||
filterState,
|
||||
availableCategories,
|
||||
hasActiveFilter,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onCategoriesChange,
|
||||
onStatusesChange,
|
||||
onNegativeFilterChange,
|
||||
@@ -114,6 +120,30 @@ export function GraphFilterControls({
|
||||
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
className="h-8 w-48 pl-8 pr-8 text-sm bg-background/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => onSearchQueryChange('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* Category Filter Dropdown */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
|
||||
@@ -60,13 +60,6 @@ const statusConfig = {
|
||||
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 = {
|
||||
@@ -95,8 +88,13 @@ function getCardBorderStyle(
|
||||
|
||||
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||
// Handle pipeline statuses by treating them like in_progress
|
||||
// Treat completed (archived) as verified for display
|
||||
const status = data.status || 'backlog';
|
||||
const statusKey = status.startsWith('pipeline_') ? 'in_progress' : status;
|
||||
const statusKey = status.startsWith('pipeline_')
|
||||
? 'in_progress'
|
||||
: status === 'completed'
|
||||
? 'verified'
|
||||
: status;
|
||||
const config = statusConfig[statusKey as keyof typeof statusConfig] || statusConfig.backlog;
|
||||
const StatusIcon = config.icon;
|
||||
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ConnectionMode,
|
||||
Node,
|
||||
Connection,
|
||||
Edge,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
@@ -35,8 +36,9 @@ import {
|
||||
} from './hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDebounceValue } from 'usehooks-ts';
|
||||
import { SearchX, Plus } from 'lucide-react';
|
||||
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
|
||||
|
||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -65,11 +67,46 @@ interface GraphCanvasProps {
|
||||
nodeActionCallbacks?: NodeActionCallbacks;
|
||||
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||
onAddFeature?: () => void;
|
||||
onOpenPlanDialog?: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
planUseSelectedWorktreeBranch?: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange?: (value: boolean) => void;
|
||||
backgroundStyle?: React.CSSProperties;
|
||||
backgroundSettings?: BackgroundSettings;
|
||||
className?: string;
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
// Helper to get session storage key for viewport
|
||||
const getViewportStorageKey = (projectPath: string) => `graph-viewport:${projectPath}`;
|
||||
|
||||
// Helper to save viewport to session storage
|
||||
const saveViewportToStorage = (
|
||||
projectPath: string,
|
||||
viewport: { x: number; y: number; zoom: number }
|
||||
) => {
|
||||
try {
|
||||
sessionStorage.setItem(getViewportStorageKey(projectPath), JSON.stringify(viewport));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to load viewport from session storage
|
||||
const loadViewportFromStorage = (
|
||||
projectPath: string
|
||||
): { x: number; y: number; zoom: number } | null => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(getViewportStorageKey(projectPath));
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function GraphCanvasInner({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
@@ -79,12 +116,38 @@ function GraphCanvasInner({
|
||||
nodeActionCallbacks,
|
||||
onCreateDependency,
|
||||
onAddFeature,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
backgroundStyle,
|
||||
backgroundSettings,
|
||||
className,
|
||||
projectPath,
|
||||
}: GraphCanvasProps) {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
||||
const { setViewport, getViewport, fitView } = useReactFlow();
|
||||
|
||||
// Refs for tracking layout and viewport state
|
||||
const hasRestoredViewport = useRef(false);
|
||||
const lastProjectPath = useRef(projectPath);
|
||||
const hasInitialLayout = useRef(false);
|
||||
const prevNodeIds = useRef<Set<string>>(new Set());
|
||||
const prevLayoutVersion = useRef<number>(0);
|
||||
const hasLayoutWithEdges = useRef(false);
|
||||
|
||||
// Reset flags when project changes
|
||||
useEffect(() => {
|
||||
if (projectPath !== lastProjectPath.current) {
|
||||
hasRestoredViewport.current = false;
|
||||
hasLayoutWithEdges.current = false;
|
||||
hasInitialLayout.current = false;
|
||||
prevNodeIds.current = new Set();
|
||||
prevLayoutVersion.current = 0;
|
||||
lastProjectPath.current = projectPath;
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
// Determine React Flow color mode based on current theme
|
||||
const effectiveTheme = useAppStore((state) => state.getEffectiveTheme());
|
||||
@@ -145,7 +208,7 @@ function GraphCanvasInner({
|
||||
});
|
||||
|
||||
// Apply layout
|
||||
const { layoutedNodes, layoutedEdges, runLayout } = useGraphLayout({
|
||||
const { layoutedNodes, layoutedEdges, layoutVersion, runLayout } = useGraphLayout({
|
||||
nodes: initialNodes,
|
||||
edges: initialEdges,
|
||||
});
|
||||
@@ -154,24 +217,22 @@ function GraphCanvasInner({
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
||||
|
||||
// Track if initial layout has been applied
|
||||
const hasInitialLayout = useRef(false);
|
||||
// Track the previous node IDs to detect new nodes
|
||||
const prevNodeIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Update nodes/edges when features change, but preserve user positions
|
||||
useEffect(() => {
|
||||
const currentNodeIds = new Set(layoutedNodes.map((n) => n.id));
|
||||
const isInitialRender = !hasInitialLayout.current;
|
||||
// Detect if a fresh layout was computed (structure changed)
|
||||
const layoutWasRecomputed = layoutVersion !== prevLayoutVersion.current;
|
||||
|
||||
// Check if there are new nodes that need layout
|
||||
const hasNewNodes = layoutedNodes.some((n) => !prevNodeIds.current.has(n.id));
|
||||
|
||||
if (isInitialRender) {
|
||||
// Apply full layout for initial render
|
||||
if (isInitialRender || layoutWasRecomputed) {
|
||||
// Apply full layout for initial render OR when layout was recomputed due to structure change
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(layoutedEdges);
|
||||
hasInitialLayout.current = true;
|
||||
prevLayoutVersion.current = layoutVersion;
|
||||
} else if (hasNewNodes) {
|
||||
// New nodes added - need to re-layout but try to preserve existing positions
|
||||
setNodes((currentNodes) => {
|
||||
@@ -197,15 +258,55 @@ function GraphCanvasInner({
|
||||
|
||||
// Update prev node IDs for next comparison
|
||||
prevNodeIds.current = currentNodeIds;
|
||||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||||
|
||||
// Restore viewport from session storage after initial layout
|
||||
if (isInitialRender && projectPath && !hasRestoredViewport.current) {
|
||||
const savedViewport = loadViewportFromStorage(projectPath);
|
||||
if (savedViewport) {
|
||||
// Use setTimeout to ensure React Flow has finished rendering
|
||||
setTimeout(() => {
|
||||
setViewport(savedViewport, { duration: 0 });
|
||||
}, 50);
|
||||
}
|
||||
hasRestoredViewport.current = true;
|
||||
}
|
||||
}, [layoutedNodes, layoutedEdges, layoutVersion, setNodes, setEdges, projectPath, setViewport]);
|
||||
|
||||
// Force layout recalculation on initial mount when edges are available
|
||||
// This fixes timing issues when navigating directly to the graph route
|
||||
useEffect(() => {
|
||||
// Only run once: when we have nodes and edges but haven't done a layout with edges yet
|
||||
if (!hasLayoutWithEdges.current && layoutedNodes.length > 0 && layoutedEdges.length > 0) {
|
||||
hasLayoutWithEdges.current = true;
|
||||
// Small delay to ensure React Flow is mounted and ready
|
||||
const timeoutId = setTimeout(() => {
|
||||
const { nodes: relayoutedNodes, edges: relayoutedEdges } = runLayout('LR');
|
||||
setNodes(relayoutedNodes);
|
||||
setEdges(relayoutedEdges);
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [layoutedNodes.length, layoutedEdges.length, runLayout, setNodes, setEdges, fitView]);
|
||||
|
||||
// Save viewport when user pans or zooms
|
||||
const handleMoveEnd = useCallback(() => {
|
||||
if (projectPath) {
|
||||
const viewport = getViewport();
|
||||
saveViewportToStorage(projectPath, viewport);
|
||||
}
|
||||
}, [projectPath, getViewport]);
|
||||
|
||||
// Handle layout direction change
|
||||
const handleRunLayout = useCallback(
|
||||
(direction: 'LR' | 'TB') => {
|
||||
setLayoutDirection(direction);
|
||||
runLayout(direction);
|
||||
const { nodes: relayoutedNodes, edges: relayoutedEdges } = runLayout(direction);
|
||||
setNodes(relayoutedNodes);
|
||||
setEdges(relayoutedEdges);
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
},
|
||||
[runLayout]
|
||||
[runLayout, setNodes, setEdges, fitView]
|
||||
);
|
||||
|
||||
// Handle clear all filters
|
||||
@@ -247,9 +348,6 @@ function GraphCanvasInner({
|
||||
[]
|
||||
);
|
||||
|
||||
// Get fitView from React Flow for orientation change handling
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Handle orientation changes on mobile devices
|
||||
// When rotating from landscape to portrait, the view may incorrectly zoom in
|
||||
// This effect listens for orientation changes and calls fitView to correct the viewport
|
||||
@@ -323,6 +421,23 @@ function GraphCanvasInner({
|
||||
};
|
||||
}, [fitView]);
|
||||
|
||||
// Handle edge deletion (when user presses delete key or uses other deletion methods)
|
||||
const handleEdgesDelete = useCallback(
|
||||
(deletedEdges: Edge[]) => {
|
||||
console.log('onEdgesDelete triggered', deletedEdges);
|
||||
deletedEdges.forEach((edge) => {
|
||||
if (nodeActionCallbacks?.onDeleteDependency) {
|
||||
console.log('Calling onDeleteDependency from onEdgesDelete', {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
});
|
||||
nodeActionCallbacks.onDeleteDependency(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
[nodeActionCallbacks]
|
||||
);
|
||||
|
||||
// MiniMap node color based on status
|
||||
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
||||
const data = node.data as TaskNodeData | undefined;
|
||||
@@ -349,7 +464,9 @@ function GraphCanvasInner({
|
||||
edges={edges}
|
||||
onNodesChange={isLocked ? undefined : onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={handleEdgesDelete}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
onConnect={handleConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={nodeTypes}
|
||||
@@ -392,6 +509,8 @@ function GraphCanvasInner({
|
||||
filterState={filterState}
|
||||
availableCategories={filterResult.availableCategories}
|
||||
hasActiveFilter={filterResult.hasActiveFilter}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onCategoriesChange={setSelectedCategories}
|
||||
onStatusesChange={setSelectedStatuses}
|
||||
onNegativeFilterChange={setIsNegativeFilter}
|
||||
@@ -402,10 +521,42 @@ function GraphCanvasInner({
|
||||
|
||||
{/* Add Feature Button */}
|
||||
<Panel position="top-right">
|
||||
<Button variant="default" size="sm" onClick={onAddFeature} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Feature
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenPlanDialog && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-border bg-secondary/60 px-2 py-1 shadow-sm">
|
||||
{hasPendingPlan && (
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center text-emerald-500 hover:text-emerald-400 transition-colors"
|
||||
data-testid="graph-plan-review-button"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onOpenPlanDialog}
|
||||
className="gap-1.5"
|
||||
data-testid="graph-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
Plan
|
||||
</Button>
|
||||
{onPlanUseSelectedWorktreeBranchChange &&
|
||||
planUseSelectedWorktreeBranch !== undefined && (
|
||||
<PlanSettingsPopover
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="default" size="sm" onClick={onAddFeature} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Empty state when all nodes are filtered out */}
|
||||
|
||||
@@ -23,6 +23,10 @@ interface GraphViewProps {
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDeleteTask?: (feature: Feature) => void;
|
||||
onAddFeature?: () => void;
|
||||
onOpenPlanDialog?: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
planUseSelectedWorktreeBranch?: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function GraphView({
|
||||
@@ -42,6 +46,10 @@ export function GraphView({
|
||||
onSpawnTask,
|
||||
onDeleteTask,
|
||||
onAddFeature,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
}: GraphViewProps) {
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
@@ -53,9 +61,6 @@ export function GraphView({
|
||||
const effectiveBranch = currentWorktreeBranch;
|
||||
|
||||
return features.filter((f) => {
|
||||
// Skip completed features (they're in archive)
|
||||
if (f.status === 'completed') return false;
|
||||
|
||||
const featureBranch = f.branchName as string | undefined;
|
||||
|
||||
if (!featureBranch) {
|
||||
@@ -178,15 +183,26 @@ export function GraphView({
|
||||
},
|
||||
onDeleteDependency: (sourceId: string, targetId: string) => {
|
||||
// Find the target feature and remove the source from its dependencies
|
||||
console.log('onDeleteDependency called', { sourceId, targetId });
|
||||
const targetFeature = features.find((f) => f.id === targetId);
|
||||
if (!targetFeature) return;
|
||||
if (!targetFeature) {
|
||||
console.error('Target feature not found:', targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDeps = (targetFeature.dependencies as string[] | undefined) || [];
|
||||
console.log('Current dependencies:', currentDeps);
|
||||
const newDeps = currentDeps.filter((depId) => depId !== sourceId);
|
||||
console.log('New dependencies:', newDeps);
|
||||
|
||||
onUpdateFeature?.(targetId, {
|
||||
dependencies: newDeps,
|
||||
});
|
||||
if (onUpdateFeature) {
|
||||
console.log('Calling onUpdateFeature');
|
||||
onUpdateFeature(targetId, {
|
||||
dependencies: newDeps,
|
||||
});
|
||||
} else {
|
||||
console.error('onUpdateFeature is not defined!');
|
||||
}
|
||||
|
||||
toast.success('Dependency removed');
|
||||
},
|
||||
@@ -215,8 +231,13 @@ export function GraphView({
|
||||
nodeActionCallbacks={nodeActionCallbacks}
|
||||
onCreateDependency={handleCreateDependency}
|
||||
onAddFeature={onAddFeature}
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
hasPendingPlan={hasPendingPlan}
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
backgroundStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
projectPath={projectPath}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,11 +89,16 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
|
||||
|
||||
/**
|
||||
* Gets the effective status of a feature (accounting for running state)
|
||||
* Treats completed (archived) as verified
|
||||
*/
|
||||
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
|
||||
if (feature.status === 'in_progress') {
|
||||
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
|
||||
}
|
||||
// Treat completed (archived) as verified
|
||||
if (feature.status === 'completed') {
|
||||
return 'verified';
|
||||
}
|
||||
return feature.status as StatusFilterValue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import dagre from 'dagre';
|
||||
import { Node, Edge, useReactFlow } from '@xyflow/react';
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import { TaskNode, DependencyEdge } from './use-graph-nodes';
|
||||
|
||||
const NODE_WIDTH = 280;
|
||||
@@ -16,11 +16,11 @@ interface UseGraphLayoutProps {
|
||||
* Dependencies flow left-to-right
|
||||
*/
|
||||
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
const { fitView, setNodes } = useReactFlow();
|
||||
|
||||
// Cache the last computed positions to avoid recalculating layout
|
||||
const positionCache = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
const lastStructureKey = useRef<string>('');
|
||||
// Track layout version to signal when fresh layout was computed
|
||||
const layoutVersion = useRef<number>(0);
|
||||
|
||||
const getLayoutedElements = useCallback(
|
||||
(
|
||||
@@ -71,31 +71,39 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
[]
|
||||
);
|
||||
|
||||
// Create a stable structure key based only on node IDs (not edge changes)
|
||||
// Edges changing shouldn't trigger re-layout
|
||||
// Create a stable structure key based on node IDs AND edge connections
|
||||
// Layout must recalculate when the dependency graph structure changes
|
||||
const structureKey = useMemo(() => {
|
||||
const nodeIds = nodes
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
return nodeIds;
|
||||
}, [nodes]);
|
||||
// Include edge structure (source->target pairs) to ensure layout recalculates
|
||||
// when dependencies change, not just when nodes are added/removed
|
||||
const edgeConnections = edges
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
return `${nodeIds}|${edgeConnections}`;
|
||||
}, [nodes, edges]);
|
||||
|
||||
// Initial layout - only recalculate when node structure changes (new nodes added/removed)
|
||||
// Initial layout - recalculate when graph structure changes (nodes added/removed OR edges/dependencies change)
|
||||
const layoutedElements = useMemo(() => {
|
||||
if (nodes.length === 0) {
|
||||
positionCache.current.clear();
|
||||
lastStructureKey.current = '';
|
||||
return { nodes: [], edges: [] };
|
||||
return { nodes: [], edges: [], didRelayout: false };
|
||||
}
|
||||
|
||||
// Check if structure changed (new nodes added or removed)
|
||||
// Check if structure changed (nodes added/removed OR dependencies changed)
|
||||
const structureChanged = structureKey !== lastStructureKey.current;
|
||||
|
||||
if (structureChanged) {
|
||||
// Structure changed - run full layout
|
||||
lastStructureKey.current = structureKey;
|
||||
return getLayoutedElements(nodes, edges, 'LR');
|
||||
layoutVersion.current += 1;
|
||||
const result = getLayoutedElements(nodes, edges, 'LR');
|
||||
return { ...result, didRelayout: true };
|
||||
} else {
|
||||
// Structure unchanged - preserve cached positions, just update node data
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
@@ -107,26 +115,22 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
sourcePosition: 'right',
|
||||
} as TaskNode;
|
||||
});
|
||||
return { nodes: layoutedNodes, edges };
|
||||
return { nodes: layoutedNodes, edges, didRelayout: false };
|
||||
}
|
||||
}, [nodes, edges, structureKey, 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);
|
||||
return getLayoutedElements(nodes, edges, direction);
|
||||
},
|
||||
[nodes, edges, getLayoutedElements, setNodes, fitView]
|
||||
[nodes, edges, getLayoutedElements]
|
||||
);
|
||||
|
||||
return {
|
||||
layoutedNodes: layoutedElements.nodes,
|
||||
layoutedEdges: layoutedElements.edges,
|
||||
layoutVersion: layoutVersion.current,
|
||||
runLayout,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user