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:
webdevcody
2026-01-15 22:21:46 -05:00
parent cb544e0011
commit 03436103d1
46 changed files with 1719 additions and 418 deletions

View File

@@ -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 (

View File

@@ -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>

View File

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

View File

@@ -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 */}

View File

@@ -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>

View File

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

View File

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