feat: Add task dependencies and spawn sub-task functionality

- 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 <noreply@anthropic.com>
This commit is contained in:
jbotwina
2025-12-23 11:02:17 -05:00
committed by James
parent d50b15e639
commit 8d80c73faa
19 changed files with 1057 additions and 16 deletions

View File

@@ -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
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onSpawnTask?.();
}}
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -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<boolean>;
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<TaskNodeData>) => {
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

View File

@@ -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<Feature>) => 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<boolean> => {
// 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"
/>

View File

@@ -24,6 +24,7 @@ export interface TaskNodeData extends Feature {
onStartTask?: () => void;
onStopTask?: () => void;
onResumeTask?: () => void;
onSpawnTask?: () => void;
}
export type TaskNode = Node<TaskNodeData, 'task'>;
@@ -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,
},
};

View File

@@ -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<string>();
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>
): 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')}`;
}

View File

@@ -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<string>();
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);
}

View File

@@ -0,0 +1,2 @@
export * from './dependency-validation';
export * from './ancestor-context';