From 8d80c73faa838d59c22187437ffe183b0155a892 Mon Sep 17 00:00:00 2001 From: jbotwina Date: Tue, 23 Dec 2025 11:02:17 -0500 Subject: [PATCH 1/6] feat: Add task dependencies and spawn sub-task functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add edge dragging to create dependencies in graph view - Add spawn sub-task action available in graph view and kanban board - Implement ancestor context selection when spawning tasks - Add dependency validation (circular, self, duplicate prevention) - Include ancestor context in spawned task descriptions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- apps/ui/package.json | 1 + apps/ui/src/components/ui/collapsible.tsx | 10 + apps/ui/src/components/views/board-view.tsx | 23 +- .../components/kanban-card/card-header.tsx | 57 ++- .../components/kanban-card/kanban-card.tsx | 3 + .../board-view/dialogs/add-feature-dialog.tsx | 95 +++- .../board-view/hooks/use-board-actions.ts | 2 + .../views/board-view/kanban-board.tsx | 3 + .../shared/ancestor-context-section.tsx | 201 +++++++++ .../views/board-view/shared/index.ts | 1 + .../views/graph-view/components/task-node.tsx | 11 + .../views/graph-view/graph-canvas.tsx | 17 + .../views/graph-view/graph-view.tsx | 58 ++- .../views/graph-view/hooks/use-graph-nodes.ts | 5 + .../graph-view/utils/ancestor-context.ts | 93 ++++ .../graph-view/utils/dependency-validation.ts | 51 +++ .../views/graph-view/utils/index.ts | 2 + package-lock.json | 33 +- pnpm-lock.yaml | 407 ++++++++++++++++++ 19 files changed, 1057 insertions(+), 16 deletions(-) create mode 100644 apps/ui/src/components/ui/collapsible.tsx create mode 100644 apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx create mode 100644 apps/ui/src/components/views/graph-view/utils/ancestor-context.ts create mode 100644 apps/ui/src/components/views/graph-view/utils/dependency-validation.ts create mode 100644 apps/ui/src/components/views/graph-view/utils/index.ts create mode 100644 pnpm-lock.yaml diff --git a/apps/ui/package.json b/apps/ui/package.json index 410e63c1..ce6edcbf 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -44,6 +44,7 @@ "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", diff --git a/apps/ui/src/components/ui/collapsible.tsx b/apps/ui/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..dfe74fc4 --- /dev/null +++ b/apps/ui/src/components/ui/collapsible.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 0459e68b..66fa50d0 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -107,6 +107,9 @@ export function BoardView() { // State for viewing plan in read-only mode const [viewPlanFeature, setViewPlanFeature] = useState(null); + // State for spawn task mode + const [spawnParentFeature, setSpawnParentFeature] = useState(null); + // Worktree dialog states const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false); const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false); @@ -1021,6 +1024,10 @@ export function BoardView() { onImplement={handleStartImplementation} onViewPlan={(feature) => setViewPlanFeature(feature)} onApprovePlan={handleOpenApprovalDialog} + onSpawnTask={(feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks} shortcuts={shortcuts} @@ -1043,6 +1050,13 @@ export function BoardView() { onStartTask={handleStartImplementation} onStopTask={handleForceStopFeature} onResumeTask={handleResumeFeature} + onUpdateFeature={(featureId, updates) => { + handleUpdateFeature(featureId, updates); + }} + onSpawnTask={(feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }} /> )} @@ -1077,7 +1091,12 @@ export function BoardView() { {/* Add Feature Dialog */} { + setShowAddDialog(open); + if (!open) { + setSpawnParentFeature(null); + } + }} onAdd={handleAddFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} @@ -1088,6 +1107,8 @@ export function BoardView() { isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} + parentFeature={spawnParentFeature} + allFeatures={hookFeatures} /> {/* Edit Feature Dialog */} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 52b34417..6f486caa 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronUp, Cpu, + GitFork, } from 'lucide-react'; import { CountUpTimer } from '@/components/ui/count-up-timer'; import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser'; @@ -31,6 +32,7 @@ interface CardHeaderProps { onEdit: () => void; onDelete: () => void; onViewOutput?: () => void; + onSpawnTask?: () => void; } export function CardHeaderSection({ @@ -40,6 +42,7 @@ export function CardHeaderSection({ onEdit, onDelete, onViewOutput, + onSpawnTask, }: CardHeaderProps) { const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -92,6 +95,17 @@ export function CardHeaderSection({ Edit + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-running-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + {/* Model info in dropdown */}
@@ -106,7 +120,21 @@ export function CardHeaderSection({ {/* Backlog header */} {!isCurrentAutoTask && feature.status === 'backlog' && ( -
+
+ + {onViewOutput && ( + +
+
+ +

+ Select ancestors to include their context in the new task's prompt. +

+ +
+ {allAncestorItems.map((item) => { + const isSelected = selectedAncestorIds.has(item.id); + const isExpanded = expandedIds.has(item.id); + const hasContent = + item.description || + ('spec' in item && item.spec) || + ('summary' in item && item.summary); + const displayTitle = + item.title || + item.description.slice(0, 50) + (item.description.length > 50 ? '...' : ''); + + return ( + +
+ toggleSelected(item.id)} + className="mt-0.5" + /> +
+
+ {hasContent && ( + + + + )} + +
+ + +
+ {item.description && ( +
+ Description: +

{item.description}

+
+ )} + {'spec' in item && item.spec && ( +
+ Specification: +

{item.spec}

+
+ )} + {'summary' in item && item.summary && ( +
+ Summary: +

{item.summary}

+
+ )} +
+
+
+
+
+ ); + })} + + {ancestors.length === 0 && ( +

+ Parent task has no additional ancestors +

+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/shared/index.ts b/apps/ui/src/components/views/board-view/shared/index.ts index 730f5d1a..304d4c89 100644 --- a/apps/ui/src/components/views/board-view/shared/index.ts +++ b/apps/ui/src/components/views/board-view/shared/index.ts @@ -6,3 +6,4 @@ export * from './testing-tab-content'; export * from './priority-selector'; export * from './branch-selector'; export * from './planning-mode-selector'; +export * from './ancestor-context-section'; diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index 8ea4f319..1c190df7 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -14,6 +14,7 @@ import { GitBranch, Terminal, RotateCcw, + GitFork, } from 'lucide-react'; import { TaskNodeData } from '../hooks/use-graph-nodes'; import { Button } from '@/components/ui/button'; @@ -266,6 +267,16 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps Resume Task )} + { + e.stopPropagation(); + data.onSpawnTask?.(); + }} + > + + Spawn Sub-Task +
diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index 283de400..88173511 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -11,6 +11,7 @@ import { SelectionMode, ConnectionMode, Node, + Connection, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; @@ -53,6 +54,7 @@ interface GraphCanvasProps { onSearchQueryChange: (query: string) => void; onNodeDoubleClick?: (featureId: string) => void; nodeActionCallbacks?: NodeActionCallbacks; + onCreateDependency?: (sourceId: string, targetId: string) => Promise; backgroundStyle?: React.CSSProperties; className?: string; } @@ -64,6 +66,7 @@ function GraphCanvasInner({ onSearchQueryChange, onNodeDoubleClick, nodeActionCallbacks, + onCreateDependency, backgroundStyle, className, }: GraphCanvasProps) { @@ -138,6 +141,19 @@ function GraphCanvasInner({ [onNodeDoubleClick] ); + // Handle edge connection (creating dependencies) + const handleConnect = useCallback( + async (connection: Connection) => { + if (!connection.source || !connection.target) return; + + // In React Flow, dragging from source handle to target handle means: + // - source = the node being dragged FROM (the prerequisite/dependency) + // - target = the node being dragged TO (the dependent task) + await onCreateDependency?.(connection.source, connection.target); + }, + [onCreateDependency] + ); + // MiniMap node color based on status const minimapNodeColor = useCallback((node: Node) => { const data = node.data as TaskNodeData | undefined; @@ -165,6 +181,7 @@ function GraphCanvasInner({ onNodesChange={isLocked ? undefined : onNodesChange} onEdgesChange={onEdgesChange} onNodeDoubleClick={handleNodeDoubleClick} + onConnect={handleConnect} nodeTypes={nodeTypes} edgeTypes={edgeTypes} fitView diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index 9ef01bce..33874dc9 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -3,6 +3,8 @@ import { Feature, useAppStore } from '@/store/app-store'; import { GraphCanvas } from './graph-canvas'; import { useBoardBackground } from '../board-view/hooks'; import { NodeActionCallbacks } from './hooks'; +import { wouldCreateCircularDependency, dependencyExists } from './utils'; +import { toast } from 'sonner'; interface GraphViewProps { features: Feature[]; @@ -17,6 +19,8 @@ interface GraphViewProps { onStartTask?: (feature: Feature) => void; onStopTask?: (feature: Feature) => void; onResumeTask?: (feature: Feature) => void; + onUpdateFeature?: (featureId: string, updates: Partial) => void; + onSpawnTask?: (feature: Feature) => void; } export function GraphView({ @@ -32,6 +36,8 @@ export function GraphView({ onStartTask, onStopTask, onResumeTask, + onUpdateFeature, + onSpawnTask, }: GraphViewProps) { const { currentProject } = useAppStore(); @@ -74,6 +80,49 @@ export function GraphView({ [features, onEditFeature] ); + // Handle creating a dependency via edge connection + const handleCreateDependency = useCallback( + async (sourceId: string, targetId: string): Promise => { + // Prevent self-dependency + if (sourceId === targetId) { + toast.error('A task cannot depend on itself'); + return false; + } + + // Check if dependency already exists + if (dependencyExists(features, sourceId, targetId)) { + toast.info('Dependency already exists'); + return false; + } + + // Check for circular dependency + if (wouldCreateCircularDependency(features, sourceId, targetId)) { + toast.error('Cannot create circular dependency', { + description: 'This would create a dependency cycle', + }); + return false; + } + + // Get target feature and update its dependencies + const targetFeature = features.find((f) => f.id === targetId); + if (!targetFeature) { + toast.error('Target task not found'); + return false; + } + + const currentDeps = targetFeature.dependencies || []; + + // Add the dependency + onUpdateFeature?.(targetId, { + dependencies: [...currentDeps, sourceId], + }); + + toast.success('Dependency created'); + return true; + }, + [features, onUpdateFeature] + ); + // Node action callbacks for dropdown menu const nodeActionCallbacks: NodeActionCallbacks = useMemo( () => ({ @@ -107,8 +156,14 @@ export function GraphView({ onResumeTask?.(feature); } }, + onSpawnTask: (featureId: string) => { + const feature = features.find((f) => f.id === featureId); + if (feature) { + onSpawnTask?.(feature); + } + }, }), - [features, onViewOutput, onEditFeature, onStartTask, onStopTask, onResumeTask] + [features, onViewOutput, onEditFeature, onStartTask, onStopTask, onResumeTask, onSpawnTask] ); return ( @@ -120,6 +175,7 @@ export function GraphView({ onSearchQueryChange={onSearchQueryChange} onNodeDoubleClick={handleNodeDoubleClick} nodeActionCallbacks={nodeActionCallbacks} + onCreateDependency={handleCreateDependency} backgroundStyle={backgroundImageStyle} className="h-full" /> diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index 2b83a4a4..e216462d 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -24,6 +24,7 @@ export interface TaskNodeData extends Feature { onStartTask?: () => void; onStopTask?: () => void; onResumeTask?: () => void; + onSpawnTask?: () => void; } export type TaskNode = Node; @@ -40,6 +41,7 @@ export interface NodeActionCallbacks { onStartTask?: (featureId: string) => void; onStopTask?: (featureId: string) => void; onResumeTask?: (featureId: string) => void; + onSpawnTask?: (featureId: string) => void; } interface UseGraphNodesProps { @@ -112,6 +114,9 @@ export function useGraphNodes({ onResumeTask: actionCallbacks?.onResumeTask ? () => actionCallbacks.onResumeTask!(feature.id) : undefined, + onSpawnTask: actionCallbacks?.onSpawnTask + ? () => actionCallbacks.onSpawnTask!(feature.id) + : undefined, }, }; diff --git a/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts b/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts new file mode 100644 index 00000000..d0f2abaf --- /dev/null +++ b/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts @@ -0,0 +1,93 @@ +import { Feature } from '@/store/app-store'; + +export interface AncestorContext { + id: string; + title?: string; + description: string; + spec?: string; + summary?: string; + depth: number; // 0 = immediate parent, 1 = grandparent, etc. +} + +/** + * Traverses the dependency graph to find all ancestors of a feature. + * Returns ancestors ordered by depth (closest first). + * + * @param feature - The feature to find ancestors for + * @param allFeatures - All features in the system + * @param maxDepth - Maximum depth to traverse (prevents infinite loops) + * @returns Array of ancestor contexts, sorted by depth (closest first) + */ +export function getAncestors( + feature: Feature, + allFeatures: Feature[], + maxDepth: number = 10 +): AncestorContext[] { + const featureMap = new Map(allFeatures.map((f) => [f.id, f])); + const ancestors: AncestorContext[] = []; + const visited = new Set(); + + function traverse(featureId: string, depth: number) { + if (depth > maxDepth || visited.has(featureId)) return; + visited.add(featureId); + + const f = featureMap.get(featureId); + if (!f?.dependencies) return; + + for (const depId of f.dependencies) { + const dep = featureMap.get(depId); + if (dep && !visited.has(depId)) { + ancestors.push({ + id: dep.id, + title: dep.title, + description: dep.description, + spec: dep.spec, + summary: dep.summary, + depth, + }); + traverse(depId, depth + 1); + } + } + } + + traverse(feature.id, 0); + + // Sort by depth (closest ancestors first) + return ancestors.sort((a, b) => a.depth - b.depth); +} + +/** + * Formats ancestor context for inclusion in a task description. + * + * @param ancestors - Array of ancestor contexts (including parent) + * @param selectedIds - Set of selected ancestor IDs to include + * @returns Formatted markdown string with ancestor context + */ +export function formatAncestorContextForPrompt( + ancestors: AncestorContext[], + selectedIds: Set +): string { + const selectedAncestors = ancestors.filter((a) => selectedIds.has(a.id)); + if (selectedAncestors.length === 0) return ''; + + const sections = selectedAncestors.map((ancestor) => { + const parts: string[] = []; + const title = ancestor.title || `Task (${ancestor.id.slice(0, 8)})`; + + parts.push(`### ${title}`); + + if (ancestor.description) { + parts.push(`**Description:** ${ancestor.description}`); + } + if (ancestor.spec) { + parts.push(`**Specification:**\n${ancestor.spec}`); + } + if (ancestor.summary) { + parts.push(`**Summary:** ${ancestor.summary}`); + } + + return parts.join('\n\n'); + }); + + return `## Ancestor Context\n\n${sections.join('\n\n---\n\n')}`; +} diff --git a/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts b/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts new file mode 100644 index 00000000..e3da76f8 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts @@ -0,0 +1,51 @@ +import { Feature } from '@/store/app-store'; + +/** + * Checks if adding a dependency from sourceId to targetId would create a circular dependency. + * Uses DFS to detect if targetId can reach sourceId through existing dependencies. + * + * @param features - All features in the system + * @param sourceId - The feature that would become a dependency (the prerequisite) + * @param targetId - The feature that would depend on sourceId + * @returns true if adding this dependency would create a cycle + */ +export function wouldCreateCircularDependency( + features: Feature[], + sourceId: string, + targetId: string +): boolean { + const featureMap = new Map(features.map((f) => [f.id, f])); + const visited = new Set(); + + function canReach(currentId: string, targetId: string): boolean { + if (currentId === targetId) return true; + if (visited.has(currentId)) return false; + + visited.add(currentId); + const feature = featureMap.get(currentId); + if (!feature?.dependencies) return false; + + for (const depId of feature.dependencies) { + if (canReach(depId, targetId)) return true; + } + return false; + } + + // Check if source can reach target through existing dependencies + // If so, adding target -> source would create a cycle + return canReach(sourceId, targetId); +} + +/** + * Checks if a dependency already exists between two features. + * + * @param features - All features in the system + * @param sourceId - The potential dependency (prerequisite) + * @param targetId - The feature that might depend on sourceId + * @returns true if targetId already depends on sourceId + */ +export function dependencyExists(features: Feature[], sourceId: string, targetId: string): boolean { + const targetFeature = features.find((f) => f.id === targetId); + if (!targetFeature?.dependencies) return false; + return targetFeature.dependencies.includes(sourceId); +} diff --git a/apps/ui/src/components/views/graph-view/utils/index.ts b/apps/ui/src/components/views/graph-view/utils/index.ts new file mode 100644 index 00000000..c3c1a6fd --- /dev/null +++ b/apps/ui/src/components/views/graph-view/utils/index.ts @@ -0,0 +1,2 @@ +export * from './dependency-validation'; +export * from './ancestor-context'; diff --git a/package-lock.json b/package-lock.json index 2072c523..0f569f29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -1216,7 +1217,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -3747,6 +3748,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..c8245ff1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,407 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + tree-kill: + specifier: ^1.2.2 + version: 1.2.2 + devDependencies: + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.2.7 + version: 16.2.7 + prettier: + specifier: ^3.7.4 + version: 3.7.4 + +packages: + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + ansi-escapes@7.2.0: + resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} + engines: {node: '>=18'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + lint-staged@16.2.7: + resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + nano-spawn@2.0.0: + resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} + engines: {node: '>=20.17'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + prettier@3.7.4: + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + engines: {node: '>=14'} + hasBin: true + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + + ansi-escapes@7.2.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.1.1: + dependencies: + slice-ansi: 7.1.2 + string-width: 8.1.0 + + colorette@2.0.20: {} + + commander@14.0.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + emoji-regex@10.6.0: {} + + environment@1.1.0: {} + + eventemitter3@5.0.1: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + get-east-asian-width@1.4.0: {} + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + + husky@9.1.7: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.4.0 + + is-number@7.0.0: {} + + isexe@2.0.0: {} + + lint-staged@16.2.7: + dependencies: + commander: 14.0.2 + listr2: 9.0.5 + micromatch: 4.0.8 + nano-spawn: 2.0.0 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.8.2 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.1.1 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.2.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-function@5.0.1: {} + + nano-spawn@2.0.0: {} + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + path-key@3.1.1: {} + + picomatch@2.3.1: {} + + pidtree@0.6.0: {} + + prettier@3.7.4: {} + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rfdc@1.4.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + string-argv@0.3.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tree-kill@1.2.2: {} + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + + yaml@2.8.2: {} From 76b7cfec9e2470cf63186518f5675bd0925698d0 Mon Sep 17 00:00:00 2001 From: jbotwina Date: Tue, 23 Dec 2025 11:25:55 -0500 Subject: [PATCH 2/6] refactor: Move utility functions to @automaker/dependency-resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidated dependency validation and ancestor traversal utilities: - wouldCreateCircularDependency, dependencyExists -> @automaker/dependency-resolver - getAncestors, formatAncestorContextForPrompt, AncestorContext -> @automaker/dependency-resolver - Removed graph-view/utils directory (now redundant) - Updated all imports to use shared package 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../board-view/dialogs/add-feature-dialog.tsx | 4 +- .../shared/ancestor-context-section.tsx | 2 +- .../views/graph-view/graph-view.tsx | 2 +- .../graph-view/utils/ancestor-context.ts | 93 ----------- .../graph-view/utils/dependency-validation.ts | 51 ------ .../views/graph-view/utils/index.ts | 2 - libs/dependency-resolver/src/index.ts | 5 + libs/dependency-resolver/src/resolver.ts | 145 ++++++++++++++++++ 8 files changed, 154 insertions(+), 150 deletions(-) delete mode 100644 apps/ui/src/components/views/graph-view/utils/ancestor-context.ts delete mode 100644 apps/ui/src/components/views/graph-view/utils/dependency-validation.ts delete mode 100644 apps/ui/src/components/views/graph-view/utils/index.ts diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 88a24316..86f45505 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -52,8 +52,8 @@ import { useNavigate } from '@tanstack/react-router'; import { getAncestors, formatAncestorContextForPrompt, - AncestorContext, -} from '@/components/views/graph-view/utils'; + type AncestorContext, +} from '@automaker/dependency-resolver'; interface AddFeatureDialogProps { open: boolean; diff --git a/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx b/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx index 8d78fa43..bbdeebda 100644 --- a/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx +++ b/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx @@ -4,7 +4,7 @@ import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { ChevronDown, ChevronRight, Users } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { AncestorContext } from '@/components/views/graph-view/utils'; +import type { AncestorContext } from '@automaker/dependency-resolver'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; interface ParentFeatureContext { diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index 33874dc9..7435348f 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -3,7 +3,7 @@ import { Feature, useAppStore } from '@/store/app-store'; import { GraphCanvas } from './graph-canvas'; import { useBoardBackground } from '../board-view/hooks'; import { NodeActionCallbacks } from './hooks'; -import { wouldCreateCircularDependency, dependencyExists } from './utils'; +import { wouldCreateCircularDependency, dependencyExists } from '@automaker/dependency-resolver'; import { toast } from 'sonner'; interface GraphViewProps { diff --git a/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts b/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts deleted file mode 100644 index d0f2abaf..00000000 --- a/apps/ui/src/components/views/graph-view/utils/ancestor-context.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Feature } from '@/store/app-store'; - -export interface AncestorContext { - id: string; - title?: string; - description: string; - spec?: string; - summary?: string; - depth: number; // 0 = immediate parent, 1 = grandparent, etc. -} - -/** - * Traverses the dependency graph to find all ancestors of a feature. - * Returns ancestors ordered by depth (closest first). - * - * @param feature - The feature to find ancestors for - * @param allFeatures - All features in the system - * @param maxDepth - Maximum depth to traverse (prevents infinite loops) - * @returns Array of ancestor contexts, sorted by depth (closest first) - */ -export function getAncestors( - feature: Feature, - allFeatures: Feature[], - maxDepth: number = 10 -): AncestorContext[] { - const featureMap = new Map(allFeatures.map((f) => [f.id, f])); - const ancestors: AncestorContext[] = []; - const visited = new Set(); - - function traverse(featureId: string, depth: number) { - if (depth > maxDepth || visited.has(featureId)) return; - visited.add(featureId); - - const f = featureMap.get(featureId); - if (!f?.dependencies) return; - - for (const depId of f.dependencies) { - const dep = featureMap.get(depId); - if (dep && !visited.has(depId)) { - ancestors.push({ - id: dep.id, - title: dep.title, - description: dep.description, - spec: dep.spec, - summary: dep.summary, - depth, - }); - traverse(depId, depth + 1); - } - } - } - - traverse(feature.id, 0); - - // Sort by depth (closest ancestors first) - return ancestors.sort((a, b) => a.depth - b.depth); -} - -/** - * Formats ancestor context for inclusion in a task description. - * - * @param ancestors - Array of ancestor contexts (including parent) - * @param selectedIds - Set of selected ancestor IDs to include - * @returns Formatted markdown string with ancestor context - */ -export function formatAncestorContextForPrompt( - ancestors: AncestorContext[], - selectedIds: Set -): string { - const selectedAncestors = ancestors.filter((a) => selectedIds.has(a.id)); - if (selectedAncestors.length === 0) return ''; - - const sections = selectedAncestors.map((ancestor) => { - const parts: string[] = []; - const title = ancestor.title || `Task (${ancestor.id.slice(0, 8)})`; - - parts.push(`### ${title}`); - - if (ancestor.description) { - parts.push(`**Description:** ${ancestor.description}`); - } - if (ancestor.spec) { - parts.push(`**Specification:**\n${ancestor.spec}`); - } - if (ancestor.summary) { - parts.push(`**Summary:** ${ancestor.summary}`); - } - - return parts.join('\n\n'); - }); - - return `## Ancestor Context\n\n${sections.join('\n\n---\n\n')}`; -} diff --git a/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts b/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts deleted file mode 100644 index e3da76f8..00000000 --- a/apps/ui/src/components/views/graph-view/utils/dependency-validation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Feature } from '@/store/app-store'; - -/** - * Checks if adding a dependency from sourceId to targetId would create a circular dependency. - * Uses DFS to detect if targetId can reach sourceId through existing dependencies. - * - * @param features - All features in the system - * @param sourceId - The feature that would become a dependency (the prerequisite) - * @param targetId - The feature that would depend on sourceId - * @returns true if adding this dependency would create a cycle - */ -export function wouldCreateCircularDependency( - features: Feature[], - sourceId: string, - targetId: string -): boolean { - const featureMap = new Map(features.map((f) => [f.id, f])); - const visited = new Set(); - - function canReach(currentId: string, targetId: string): boolean { - if (currentId === targetId) return true; - if (visited.has(currentId)) return false; - - visited.add(currentId); - const feature = featureMap.get(currentId); - if (!feature?.dependencies) return false; - - for (const depId of feature.dependencies) { - if (canReach(depId, targetId)) return true; - } - return false; - } - - // Check if source can reach target through existing dependencies - // If so, adding target -> source would create a cycle - return canReach(sourceId, targetId); -} - -/** - * Checks if a dependency already exists between two features. - * - * @param features - All features in the system - * @param sourceId - The potential dependency (prerequisite) - * @param targetId - The feature that might depend on sourceId - * @returns true if targetId already depends on sourceId - */ -export function dependencyExists(features: Feature[], sourceId: string, targetId: string): boolean { - const targetFeature = features.find((f) => f.id === targetId); - if (!targetFeature?.dependencies) return false; - return targetFeature.dependencies.includes(sourceId); -} diff --git a/apps/ui/src/components/views/graph-view/utils/index.ts b/apps/ui/src/components/views/graph-view/utils/index.ts deleted file mode 100644 index c3c1a6fd..00000000 --- a/apps/ui/src/components/views/graph-view/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './dependency-validation'; -export * from './ancestor-context'; diff --git a/libs/dependency-resolver/src/index.ts b/libs/dependency-resolver/src/index.ts index 5f6d7259..9ecaa487 100644 --- a/libs/dependency-resolver/src/index.ts +++ b/libs/dependency-resolver/src/index.ts @@ -7,5 +7,10 @@ export { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + wouldCreateCircularDependency, + dependencyExists, + getAncestors, + formatAncestorContextForPrompt, type DependencyResolutionResult, + type AncestorContext, } from './resolver.js'; diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts index ec7e8273..c63b1a1a 100644 --- a/libs/dependency-resolver/src/resolver.ts +++ b/libs/dependency-resolver/src/resolver.ts @@ -209,3 +209,148 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[] return dep && dep.status !== 'completed' && dep.status !== 'verified'; }); } + +/** + * Checks if adding a dependency from sourceId to targetId would create a circular dependency. + * Uses DFS to detect if targetId can reach sourceId through existing dependencies. + * + * @param features - All features in the system + * @param sourceId - The feature that would become a dependency (the prerequisite) + * @param targetId - The feature that would depend on sourceId + * @returns true if adding this dependency would create a cycle + */ +export function wouldCreateCircularDependency( + features: Feature[], + sourceId: string, + targetId: string +): boolean { + const featureMap = new Map(features.map((f) => [f.id, f])); + const visited = new Set(); + + function canReach(currentId: string, target: string): boolean { + if (currentId === target) return true; + if (visited.has(currentId)) return false; + + visited.add(currentId); + const feature = featureMap.get(currentId); + if (!feature?.dependencies) return false; + + for (const depId of feature.dependencies) { + if (canReach(depId, target)) return true; + } + return false; + } + + // Check if source can reach target through existing dependencies + // If so, adding target -> source would create a cycle + return canReach(sourceId, targetId); +} + +/** + * Checks if a dependency already exists between two features. + * + * @param features - All features in the system + * @param sourceId - The potential dependency (prerequisite) + * @param targetId - The feature that might depend on sourceId + * @returns true if targetId already depends on sourceId + */ +export function dependencyExists(features: Feature[], sourceId: string, targetId: string): boolean { + const targetFeature = features.find((f) => f.id === targetId); + if (!targetFeature?.dependencies) return false; + return targetFeature.dependencies.includes(sourceId); +} + +/** + * Context information about an ancestor feature in the dependency graph. + */ +export interface AncestorContext { + id: string; + title?: string; + description: string; + spec?: string; + summary?: string; + depth: number; // 0 = immediate parent, 1 = grandparent, etc. +} + +/** + * Traverses the dependency graph to find all ancestors of a feature. + * Returns ancestors ordered by depth (closest first). + * + * @param feature - The feature to find ancestors for + * @param allFeatures - All features in the system + * @param maxDepth - Maximum depth to traverse (prevents infinite loops) + * @returns Array of ancestor contexts, sorted by depth (closest first) + */ +export function getAncestors( + feature: Feature, + allFeatures: Feature[], + maxDepth: number = 10 +): AncestorContext[] { + const featureMap = new Map(allFeatures.map((f) => [f.id, f])); + const ancestors: AncestorContext[] = []; + const visited = new Set(); + + function traverse(featureId: string, depth: number) { + if (depth > maxDepth || visited.has(featureId)) return; + visited.add(featureId); + + const f = featureMap.get(featureId); + if (!f?.dependencies) return; + + for (const depId of f.dependencies) { + const dep = featureMap.get(depId); + if (dep && !visited.has(depId)) { + ancestors.push({ + id: dep.id, + title: dep.title, + description: dep.description, + spec: dep.spec, + summary: dep.summary, + depth, + }); + traverse(depId, depth + 1); + } + } + } + + traverse(feature.id, 0); + + // Sort by depth (closest ancestors first) + return ancestors.sort((a, b) => a.depth - b.depth); +} + +/** + * Formats ancestor context for inclusion in a task description. + * + * @param ancestors - Array of ancestor contexts (including parent) + * @param selectedIds - Set of selected ancestor IDs to include + * @returns Formatted markdown string with ancestor context + */ +export function formatAncestorContextForPrompt( + ancestors: AncestorContext[], + selectedIds: Set +): string { + const selectedAncestors = ancestors.filter((a) => selectedIds.has(a.id)); + if (selectedAncestors.length === 0) return ''; + + const sections = selectedAncestors.map((ancestor) => { + const parts: string[] = []; + const title = ancestor.title || `Task (${ancestor.id.slice(0, 8)})`; + + parts.push(`### ${title}`); + + if (ancestor.description) { + parts.push(`**Description:** ${ancestor.description}`); + } + if (ancestor.spec) { + parts.push(`**Specification:**\n${ancestor.spec}`); + } + if (ancestor.summary) { + parts.push(`**Summary:** ${ancestor.summary}`); + } + + return parts.join('\n\n'); + }); + + return `## Ancestor Context\n\n${sections.join('\n\n---\n\n')}`; +} From 502043f6de4d89cdda0abbd2462930b6493a67df Mon Sep 17 00:00:00 2001 From: James Date: Tue, 23 Dec 2025 20:25:06 -0500 Subject: [PATCH 3/6] feat(graph-view): implement task deletion and dependency management enhancements - Added onDeleteTask functionality to allow task deletion from both board and graph views. - Integrated delete options for dependencies in the graph view, enhancing user interaction. - Updated ancestor context section to clarify the role of parent tasks in task descriptions. - Improved layout handling in graph view to preserve node positions during updates. This update enhances task management capabilities and improves user experience in the graph view. --- apps/ui/src/components/views/board-view.tsx | 1 + .../board-view/dialogs/add-feature-dialog.tsx | 5 +- .../shared/ancestor-context-section.tsx | 22 +- .../graph-view/components/dependency-edge.tsx | 66 +++++- .../views/graph-view/components/task-node.tsx | 17 ++ .../views/graph-view/graph-canvas.tsx | 61 +++++- .../views/graph-view/graph-view.tsx | 78 ++++++- .../graph-view/hooks/use-graph-layout.ts | 55 ++++- .../views/graph-view/hooks/use-graph-nodes.ts | 8 + libs/dependency-resolver/src/resolver.ts | 86 ++++++-- .../tests/resolver.test.ts | 202 ++++++++++++++++++ 11 files changed, 546 insertions(+), 55 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 66fa50d0..f30ec20f 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1057,6 +1057,7 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }} + onDeleteTask={(feature) => handleDeleteFeature(feature.id)} /> )} diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 86f45505..a5eea2c5 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -169,9 +169,8 @@ export function AddFeatureDialog({ if (parentFeature) { const ancestorList = getAncestors(parentFeature, allFeatures); setAncestors(ancestorList); - // Select all ancestors by default (including parent) - const allIds = new Set([parentFeature.id, ...ancestorList.map((a) => a.id)]); - setSelectedAncestorIds(allIds); + // Only select parent by default - ancestors are optional context + setSelectedAncestorIds(new Set([parentFeature.id])); } else { setAncestors([]); setSelectedAncestorIds(new Set()); diff --git a/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx b/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx index bbdeebda..c0a40293 100644 --- a/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx +++ b/apps/ui/src/components/views/board-view/shared/ancestor-context-section.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronRight, Users } from 'lucide-react'; +import { ChevronDown, ChevronRight, Users, CheckCircle2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { AncestorContext } from '@automaker/dependency-resolver'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -102,7 +102,8 @@ export function AncestorContextSection({

- Select ancestors to include their context in the new task's prompt. + The parent task context will be included to help the AI understand the background. + Additional ancestors can optionally be included for more context.

@@ -122,7 +123,13 @@ export function AncestorContextSection({
@@ -156,10 +163,13 @@ export function AncestorContextSection({ className="text-sm font-medium cursor-pointer truncate flex-1" > {displayTitle} - {item.isParent && ( - (Parent) - )} + {item.isParent && ( + + + Completed Parent + + )}
diff --git a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx index cbf3cdb9..95f72870 100644 --- a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx +++ b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx @@ -1,14 +1,16 @@ -import { memo } from 'react'; +import { memo, useState } 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'; +import { Trash2 } from 'lucide-react'; export interface DependencyEdgeData { sourceStatus: Feature['status']; targetStatus: Feature['status']; isHighlighted?: boolean; isDimmed?: boolean; + onDeleteDependency?: (sourceId: string, targetId: string) => void; } const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => { @@ -31,6 +33,8 @@ const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature[' export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { const { id, + source, + target, sourceX, sourceY, targetX, @@ -42,6 +46,7 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { animated, } = props; + const [isHovered, setIsHovered] = useState(false); const edgeData = data as DependencyEdgeData | undefined; const [edgePath, labelX, labelY] = getBezierPath({ @@ -67,14 +72,30 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified'; const isInProgress = edgeData?.targetStatus === 'in_progress'; + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + edgeData?.onDeleteDependency?.(source, target); + }; + return ( <> + {/* Invisible wider path for hover detection */} + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ cursor: 'pointer' }} + /> + {/* Background edge for better visibility */} + {/* Delete button on hover or select */} + {(isHovered || selected) && edgeData?.onDeleteDependency && ( + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + +
+
+ )} + {/* Animated particles for in-progress edges */} - {animated && ( + {animated && !isHovered && (
{/* Target handle (left side - receives dependencies) */} Spawn Sub-Task + {!data.isRunning && ( + { + e.stopPropagation(); + data.onDeleteTask?.(); + }} + > + + Delete Task + + )}
@@ -336,8 +351,10 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps {/* Source handle (right side - provides to dependents) */} >(new Set()); + + // Update nodes/edges when features change, but preserve user positions useEffect(() => { - setNodes(layoutedNodes); - setEdges(layoutedEdges); + const currentNodeIds = new Set(layoutedNodes.map((n) => n.id)); + const isInitialRender = !hasInitialLayout.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 + setNodes(layoutedNodes); + setEdges(layoutedEdges); + hasInitialLayout.current = true; + } else if (hasNewNodes) { + // New nodes added - need to re-layout but try to preserve existing positions + setNodes((currentNodes) => { + const positionMap = new Map(currentNodes.map((n) => [n.id, n.position])); + return layoutedNodes.map((node) => ({ + ...node, + position: positionMap.get(node.id) || node.position, + })); + }); + setEdges(layoutedEdges); + } else { + // No new nodes - just update data without changing positions + setNodes((currentNodes) => { + const positionMap = new Map(currentNodes.map((n) => [n.id, n.position])); + // Filter to only include nodes that still exist + const existingNodeIds = new Set(layoutedNodes.map((n) => n.id)); + + return layoutedNodes.map((node) => ({ + ...node, + position: positionMap.get(node.id) || node.position, + })); + }); + // Update edges without triggering re-render of nodes + setEdges(layoutedEdges); + } + + // Update prev node IDs for next comparison + prevNodeIds.current = currentNodeIds; }, [layoutedNodes, layoutedEdges, setNodes, setEdges]); // Handle layout direction change @@ -154,6 +196,16 @@ function GraphCanvasInner({ [onCreateDependency] ); + // Allow any connection between different nodes + const isValidConnection = useCallback( + (connection: Connection | { source: string; target: string }) => { + // Don't allow self-connections + if (connection.source === connection.target) return false; + return true; + }, + [] + ); + // MiniMap node color based on status const minimapNodeColor = useCallback((node: Node) => { const data = node.data as TaskNodeData | undefined; @@ -182,6 +234,7 @@ function GraphCanvasInner({ onEdgesChange={onEdgesChange} onNodeDoubleClick={handleNodeDoubleClick} onConnect={handleConnect} + isValidConnection={isValidConnection} nodeTypes={nodeTypes} edgeTypes={edgeTypes} fitView diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index 7435348f..b3ca5e3c 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -21,6 +21,7 @@ interface GraphViewProps { onResumeTask?: (feature: Feature) => void; onUpdateFeature?: (featureId: string, updates: Partial) => void; onSpawnTask?: (feature: Feature) => void; + onDeleteTask?: (feature: Feature) => void; } export function GraphView({ @@ -38,6 +39,7 @@ export function GraphView({ onResumeTask, onUpdateFeature, onSpawnTask, + onDeleteTask, }: GraphViewProps) { const { currentProject } = useAppStore(); @@ -83,6 +85,34 @@ export function GraphView({ // Handle creating a dependency via edge connection const handleCreateDependency = useCallback( async (sourceId: string, targetId: string): Promise => { + const sourceFeature = features.find((f) => f.id === sourceId); + const targetFeature = features.find((f) => f.id === targetId); + + // Debug logging + console.log('[Dependency Check] ==========================='); + console.log('[Dependency Check] Source (prerequisite):', { + id: sourceId, + title: sourceFeature?.title || sourceFeature?.description?.slice(0, 50), + dependencies: sourceFeature?.dependencies, + }); + console.log('[Dependency Check] Target (will depend on source):', { + id: targetId, + title: targetFeature?.title || targetFeature?.description?.slice(0, 50), + dependencies: targetFeature?.dependencies, + }); + console.log( + '[Dependency Check] Action:', + `${targetFeature?.title || targetId} will depend on ${sourceFeature?.title || sourceId}` + ); + console.log( + '[Dependency Check] All features:', + features.map((f) => ({ + id: f.id, + title: f.title || f.description?.slice(0, 30), + deps: f.dependencies, + })) + ); + // Prevent self-dependency if (sourceId === targetId) { toast.error('A task cannot depend on itself'); @@ -96,7 +126,18 @@ export function GraphView({ } // Check for circular dependency - if (wouldCreateCircularDependency(features, sourceId, targetId)) { + // This checks: if we make targetId depend on sourceId, would it create a cycle? + // A cycle would occur if sourceId already depends on targetId (transitively) + console.log('[Cycle Check] Checking if adding dependency would create cycle...'); + console.log( + '[Cycle Check] Would create cycle if:', + sourceFeature?.title || sourceId, + 'already depends on', + targetFeature?.title || targetId + ); + const wouldCycle = wouldCreateCircularDependency(features, sourceId, targetId); + console.log('[Cycle Check] Result:', wouldCycle ? 'WOULD CREATE CYCLE' : 'Safe to add'); + if (wouldCycle) { toast.error('Cannot create circular dependency', { description: 'This would create a dependency cycle', }); @@ -104,13 +145,12 @@ export function GraphView({ } // Get target feature and update its dependencies - const targetFeature = features.find((f) => f.id === targetId); if (!targetFeature) { toast.error('Target task not found'); return false; } - const currentDeps = targetFeature.dependencies || []; + const currentDeps = (targetFeature.dependencies as string[] | undefined) || []; // Add the dependency onUpdateFeature?.(targetId, { @@ -162,8 +202,38 @@ export function GraphView({ onSpawnTask?.(feature); } }, + onDeleteTask: (featureId: string) => { + const feature = features.find((f) => f.id === featureId); + if (feature) { + onDeleteTask?.(feature); + } + }, + onDeleteDependency: (sourceId: string, targetId: string) => { + // Find the target feature and remove the source from its dependencies + const targetFeature = features.find((f) => f.id === targetId); + if (!targetFeature) return; + + const currentDeps = (targetFeature.dependencies as string[] | undefined) || []; + const newDeps = currentDeps.filter((depId) => depId !== sourceId); + + onUpdateFeature?.(targetId, { + dependencies: newDeps, + }); + + toast.success('Dependency removed'); + }, }), - [features, onViewOutput, onEditFeature, onStartTask, onStopTask, onResumeTask, onSpawnTask] + [ + features, + onViewOutput, + onEditFeature, + onStartTask, + onStopTask, + onResumeTask, + onSpawnTask, + onDeleteTask, + onUpdateFeature, + ] ); return ( diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts index b44b7bcf..667ef737 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import dagre from 'dagre'; import { Node, Edge, useReactFlow } from '@xyflow/react'; import { TaskNode, DependencyEdge } from './use-graph-nodes'; @@ -18,6 +18,10 @@ interface UseGraphLayoutProps { export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) { const { fitView, setNodes } = useReactFlow(); + // Cache the last computed positions to avoid recalculating layout + const positionCache = useRef>(new Map()); + const lastStructureKey = useRef(''); + const getLayoutedElements = useCallback( ( inputNodes: TaskNode[], @@ -48,12 +52,15 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) { const layoutedNodes = inputNodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id); + const position = { + x: nodeWithPosition.x - NODE_WIDTH / 2, + y: nodeWithPosition.y - NODE_HEIGHT / 2, + }; + // Update cache + positionCache.current.set(node.id, position); return { ...node, - position: { - x: nodeWithPosition.x - NODE_WIDTH / 2, - y: nodeWithPosition.y - NODE_HEIGHT / 2, - }, + position, targetPosition: isHorizontal ? 'left' : 'top', sourcePosition: isHorizontal ? 'right' : 'bottom', } as TaskNode; @@ -64,13 +71,45 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) { [] ); - // Initial layout + // Create a stable structure key based only on node IDs (not edge changes) + // Edges changing shouldn't trigger re-layout + const structureKey = useMemo(() => { + const nodeIds = nodes + .map((n) => n.id) + .sort() + .join(','); + return nodeIds; + }, [nodes]); + + // Initial layout - only recalculate when node structure changes (new nodes added/removed) const layoutedElements = useMemo(() => { if (nodes.length === 0) { + positionCache.current.clear(); + lastStructureKey.current = ''; return { nodes: [], edges: [] }; } - return getLayoutedElements(nodes, edges, 'LR'); - }, [nodes, edges, getLayoutedElements]); + + // Check if structure changed (new nodes added or removed) + const structureChanged = structureKey !== lastStructureKey.current; + + if (structureChanged) { + // Structure changed - run full layout + lastStructureKey.current = structureKey; + return getLayoutedElements(nodes, edges, 'LR'); + } else { + // Structure unchanged - preserve cached positions, just update node data + const layoutedNodes = nodes.map((node) => { + const cachedPosition = positionCache.current.get(node.id); + return { + ...node, + position: cachedPosition || { x: 0, y: 0 }, + targetPosition: 'left', + sourcePosition: 'right', + } as TaskNode; + }); + return { nodes: layoutedNodes, edges }; + } + }, [nodes, edges, structureKey, getLayoutedElements]); // Manual re-layout function const runLayout = useCallback( diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index e216462d..d9b340a9 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -25,6 +25,7 @@ export interface TaskNodeData extends Feature { onStopTask?: () => void; onResumeTask?: () => void; onSpawnTask?: () => void; + onDeleteTask?: () => void; } export type TaskNode = Node; @@ -33,6 +34,7 @@ export type DependencyEdge = Edge<{ targetStatus: Feature['status']; isHighlighted?: boolean; isDimmed?: boolean; + onDeleteDependency?: (sourceId: string, targetId: string) => void; }>; export interface NodeActionCallbacks { @@ -42,6 +44,8 @@ export interface NodeActionCallbacks { onStopTask?: (featureId: string) => void; onResumeTask?: (featureId: string) => void; onSpawnTask?: (featureId: string) => void; + onDeleteTask?: (featureId: string) => void; + onDeleteDependency?: (sourceId: string, targetId: string) => void; } interface UseGraphNodesProps { @@ -117,6 +121,9 @@ export function useGraphNodes({ onSpawnTask: actionCallbacks?.onSpawnTask ? () => actionCallbacks.onSpawnTask!(feature.id) : undefined, + onDeleteTask: actionCallbacks?.onDeleteTask + ? () => actionCallbacks.onDeleteTask!(feature.id) + : undefined, }, }; @@ -146,6 +153,7 @@ export function useGraphNodes({ targetStatus: feature.status, isHighlighted: edgeIsHighlighted, isDimmed: edgeIsDimmed, + onDeleteDependency: actionCallbacks?.onDeleteDependency, }, }; edgeList.push(edge); diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts index c63b1a1a..f54524c0 100644 --- a/libs/dependency-resolver/src/resolver.ts +++ b/libs/dependency-resolver/src/resolver.ts @@ -212,7 +212,8 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[] /** * Checks if adding a dependency from sourceId to targetId would create a circular dependency. - * Uses DFS to detect if targetId can reach sourceId through existing dependencies. + * When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies. + * A cycle would occur if sourceId already depends on targetId (directly or transitively). * * @param features - All features in the system * @param sourceId - The feature that would become a dependency (the prerequisite) @@ -227,22 +228,24 @@ export function wouldCreateCircularDependency( const featureMap = new Map(features.map((f) => [f.id, f])); const visited = new Set(); - function canReach(currentId: string, target: string): boolean { - if (currentId === target) return true; - if (visited.has(currentId)) return false; + // Check if 'from' can reach 'to' by following dependencies + function canReach(fromId: string, toId: string): boolean { + if (fromId === toId) return true; + if (visited.has(fromId)) return false; - visited.add(currentId); - const feature = featureMap.get(currentId); + visited.add(fromId); + const feature = featureMap.get(fromId); if (!feature?.dependencies) return false; for (const depId of feature.dependencies) { - if (canReach(depId, target)) return true; + if (canReach(depId, toId)) return true; } return false; } - // Check if source can reach target through existing dependencies - // If so, adding target -> source would create a cycle + // We want to add: targetId depends on sourceId (sourceId -> targetId in dependency graph) + // This would create a cycle if sourceId already depends on targetId (transitively) + // i.e., if we can reach targetId starting from sourceId by following dependencies return canReach(sourceId, targetId); } @@ -321,8 +324,10 @@ export function getAncestors( /** * Formats ancestor context for inclusion in a task description. + * The parent task (depth=-1) is formatted with special emphasis indicating + * it was already completed and is provided for context only. * - * @param ancestors - Array of ancestor contexts (including parent) + * @param ancestors - Array of ancestor contexts (including parent with depth=-1) * @param selectedIds - Set of selected ancestor IDs to include * @returns Formatted markdown string with ancestor context */ @@ -333,24 +338,59 @@ export function formatAncestorContextForPrompt( const selectedAncestors = ancestors.filter((a) => selectedIds.has(a.id)); if (selectedAncestors.length === 0) return ''; - const sections = selectedAncestors.map((ancestor) => { - const parts: string[] = []; - const title = ancestor.title || `Task (${ancestor.id.slice(0, 8)})`; + // Separate parent (depth=-1) from other ancestors + const parent = selectedAncestors.find((a) => a.depth === -1); + const otherAncestors = selectedAncestors.filter((a) => a.depth !== -1); - parts.push(`### ${title}`); + const sections: string[] = []; - if (ancestor.description) { - parts.push(`**Description:** ${ancestor.description}`); + // Format parent with special emphasis + if (parent) { + const parentTitle = parent.title || `Task (${parent.id.slice(0, 8)})`; + const parentParts: string[] = []; + + parentParts.push(`## Parent Task Context (Already Completed)`); + parentParts.push( + `> **Note:** The following parent task has already been completed. This context is provided to help you understand the background and requirements for this sub-task. Do not re-implement the parent task - focus only on the new sub-task described below.` + ); + parentParts.push(`### ${parentTitle}`); + + if (parent.description) { + parentParts.push(`**Description:** ${parent.description}`); } - if (ancestor.spec) { - parts.push(`**Specification:**\n${ancestor.spec}`); + if (parent.spec) { + parentParts.push(`**Specification:**\n${parent.spec}`); } - if (ancestor.summary) { - parts.push(`**Summary:** ${ancestor.summary}`); + if (parent.summary) { + parentParts.push(`**Summary:** ${parent.summary}`); } - return parts.join('\n\n'); - }); + sections.push(parentParts.join('\n\n')); + } - return `## Ancestor Context\n\n${sections.join('\n\n---\n\n')}`; + // Format other ancestors if any + if (otherAncestors.length > 0) { + const ancestorSections = otherAncestors.map((ancestor) => { + const parts: string[] = []; + const title = ancestor.title || `Task (${ancestor.id.slice(0, 8)})`; + + parts.push(`### ${title}`); + + if (ancestor.description) { + parts.push(`**Description:** ${ancestor.description}`); + } + if (ancestor.spec) { + parts.push(`**Specification:**\n${ancestor.spec}`); + } + if (ancestor.summary) { + parts.push(`**Summary:** ${ancestor.summary}`); + } + + return parts.join('\n\n'); + }); + + sections.push(`## Additional Ancestor Context\n\n${ancestorSections.join('\n\n---\n\n')}`); + } + + return sections.join('\n\n---\n\n'); } diff --git a/libs/dependency-resolver/tests/resolver.test.ts b/libs/dependency-resolver/tests/resolver.test.ts index a44669e5..5f246b2a 100644 --- a/libs/dependency-resolver/tests/resolver.test.ts +++ b/libs/dependency-resolver/tests/resolver.test.ts @@ -3,6 +3,8 @@ import { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + wouldCreateCircularDependency, + dependencyExists, } from '../src/resolver'; import type { Feature } from '@automaker/types'; @@ -348,4 +350,204 @@ describe('resolver.ts', () => { expect(blocking).not.toContain('Dep2'); }); }); + + describe('wouldCreateCircularDependency', () => { + it('should return false for features with no existing dependencies', () => { + const features = [createFeature('A'), createFeature('B')]; + + // Making B depend on A should not create a cycle + expect(wouldCreateCircularDependency(features, 'A', 'B')).toBe(false); + }); + + it('should return false for valid linear dependency chain', () => { + // A <- B <- C (C depends on B, B depends on A) + const features = [ + createFeature('A'), + createFeature('B', { dependencies: ['A'] }), + createFeature('C', { dependencies: ['B'] }), + ]; + + // Making D depend on C should not create a cycle + const featuresWithD = [...features, createFeature('D')]; + expect(wouldCreateCircularDependency(featuresWithD, 'C', 'D')).toBe(false); + }); + + it('should detect direct circular dependency (A -> B -> A)', () => { + // B depends on A + const features = [createFeature('A'), createFeature('B', { dependencies: ['A'] })]; + + // Making A depend on B would create: A -> B -> A (cycle!) + // sourceId = B (prerequisite), targetId = A (will depend on B) + // This creates a cycle because B already depends on A + expect(wouldCreateCircularDependency(features, 'B', 'A')).toBe(true); + }); + + it('should detect transitive circular dependency (A -> B -> C -> A)', () => { + // C depends on B, B depends on A + const features = [ + createFeature('A'), + createFeature('B', { dependencies: ['A'] }), + createFeature('C', { dependencies: ['B'] }), + ]; + + // Making A depend on C would create: A -> C -> B -> A (cycle!) + // sourceId = C (prerequisite), targetId = A (will depend on C) + expect(wouldCreateCircularDependency(features, 'C', 'A')).toBe(true); + }); + + it('should detect cycle in complex graph', () => { + // Graph: A <- B, A <- C, B <- C (C depends on both A and B, B depends on A) + const features = [ + createFeature('A'), + createFeature('B', { dependencies: ['A'] }), + createFeature('C', { dependencies: ['A', 'B'] }), + ]; + + // Making A depend on C would create a cycle + expect(wouldCreateCircularDependency(features, 'C', 'A')).toBe(true); + + // Making B depend on C would also create a cycle + expect(wouldCreateCircularDependency(features, 'C', 'B')).toBe(true); + }); + + it('should return false for parallel branches', () => { + // A <- B, A <- C (B and C both depend on A, but not on each other) + const features = [ + createFeature('A'), + createFeature('B', { dependencies: ['A'] }), + createFeature('C', { dependencies: ['A'] }), + ]; + + // Making B depend on C should be fine (no cycle) + expect(wouldCreateCircularDependency(features, 'C', 'B')).toBe(false); + + // Making C depend on B should also be fine + expect(wouldCreateCircularDependency(features, 'B', 'C')).toBe(false); + }); + + it('should handle self-dependency check', () => { + const features = [createFeature('A')]; + + // A depending on itself would be a trivial cycle + expect(wouldCreateCircularDependency(features, 'A', 'A')).toBe(true); + }); + + it('should handle feature not in list', () => { + const features = [createFeature('A')]; + + // Non-existent source - should return false (no path exists) + expect(wouldCreateCircularDependency(features, 'NonExistent', 'A')).toBe(false); + + // Non-existent target - should return false + expect(wouldCreateCircularDependency(features, 'A', 'NonExistent')).toBe(false); + }); + + it('should handle empty features list', () => { + const features: Feature[] = []; + + expect(wouldCreateCircularDependency(features, 'A', 'B')).toBe(false); + }); + + it('should handle longer transitive chains', () => { + // A <- B <- C <- D <- E + const features = [ + createFeature('A'), + createFeature('B', { dependencies: ['A'] }), + createFeature('C', { dependencies: ['B'] }), + createFeature('D', { dependencies: ['C'] }), + createFeature('E', { dependencies: ['D'] }), + ]; + + // Making A depend on E would create a 5-node cycle + expect(wouldCreateCircularDependency(features, 'E', 'A')).toBe(true); + + // Making B depend on E would create a 4-node cycle + expect(wouldCreateCircularDependency(features, 'E', 'B')).toBe(true); + + // Making E depend on A is fine (already exists transitively, but adding explicit is ok) + // Wait, E already depends on A transitively. Let's add F instead + const featuresWithF = [...features, createFeature('F')]; + expect(wouldCreateCircularDependency(featuresWithF, 'E', 'F')).toBe(false); + }); + + it('should handle diamond dependency pattern', () => { + // A + // / \ + // B C + // \ / + // D + const features = [ + createFeature('A'), + createFeature('B', { dependencies: ['A'] }), + createFeature('C', { dependencies: ['A'] }), + createFeature('D', { dependencies: ['B', 'C'] }), + ]; + + // Making A depend on D would create a cycle through both paths + expect(wouldCreateCircularDependency(features, 'D', 'A')).toBe(true); + + // Making B depend on D would create a cycle + expect(wouldCreateCircularDependency(features, 'D', 'B')).toBe(true); + + // Adding E that depends on D should be fine + const featuresWithE = [...features, createFeature('E')]; + expect(wouldCreateCircularDependency(featuresWithE, 'D', 'E')).toBe(false); + }); + }); + + describe('dependencyExists', () => { + it('should return false when target has no dependencies', () => { + const features = [createFeature('A'), createFeature('B')]; + + expect(dependencyExists(features, 'A', 'B')).toBe(false); + }); + + it('should return true when direct dependency exists', () => { + const features = [createFeature('A'), createFeature('B', { dependencies: ['A'] })]; + + expect(dependencyExists(features, 'A', 'B')).toBe(true); + }); + + it('should return false for reverse direction', () => { + const features = [createFeature('A'), createFeature('B', { dependencies: ['A'] })]; + + // B depends on A, but A does not depend on B + expect(dependencyExists(features, 'B', 'A')).toBe(false); + }); + + it('should return false for transitive dependencies', () => { + // This function only checks direct dependencies, not transitive + const features = [ + createFeature('A'), + createFeature('B', { dependencies: ['A'] }), + createFeature('C', { dependencies: ['B'] }), + ]; + + // C depends on B which depends on A, but C doesn't directly depend on A + expect(dependencyExists(features, 'A', 'C')).toBe(false); + }); + + it('should return true for one of multiple dependencies', () => { + const features = [ + createFeature('A'), + createFeature('B'), + createFeature('C', { dependencies: ['A', 'B'] }), + ]; + + expect(dependencyExists(features, 'A', 'C')).toBe(true); + expect(dependencyExists(features, 'B', 'C')).toBe(true); + }); + + it('should return false when target feature does not exist', () => { + const features = [createFeature('A')]; + + expect(dependencyExists(features, 'A', 'NonExistent')).toBe(false); + }); + + it('should return false for empty dependencies array', () => { + const features = [createFeature('A'), createFeature('B', { dependencies: [] })]; + + expect(dependencyExists(features, 'A', 'B')).toBe(false); + }); + }); }); From 3307ff81007152748329b50d6fd9f63001dfa3dc Mon Sep 17 00:00:00 2001 From: James Date: Tue, 23 Dec 2025 20:29:14 -0500 Subject: [PATCH 4/6] fix lock --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 0f569f29..e51794c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1217,7 +1217,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", From 686a24d3c6f3ca6473b090906228aaf5c3b0da8c Mon Sep 17 00:00:00 2001 From: James Date: Tue, 23 Dec 2025 20:39:28 -0500 Subject: [PATCH 5/6] small log fix --- .../views/graph-view/graph-canvas.tsx | 3 -- .../views/graph-view/graph-view.tsx | 34 ------------------- 2 files changed, 37 deletions(-) diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index 54e9192d..06d32dce 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -142,9 +142,6 @@ function GraphCanvasInner({ // No new nodes - just update data without changing positions setNodes((currentNodes) => { const positionMap = new Map(currentNodes.map((n) => [n.id, n.position])); - // Filter to only include nodes that still exist - const existingNodeIds = new Set(layoutedNodes.map((n) => n.id)); - return layoutedNodes.map((node) => ({ ...node, position: positionMap.get(node.id) || node.position, diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index b3ca5e3c..fbb33960 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -85,34 +85,8 @@ export function GraphView({ // Handle creating a dependency via edge connection const handleCreateDependency = useCallback( async (sourceId: string, targetId: string): Promise => { - const sourceFeature = features.find((f) => f.id === sourceId); const targetFeature = features.find((f) => f.id === targetId); - // Debug logging - console.log('[Dependency Check] ==========================='); - console.log('[Dependency Check] Source (prerequisite):', { - id: sourceId, - title: sourceFeature?.title || sourceFeature?.description?.slice(0, 50), - dependencies: sourceFeature?.dependencies, - }); - console.log('[Dependency Check] Target (will depend on source):', { - id: targetId, - title: targetFeature?.title || targetFeature?.description?.slice(0, 50), - dependencies: targetFeature?.dependencies, - }); - console.log( - '[Dependency Check] Action:', - `${targetFeature?.title || targetId} will depend on ${sourceFeature?.title || sourceId}` - ); - console.log( - '[Dependency Check] All features:', - features.map((f) => ({ - id: f.id, - title: f.title || f.description?.slice(0, 30), - deps: f.dependencies, - })) - ); - // Prevent self-dependency if (sourceId === targetId) { toast.error('A task cannot depend on itself'); @@ -128,15 +102,7 @@ export function GraphView({ // Check for circular dependency // This checks: if we make targetId depend on sourceId, would it create a cycle? // A cycle would occur if sourceId already depends on targetId (transitively) - console.log('[Cycle Check] Checking if adding dependency would create cycle...'); - console.log( - '[Cycle Check] Would create cycle if:', - sourceFeature?.title || sourceId, - 'already depends on', - targetFeature?.title || targetId - ); const wouldCycle = wouldCreateCircularDependency(features, sourceId, targetId); - console.log('[Cycle Check] Result:', wouldCycle ? 'WOULD CREATE CYCLE' : 'Safe to add'); if (wouldCycle) { toast.error('Cannot create circular dependency', { description: 'This would create a dependency cycle', From 34c0d39e39adfc7f0d192ac5ea6854c9e32fa5ef Mon Sep 17 00:00:00 2001 From: James Date: Tue, 23 Dec 2025 20:44:05 -0500 Subject: [PATCH 6/6] fix --- apps/ui/src/components/views/board-view.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index f30ec20f..f54d8bc8 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1050,9 +1050,7 @@ export function BoardView() { onStartTask={handleStartImplementation} onStopTask={handleForceStopFeature} onResumeTask={handleResumeFeature} - onUpdateFeature={(featureId, updates) => { - handleUpdateFeature(featureId, updates); - }} + onUpdateFeature={updateFeature} onSpawnTask={(feature) => { setSpawnParentFeature(feature); setShowAddDialog(true);