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

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

View File

@@ -107,6 +107,9 @@ export function BoardView() {
// State for viewing plan in read-only mode
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
// State for spawn task mode
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(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);
}}
/>
)}
</div>
@@ -1077,7 +1091,12 @@ export function BoardView() {
{/* Add Feature Dialog */}
<AddFeatureDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
onOpenChange={(open) => {
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 */}

View File

@@ -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 className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-running-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
@@ -106,7 +120,21 @@ export function CardHeaderSection({
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<div className="absolute top-2 right-2">
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-backlog-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -141,6 +169,22 @@ export function CardHeaderSection({
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
{onViewOutput && (
<Button
variant="ghost"
@@ -229,6 +273,17 @@ export function CardHeaderSection({
View Logs
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-feature-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">

View File

@@ -25,6 +25,7 @@ interface KanbanCardProps {
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -51,6 +52,7 @@ export const KanbanCard = memo(function KanbanCard({
onComplete,
onViewPlan,
onApprovePlan,
onSpawnTask,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -146,6 +148,7 @@ export const KanbanCard = memo(function KanbanCard({
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
onSpawnTask={onSpawnTask}
/>
<CardContent className="px-3 pt-0 pb-0">

View File

@@ -19,14 +19,7 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
} from 'lucide-react';
import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
@@ -37,6 +30,7 @@ import {
FeatureImage,
AIProfile,
PlanningMode,
Feature,
} from '@/store/app-store';
import {
ModelSelector,
@@ -46,6 +40,7 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
AncestorContextSection,
} from '../shared';
import {
DropdownMenu,
@@ -54,6 +49,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router';
import {
getAncestors,
formatAncestorContextForPrompt,
AncestorContext,
} from '@/components/views/graph-view/utils';
interface AddFeatureDialogProps {
open: boolean;
@@ -72,6 +72,7 @@ interface AddFeatureDialogProps {
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -82,6 +83,9 @@ interface AddFeatureDialogProps {
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
// Spawn task mode props
parentFeature?: Feature | null;
allFeatures?: Feature[];
}
export function AddFeatureDialog({
@@ -97,7 +101,10 @@ export function AddFeatureDialog({
isMaximized,
showProfilesOnly,
aiProfiles,
parentFeature = null,
allFeatures = [],
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
@@ -125,6 +132,10 @@ export function AddFeatureDialog({
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get enhancement model, planning mode defaults, and worktrees setting from store
const {
enhancementModel,
@@ -153,6 +164,18 @@ export function AddFeatureDialog({
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
// Initialize ancestors for spawn mode
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);
} else {
setAncestors([]);
setSelectedAncestorIds(new Set());
}
}
}, [
open,
@@ -162,6 +185,8 @@ export function AddFeatureDialog({
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
parentFeature,
allFeatures,
]);
const handleAdd = () => {
@@ -187,10 +212,34 @@ export function AddFeatureDialog({
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
// Build final description - prepend ancestor context in spawn mode
let finalDescription = newFeature.description;
if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) {
// Create parent context as an AncestorContext
const parentContext: AncestorContext = {
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
depth: -1,
};
const allAncestorsWithParent = [parentContext, ...ancestors];
const contextText = formatAncestorContextForPrompt(
allAncestorsWithParent,
selectedAncestorIds
);
if (contextText) {
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`;
}
}
onAdd({
title: newFeature.title,
category,
description: newFeature.description,
description: finalDescription,
images: newFeature.images,
imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths,
@@ -201,6 +250,8 @@ export function AddFeatureDialog({
priority: newFeature.priority,
planningMode,
requirePlanApproval,
// In spawn mode, automatically add parent as dependency
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
});
// Reset form
@@ -299,8 +350,12 @@ export function AddFeatureDialog({
}}
>
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>Create a new feature card for the Kanban board.</DialogDescription>
<DialogTitle>{isSpawnMode ? 'Spawn Sub-Task' : 'Add New Feature'}</DialogTitle>
<DialogDescription>
{isSpawnMode
? `Create a sub-task that depends on "${parentFeature?.title || parentFeature?.description.slice(0, 50)}..."`
: 'Create a new feature card for the Kanban board.'}
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4">
@@ -320,6 +375,22 @@ export function AddFeatureDialog({
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
{/* Ancestor Context Section - only in spawn mode */}
{isSpawnMode && parentFeature && (
<AncestorContextSection
parentFeature={{
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
}}
ancestors={ancestors}
selectedAncestorIds={selectedAncestorIds}
onSelectionChange={setSelectedAncestorIds}
/>
)}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
@@ -512,7 +583,7 @@ export function AddFeatureDialog({
data-testid="confirm-add-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
Add Feature
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
</HotkeyButton>
</DialogFooter>
</DialogContent>

View File

@@ -98,6 +98,7 @@ export function useBoardActions({
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
}) => {
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
// Non-empty string is the actual branch name (for non-primary worktrees)
@@ -150,6 +151,7 @@ export function useBoardActions({
titleGenerating: needsTitleGeneration,
status: 'backlog' as const,
branchName: finalBranchName,
dependencies: featureData.dependencies || [],
};
const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it

View File

@@ -41,6 +41,7 @@ interface KanbanBoardProps {
onImplement: (feature: Feature) => void;
onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
@@ -73,6 +74,7 @@ export function KanbanBoard({
onImplement,
onViewPlan,
onApprovePlan,
onSpawnTask,
featuresWithContext,
runningAutoTasks,
shortcuts,
@@ -184,6 +186,7 @@ export function KanbanBoard({
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}

View File

@@ -0,0 +1,201 @@
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 { cn } from '@/lib/utils';
import { AncestorContext } from '@/components/views/graph-view/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
interface ParentFeatureContext {
id: string;
title?: string;
description: string;
spec?: string;
summary?: string;
}
interface AncestorContextSectionProps {
parentFeature: ParentFeatureContext;
ancestors: AncestorContext[];
selectedAncestorIds: Set<string>;
onSelectionChange: (ids: Set<string>) => void;
}
export function AncestorContextSection({
parentFeature,
ancestors,
selectedAncestorIds,
onSelectionChange,
}: AncestorContextSectionProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const toggleExpanded = (id: string) => {
const newExpanded = new Set(expandedIds);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedIds(newExpanded);
};
const toggleSelected = (id: string) => {
const newSelected = new Set(selectedAncestorIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
onSelectionChange(newSelected);
};
const selectAll = () => {
const allIds = new Set([parentFeature.id, ...ancestors.map((a) => a.id)]);
onSelectionChange(allIds);
};
const selectNone = () => {
onSelectionChange(new Set());
};
// Combine parent and ancestors into a single list
const allAncestorItems: Array<
(AncestorContext | ParentFeatureContext) & { isParent: boolean; depth: number }
> = [
{ ...parentFeature, depth: -1, isParent: true },
...ancestors.map((a) => ({ ...a, isParent: false })),
];
const totalCount = allAncestorItems.length;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-muted-foreground" />
<Label className="text-sm font-medium">Ancestor Context</Label>
<span className="text-xs text-muted-foreground">
({selectedAncestorIds.size}/{totalCount} selected)
</span>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={selectAll}
className="h-6 px-2 text-xs"
>
All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={selectNone}
className="h-6 px-2 text-xs"
>
None
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Select ancestors to include their context in the new task&apos;s prompt.
</p>
<div className="space-y-1 max-h-[200px] overflow-y-auto border rounded-lg p-2 bg-muted/20">
{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 (
<Collapsible key={item.id} open={isExpanded}>
<div
className={cn(
'flex items-start gap-2 p-2 rounded-md transition-colors',
isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'
)}
style={{ marginLeft: item.isParent ? 0 : `${item.depth * 12}px` }}
>
<Checkbox
id={`ancestor-${item.id}`}
checked={isSelected}
onCheckedChange={() => toggleSelected(item.id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{hasContent && (
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => toggleExpanded(item.id)}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</Button>
</CollapsibleTrigger>
)}
<label
htmlFor={`ancestor-${item.id}`}
className="text-sm font-medium cursor-pointer truncate flex-1"
>
{displayTitle}
{item.isParent && (
<span className="ml-2 text-xs text-primary font-normal">(Parent)</span>
)}
</label>
</div>
<CollapsibleContent>
<div className="mt-2 space-y-2 text-xs text-muted-foreground pl-5">
{item.description && (
<div>
<span className="font-medium text-foreground">Description:</span>
<p className="mt-0.5 line-clamp-3">{item.description}</p>
</div>
)}
{'spec' in item && item.spec && (
<div>
<span className="font-medium text-foreground">Specification:</span>
<p className="mt-0.5 line-clamp-3">{item.spec}</p>
</div>
)}
{'summary' in item && item.summary && (
<div>
<span className="font-medium text-foreground">Summary:</span>
<p className="mt-0.5 line-clamp-3">{item.summary}</p>
</div>
)}
</div>
</CollapsibleContent>
</div>
</div>
</Collapsible>
);
})}
{ancestors.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
Parent task has no additional ancestors
</p>
)}
</div>
</div>
);
}

View File

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

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