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

@@ -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,12 @@ export function BoardView() {
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
)}
</div>
@@ -1077,7 +1090,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 +1106,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,
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';

View File

@@ -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 */}
<path
d={edgePath}
fill="none"
stroke="transparent"
strokeWidth={20}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{ cursor: 'pointer' }}
/>
{/* Background edge for better visibility */}
<BaseEdge
id={`${id}-bg`}
path={edgePath}
style={{
strokeWidth: isHighlighted ? 6 : 4,
strokeWidth: isHighlighted || isHovered ? 6 : 4,
stroke: 'var(--background)',
opacity: isDimmed ? 0.3 : 1,
}}
@@ -92,20 +113,51 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
isDimmed && 'graph-edge-dimmed'
)}
style={{
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2,
stroke: edgeColor,
strokeWidth: isHighlighted ? 4 : isHovered || selected ? 3 : isDimmed ? 1 : 2,
stroke: isHovered || selected ? 'var(--status-error)' : edgeColor,
strokeDasharray: isCompleted ? 'none' : '5 5',
filter: isHighlighted
? 'drop-shadow(0 0 6px var(--brand-500))'
: selected
? 'drop-shadow(0 0 3px var(--brand-500))'
: isHovered || selected
? 'drop-shadow(0 0 4px var(--status-error))'
: 'none',
opacity: isDimmed ? 0.2 : 1,
}}
/>
{/* Delete button on hover or select */}
{(isHovered || selected) && edgeData?.onDeleteDependency && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'auto',
zIndex: 1000,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<button
onClick={handleDelete}
className={cn(
'flex items-center justify-center',
'w-6 h-6 rounded-full',
'bg-[var(--status-error)] hover:bg-[var(--status-error)]/80',
'text-white shadow-lg',
'transition-all duration-150',
'hover:scale-110'
)}
title="Delete dependency"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</EdgeLabelRenderer>
)}
{/* Animated particles for in-progress edges */}
{animated && (
{animated && !isHovered && (
<EdgeLabelRenderer>
<div
style={{

View File

@@ -14,6 +14,8 @@ import {
GitBranch,
Terminal,
RotateCcw,
GitFork,
Trash2,
} from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes';
import { Button } from '@/components/ui/button';
@@ -90,8 +92,10 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
<>
{/* Target handle (left side - receives dependencies) */}
<Handle
id="target"
type="target"
position={Position.Left}
isConnectable={true}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
@@ -266,6 +270,28 @@ 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>
{!data.isRunning && (
<DropdownMenuItem
className="text-xs text-[var(--status-error)] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onDeleteTask?.();
}}
>
<Trash2 className="w-3 h-3 mr-2" />
Delete Task
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -325,8 +351,10 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
{/* Source handle (right side - provides to dependents) */}
<Handle
id="source"
type="source"
position={Position.Right}
isConnectable={true}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useEffect } from 'react';
import { useCallback, useState, useEffect, useRef } from 'react';
import {
ReactFlow,
Background,
@@ -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) {
@@ -107,10 +110,49 @@ function GraphCanvasInner({
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
// Update nodes/edges when features change
// Track if initial layout has been applied
const hasInitialLayout = useRef(false);
// Track the previous node IDs to detect new nodes
const prevNodeIds = useRef<Set<string>>(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]));
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
@@ -138,6 +180,29 @@ 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]
);
// 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<TaskNodeData>) => {
const data = node.data as TaskNodeData | undefined;
@@ -165,6 +230,8 @@ function GraphCanvasInner({
onNodesChange={isLocked ? undefined : onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDoubleClick={handleNodeDoubleClick}
onConnect={handleConnect}
isValidConnection={isValidConnection}
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 '@automaker/dependency-resolver';
import { toast } from 'sonner';
interface GraphViewProps {
features: Feature[];
@@ -17,6 +19,9 @@ 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;
onDeleteTask?: (feature: Feature) => void;
}
export function GraphView({
@@ -32,6 +37,9 @@ export function GraphView({
onStartTask,
onStopTask,
onResumeTask,
onUpdateFeature,
onSpawnTask,
onDeleteTask,
}: GraphViewProps) {
const { currentProject } = useAppStore();
@@ -74,6 +82,53 @@ export function GraphView({
[features, onEditFeature]
);
// Handle creating a dependency via edge connection
const handleCreateDependency = useCallback(
async (sourceId: string, targetId: string): Promise<boolean> => {
const targetFeature = features.find((f) => f.id === targetId);
// 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
// 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)
const wouldCycle = wouldCreateCircularDependency(features, sourceId, targetId);
if (wouldCycle) {
toast.error('Cannot create circular dependency', {
description: 'This would create a dependency cycle',
});
return false;
}
// Get target feature and update its dependencies
if (!targetFeature) {
toast.error('Target task not found');
return false;
}
const currentDeps = (targetFeature.dependencies as string[] | undefined) || [];
// 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 +162,44 @@ export function GraphView({
onResumeTask?.(feature);
}
},
onSpawnTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
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]
[
features,
onViewOutput,
onEditFeature,
onStartTask,
onStopTask,
onResumeTask,
onSpawnTask,
onDeleteTask,
onUpdateFeature,
]
);
return (
@@ -120,6 +211,7 @@ export function GraphView({
onSearchQueryChange={onSearchQueryChange}
onNodeDoubleClick={handleNodeDoubleClick}
nodeActionCallbacks={nodeActionCallbacks}
onCreateDependency={handleCreateDependency}
backgroundStyle={backgroundImageStyle}
className="h-full"
/>

View File

@@ -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<Map<string, { x: number; y: number }>>(new Map());
const lastStructureKey = useRef<string>('');
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(

View File

@@ -24,6 +24,8 @@ export interface TaskNodeData extends Feature {
onStartTask?: () => void;
onStopTask?: () => void;
onResumeTask?: () => void;
onSpawnTask?: () => void;
onDeleteTask?: () => void;
}
export type TaskNode = Node<TaskNodeData, 'task'>;
@@ -32,6 +34,7 @@ export type DependencyEdge = Edge<{
targetStatus: Feature['status'];
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
}>;
export interface NodeActionCallbacks {
@@ -40,6 +43,9 @@ export interface NodeActionCallbacks {
onStartTask?: (featureId: string) => void;
onStopTask?: (featureId: string) => void;
onResumeTask?: (featureId: string) => void;
onSpawnTask?: (featureId: string) => void;
onDeleteTask?: (featureId: string) => void;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
}
interface UseGraphNodesProps {
@@ -112,6 +118,12 @@ export function useGraphNodes({
onResumeTask: actionCallbacks?.onResumeTask
? () => actionCallbacks.onResumeTask!(feature.id)
: undefined,
onSpawnTask: actionCallbacks?.onSpawnTask
? () => actionCallbacks.onSpawnTask!(feature.id)
: undefined,
onDeleteTask: actionCallbacks?.onDeleteTask
? () => actionCallbacks.onDeleteTask!(feature.id)
: undefined,
},
};
@@ -141,6 +153,7 @@ export function useGraphNodes({
targetStatus: feature.status,
isHighlighted: edgeIsHighlighted,
isDimmed: edgeIsDimmed,
onDeleteDependency: actionCallbacks?.onDeleteDependency,
},
};
edgeList.push(edge);