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

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