Merge pull request #243 from JBotwina/JBotwina/task-deps-spawn

feat: Add task dependencies and spawn sub-task functionality
This commit is contained in:
Web Dev Cody
2025-12-24 11:48:22 -05:00
committed by GitHub
21 changed files with 1531 additions and 34 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,
type AncestorContext,
} from '@automaker/dependency-resolver';
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,17 @@ export function AddFeatureDialog({
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
// Initialize ancestors for spawn mode
if (parentFeature) {
const ancestorList = getAncestors(parentFeature, allFeatures);
setAncestors(ancestorList);
// Only select parent by default - ancestors are optional context
setSelectedAncestorIds(new Set([parentFeature.id]));
} else {
setAncestors([]);
setSelectedAncestorIds(new Set());
}
}
}, [
open,
@@ -162,6 +184,8 @@ export function AddFeatureDialog({
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
parentFeature,
allFeatures,
]);
const handleAdd = () => {
@@ -187,10 +211,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 +249,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 +349,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 +374,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 +582,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,211 @@
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, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AncestorContext } from '@automaker/dependency-resolver';
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">
The parent task context will be included to help the AI understand the background.
Additional ancestors can optionally be included for more context.
</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',
item.isParent
? isSelected
? 'bg-[var(--status-success-bg)] border border-[var(--status-success)]/30'
: 'bg-muted/30 border border-border hover:bg-muted/50'
: 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}
</label>
{item.isParent && (
<span className="ml-2 inline-flex items-center gap-1 text-xs text-[var(--status-success)] font-medium">
<CheckCircle2 className="w-3 h-3" />
Completed Parent
</span>
)}
</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';