mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge pull request #243 from JBotwina/JBotwina/task-deps-spawn
feat: Add task dependencies and spawn sub-task functionality
This commit is contained in:
@@ -44,6 +44,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
|||||||
10
apps/ui/src/components/ui/collapsible.tsx
Normal file
10
apps/ui/src/components/ui/collapsible.tsx
Normal 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 };
|
||||||
@@ -107,6 +107,9 @@ export function BoardView() {
|
|||||||
// State for viewing plan in read-only mode
|
// State for viewing plan in read-only mode
|
||||||
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
|
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
|
||||||
|
|
||||||
|
// State for spawn task mode
|
||||||
|
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
|
||||||
|
|
||||||
// Worktree dialog states
|
// Worktree dialog states
|
||||||
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
|
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
|
||||||
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
|
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
|
||||||
@@ -1021,6 +1024,10 @@ export function BoardView() {
|
|||||||
onImplement={handleStartImplementation}
|
onImplement={handleStartImplementation}
|
||||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||||
onApprovePlan={handleOpenApprovalDialog}
|
onApprovePlan={handleOpenApprovalDialog}
|
||||||
|
onSpawnTask={(feature) => {
|
||||||
|
setSpawnParentFeature(feature);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
featuresWithContext={featuresWithContext}
|
featuresWithContext={featuresWithContext}
|
||||||
runningAutoTasks={runningAutoTasks}
|
runningAutoTasks={runningAutoTasks}
|
||||||
shortcuts={shortcuts}
|
shortcuts={shortcuts}
|
||||||
@@ -1043,6 +1050,12 @@ export function BoardView() {
|
|||||||
onStartTask={handleStartImplementation}
|
onStartTask={handleStartImplementation}
|
||||||
onStopTask={handleForceStopFeature}
|
onStopTask={handleForceStopFeature}
|
||||||
onResumeTask={handleResumeFeature}
|
onResumeTask={handleResumeFeature}
|
||||||
|
onUpdateFeature={updateFeature}
|
||||||
|
onSpawnTask={(feature) => {
|
||||||
|
setSpawnParentFeature(feature);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1077,7 +1090,12 @@ export function BoardView() {
|
|||||||
{/* Add Feature Dialog */}
|
{/* Add Feature Dialog */}
|
||||||
<AddFeatureDialog
|
<AddFeatureDialog
|
||||||
open={showAddDialog}
|
open={showAddDialog}
|
||||||
onOpenChange={setShowAddDialog}
|
onOpenChange={(open) => {
|
||||||
|
setShowAddDialog(open);
|
||||||
|
if (!open) {
|
||||||
|
setSpawnParentFeature(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onAdd={handleAddFeature}
|
onAdd={handleAddFeature}
|
||||||
categorySuggestions={categorySuggestions}
|
categorySuggestions={categorySuggestions}
|
||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
@@ -1088,6 +1106,8 @@ export function BoardView() {
|
|||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
showProfilesOnly={showProfilesOnly}
|
showProfilesOnly={showProfilesOnly}
|
||||||
aiProfiles={aiProfiles}
|
aiProfiles={aiProfiles}
|
||||||
|
parentFeature={spawnParentFeature}
|
||||||
|
allFeatures={hookFeatures}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Edit Feature Dialog */}
|
{/* Edit Feature Dialog */}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
GitFork,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
||||||
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||||
@@ -31,6 +32,7 @@ interface CardHeaderProps {
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onViewOutput?: () => void;
|
onViewOutput?: () => void;
|
||||||
|
onSpawnTask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeaderSection({
|
export function CardHeaderSection({
|
||||||
@@ -40,6 +42,7 @@ export function CardHeaderSection({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onViewOutput,
|
onViewOutput,
|
||||||
|
onSpawnTask,
|
||||||
}: CardHeaderProps) {
|
}: CardHeaderProps) {
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
@@ -92,6 +95,17 @@ export function CardHeaderSection({
|
|||||||
<Edit className="w-3 h-3 mr-2" />
|
<Edit className="w-3 h-3 mr-2" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</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 */}
|
{/* 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="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -106,7 +120,21 @@ export function CardHeaderSection({
|
|||||||
|
|
||||||
{/* Backlog header */}
|
{/* Backlog header */}
|
||||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
{!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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -141,6 +169,22 @@ export function CardHeaderSection({
|
|||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</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 && (
|
{onViewOutput && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -229,6 +273,17 @@ export function CardHeaderSection({
|
|||||||
View Logs
|
View Logs
|
||||||
</DropdownMenuItem>
|
</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 */}
|
{/* 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="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface KanbanCardProps {
|
|||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
onViewPlan?: () => void;
|
onViewPlan?: () => void;
|
||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
|
onSpawnTask?: () => void;
|
||||||
hasContext?: boolean;
|
hasContext?: boolean;
|
||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
shortcutKey?: string;
|
shortcutKey?: string;
|
||||||
@@ -51,6 +52,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onComplete,
|
onComplete,
|
||||||
onViewPlan,
|
onViewPlan,
|
||||||
onApprovePlan,
|
onApprovePlan,
|
||||||
|
onSpawnTask,
|
||||||
hasContext,
|
hasContext,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
@@ -146,6 +148,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onViewOutput={onViewOutput}
|
onViewOutput={onViewOutput}
|
||||||
|
onSpawnTask={onSpawnTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent className="px-3 pt-0 pb-0">
|
<CardContent className="px-3 pt-0 pb-0">
|
||||||
|
|||||||
@@ -19,14 +19,7 @@ import {
|
|||||||
FeatureTextFilePath as DescriptionTextFilePath,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import {
|
import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown } from 'lucide-react';
|
||||||
MessageSquare,
|
|
||||||
Settings2,
|
|
||||||
SlidersHorizontal,
|
|
||||||
FlaskConical,
|
|
||||||
Sparkles,
|
|
||||||
ChevronDown,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
@@ -37,6 +30,7 @@ import {
|
|||||||
FeatureImage,
|
FeatureImage,
|
||||||
AIProfile,
|
AIProfile,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
|
Feature,
|
||||||
} from '@/store/app-store';
|
} from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
@@ -46,6 +40,7 @@ import {
|
|||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
BranchSelector,
|
BranchSelector,
|
||||||
PlanningModeSelector,
|
PlanningModeSelector,
|
||||||
|
AncestorContextSection,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -54,6 +49,11 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
getAncestors,
|
||||||
|
formatAncestorContextForPrompt,
|
||||||
|
type AncestorContext,
|
||||||
|
} from '@automaker/dependency-resolver';
|
||||||
|
|
||||||
interface AddFeatureDialogProps {
|
interface AddFeatureDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -72,6 +72,7 @@ interface AddFeatureDialogProps {
|
|||||||
priority: number;
|
priority: number;
|
||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
|
dependencies?: string[];
|
||||||
}) => void;
|
}) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
@@ -82,6 +83,9 @@ interface AddFeatureDialogProps {
|
|||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
showProfilesOnly: boolean;
|
showProfilesOnly: boolean;
|
||||||
aiProfiles: AIProfile[];
|
aiProfiles: AIProfile[];
|
||||||
|
// Spawn task mode props
|
||||||
|
parentFeature?: Feature | null;
|
||||||
|
allFeatures?: Feature[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddFeatureDialog({
|
export function AddFeatureDialog({
|
||||||
@@ -97,7 +101,10 @@ export function AddFeatureDialog({
|
|||||||
isMaximized,
|
isMaximized,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
aiProfiles,
|
aiProfiles,
|
||||||
|
parentFeature = null,
|
||||||
|
allFeatures = [],
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
|
const isSpawnMode = !!parentFeature;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||||
const [newFeature, setNewFeature] = useState({
|
const [newFeature, setNewFeature] = useState({
|
||||||
@@ -125,6 +132,10 @@ export function AddFeatureDialog({
|
|||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
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
|
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
||||||
const {
|
const {
|
||||||
enhancementModel,
|
enhancementModel,
|
||||||
@@ -153,6 +164,17 @@ export function AddFeatureDialog({
|
|||||||
setUseCurrentBranch(true);
|
setUseCurrentBranch(true);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
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,
|
open,
|
||||||
@@ -162,6 +184,8 @@ export function AddFeatureDialog({
|
|||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
defaultAIProfileId,
|
defaultAIProfileId,
|
||||||
aiProfiles,
|
aiProfiles,
|
||||||
|
parentFeature,
|
||||||
|
allFeatures,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
@@ -187,10 +211,34 @@ export function AddFeatureDialog({
|
|||||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||||
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
|
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({
|
onAdd({
|
||||||
title: newFeature.title,
|
title: newFeature.title,
|
||||||
category,
|
category,
|
||||||
description: newFeature.description,
|
description: finalDescription,
|
||||||
images: newFeature.images,
|
images: newFeature.images,
|
||||||
imagePaths: newFeature.imagePaths,
|
imagePaths: newFeature.imagePaths,
|
||||||
textFilePaths: newFeature.textFilePaths,
|
textFilePaths: newFeature.textFilePaths,
|
||||||
@@ -201,6 +249,8 @@ export function AddFeatureDialog({
|
|||||||
priority: newFeature.priority,
|
priority: newFeature.priority,
|
||||||
planningMode,
|
planningMode,
|
||||||
requirePlanApproval,
|
requirePlanApproval,
|
||||||
|
// In spawn mode, automatically add parent as dependency
|
||||||
|
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
@@ -299,8 +349,12 @@ export function AddFeatureDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Feature</DialogTitle>
|
<DialogTitle>{isSpawnMode ? 'Spawn Sub-Task' : 'Add New Feature'}</DialogTitle>
|
||||||
<DialogDescription>Create a new feature card for the Kanban board.</DialogDescription>
|
<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>
|
</DialogHeader>
|
||||||
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
||||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||||
@@ -320,6 +374,22 @@ export function AddFeatureDialog({
|
|||||||
|
|
||||||
{/* Prompt Tab */}
|
{/* Prompt Tab */}
|
||||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<DescriptionImageDropZone
|
<DescriptionImageDropZone
|
||||||
@@ -512,7 +582,7 @@ export function AddFeatureDialog({
|
|||||||
data-testid="confirm-add-feature"
|
data-testid="confirm-add-feature"
|
||||||
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
|
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
|
||||||
>
|
>
|
||||||
Add Feature
|
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
|
||||||
</HotkeyButton>
|
</HotkeyButton>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export function useBoardActions({
|
|||||||
priority: number;
|
priority: number;
|
||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
|
dependencies?: string[];
|
||||||
}) => {
|
}) => {
|
||||||
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
|
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
|
||||||
// Non-empty string is the actual branch name (for non-primary worktrees)
|
// Non-empty string is the actual branch name (for non-primary worktrees)
|
||||||
@@ -150,6 +151,7 @@ export function useBoardActions({
|
|||||||
titleGenerating: needsTitleGeneration,
|
titleGenerating: needsTitleGeneration,
|
||||||
status: 'backlog' as const,
|
status: 'backlog' as const,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
|
dependencies: featureData.dependencies || [],
|
||||||
};
|
};
|
||||||
const createdFeature = addFeature(newFeatureData);
|
const createdFeature = addFeature(newFeatureData);
|
||||||
// Must await to ensure feature exists on server before user can drag it
|
// Must await to ensure feature exists on server before user can drag it
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface KanbanBoardProps {
|
|||||||
onImplement: (feature: Feature) => void;
|
onImplement: (feature: Feature) => void;
|
||||||
onViewPlan: (feature: Feature) => void;
|
onViewPlan: (feature: Feature) => void;
|
||||||
onApprovePlan: (feature: Feature) => void;
|
onApprovePlan: (feature: Feature) => void;
|
||||||
|
onSpawnTask?: (feature: Feature) => void;
|
||||||
featuresWithContext: Set<string>;
|
featuresWithContext: Set<string>;
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
||||||
@@ -73,6 +74,7 @@ export function KanbanBoard({
|
|||||||
onImplement,
|
onImplement,
|
||||||
onViewPlan,
|
onViewPlan,
|
||||||
onApprovePlan,
|
onApprovePlan,
|
||||||
|
onSpawnTask,
|
||||||
featuresWithContext,
|
featuresWithContext,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
@@ -184,6 +186,7 @@ export function KanbanBoard({
|
|||||||
onImplement={() => onImplement(feature)}
|
onImplement={() => onImplement(feature)}
|
||||||
onViewPlan={() => onViewPlan(feature)}
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
onApprovePlan={() => onApprovePlan(feature)}
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
|
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export * from './testing-tab-content';
|
|||||||
export * from './priority-selector';
|
export * from './priority-selector';
|
||||||
export * from './branch-selector';
|
export * from './branch-selector';
|
||||||
export * from './planning-mode-selector';
|
export * from './planning-mode-selector';
|
||||||
|
export * from './ancestor-context-section';
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import { BaseEdge, getBezierPath, EdgeLabelRenderer } from '@xyflow/react';
|
import { BaseEdge, getBezierPath, EdgeLabelRenderer } from '@xyflow/react';
|
||||||
import type { EdgeProps } from '@xyflow/react';
|
import type { EdgeProps } from '@xyflow/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
export interface DependencyEdgeData {
|
export interface DependencyEdgeData {
|
||||||
sourceStatus: Feature['status'];
|
sourceStatus: Feature['status'];
|
||||||
targetStatus: Feature['status'];
|
targetStatus: Feature['status'];
|
||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
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) {
|
export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
@@ -42,6 +46,7 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
animated,
|
animated,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const edgeData = data as DependencyEdgeData | undefined;
|
const edgeData = data as DependencyEdgeData | undefined;
|
||||||
|
|
||||||
const [edgePath, labelX, labelY] = getBezierPath({
|
const [edgePath, labelX, labelY] = getBezierPath({
|
||||||
@@ -67,14 +72,30 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||||
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
edgeData?.onDeleteDependency?.(source, target);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Background edge for better visibility */}
|
||||||
<BaseEdge
|
<BaseEdge
|
||||||
id={`${id}-bg`}
|
id={`${id}-bg`}
|
||||||
path={edgePath}
|
path={edgePath}
|
||||||
style={{
|
style={{
|
||||||
strokeWidth: isHighlighted ? 6 : 4,
|
strokeWidth: isHighlighted || isHovered ? 6 : 4,
|
||||||
stroke: 'var(--background)',
|
stroke: 'var(--background)',
|
||||||
opacity: isDimmed ? 0.3 : 1,
|
opacity: isDimmed ? 0.3 : 1,
|
||||||
}}
|
}}
|
||||||
@@ -92,20 +113,51 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
isDimmed && 'graph-edge-dimmed'
|
isDimmed && 'graph-edge-dimmed'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2,
|
strokeWidth: isHighlighted ? 4 : isHovered || selected ? 3 : isDimmed ? 1 : 2,
|
||||||
stroke: edgeColor,
|
stroke: isHovered || selected ? 'var(--status-error)' : edgeColor,
|
||||||
strokeDasharray: isCompleted ? 'none' : '5 5',
|
strokeDasharray: isCompleted ? 'none' : '5 5',
|
||||||
filter: isHighlighted
|
filter: isHighlighted
|
||||||
? 'drop-shadow(0 0 6px var(--brand-500))'
|
? 'drop-shadow(0 0 6px var(--brand-500))'
|
||||||
: selected
|
: isHovered || selected
|
||||||
? 'drop-shadow(0 0 3px var(--brand-500))'
|
? 'drop-shadow(0 0 4px var(--status-error))'
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: isDimmed ? 0.2 : 1,
|
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 particles for in-progress edges */}
|
||||||
{animated && (
|
{animated && !isHovered && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Terminal,
|
Terminal,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
GitFork,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
||||||
import { Button } from '@/components/ui/button';
|
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) */}
|
{/* Target handle (left side - receives dependencies) */}
|
||||||
<Handle
|
<Handle
|
||||||
|
id="target"
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
|
isConnectable={true}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-3 h-3 !bg-border border-2 border-background',
|
'w-3 h-3 !bg-border border-2 border-background',
|
||||||
'transition-colors duration-200',
|
'transition-colors duration-200',
|
||||||
@@ -266,6 +270,28 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
Resume Task
|
Resume Task
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,8 +351,10 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
|
|
||||||
{/* Source handle (right side - provides to dependents) */}
|
{/* Source handle (right side - provides to dependents) */}
|
||||||
<Handle
|
<Handle
|
||||||
|
id="source"
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
|
isConnectable={true}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-3 h-3 !bg-border border-2 border-background',
|
'w-3 h-3 !bg-border border-2 border-background',
|
||||||
'transition-colors duration-200',
|
'transition-colors duration-200',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState, useEffect } from 'react';
|
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SelectionMode,
|
SelectionMode,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
Node,
|
Node,
|
||||||
|
Connection,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ interface GraphCanvasProps {
|
|||||||
onSearchQueryChange: (query: string) => void;
|
onSearchQueryChange: (query: string) => void;
|
||||||
onNodeDoubleClick?: (featureId: string) => void;
|
onNodeDoubleClick?: (featureId: string) => void;
|
||||||
nodeActionCallbacks?: NodeActionCallbacks;
|
nodeActionCallbacks?: NodeActionCallbacks;
|
||||||
|
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||||
backgroundStyle?: React.CSSProperties;
|
backgroundStyle?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,7 @@ function GraphCanvasInner({
|
|||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
onNodeDoubleClick,
|
onNodeDoubleClick,
|
||||||
nodeActionCallbacks,
|
nodeActionCallbacks,
|
||||||
|
onCreateDependency,
|
||||||
backgroundStyle,
|
backgroundStyle,
|
||||||
className,
|
className,
|
||||||
}: GraphCanvasProps) {
|
}: GraphCanvasProps) {
|
||||||
@@ -107,10 +110,49 @@ function GraphCanvasInner({
|
|||||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
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(() => {
|
useEffect(() => {
|
||||||
setNodes(layoutedNodes);
|
const currentNodeIds = new Set(layoutedNodes.map((n) => n.id));
|
||||||
setEdges(layoutedEdges);
|
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]);
|
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||||||
|
|
||||||
// Handle layout direction change
|
// Handle layout direction change
|
||||||
@@ -138,6 +180,29 @@ function GraphCanvasInner({
|
|||||||
[onNodeDoubleClick]
|
[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
|
// MiniMap node color based on status
|
||||||
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
||||||
const data = node.data as TaskNodeData | undefined;
|
const data = node.data as TaskNodeData | undefined;
|
||||||
@@ -165,6 +230,8 @@ function GraphCanvasInner({
|
|||||||
onNodesChange={isLocked ? undefined : onNodesChange}
|
onNodesChange={isLocked ? undefined : onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
|
onConnect={handleConnect}
|
||||||
|
isValidConnection={isValidConnection}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
fitView
|
fitView
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Feature, useAppStore } from '@/store/app-store';
|
|||||||
import { GraphCanvas } from './graph-canvas';
|
import { GraphCanvas } from './graph-canvas';
|
||||||
import { useBoardBackground } from '../board-view/hooks';
|
import { useBoardBackground } from '../board-view/hooks';
|
||||||
import { NodeActionCallbacks } from './hooks';
|
import { NodeActionCallbacks } from './hooks';
|
||||||
|
import { wouldCreateCircularDependency, dependencyExists } from '@automaker/dependency-resolver';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface GraphViewProps {
|
interface GraphViewProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
@@ -17,6 +19,9 @@ interface GraphViewProps {
|
|||||||
onStartTask?: (feature: Feature) => void;
|
onStartTask?: (feature: Feature) => void;
|
||||||
onStopTask?: (feature: Feature) => void;
|
onStopTask?: (feature: Feature) => void;
|
||||||
onResumeTask?: (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({
|
export function GraphView({
|
||||||
@@ -32,6 +37,9 @@ export function GraphView({
|
|||||||
onStartTask,
|
onStartTask,
|
||||||
onStopTask,
|
onStopTask,
|
||||||
onResumeTask,
|
onResumeTask,
|
||||||
|
onUpdateFeature,
|
||||||
|
onSpawnTask,
|
||||||
|
onDeleteTask,
|
||||||
}: GraphViewProps) {
|
}: GraphViewProps) {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
@@ -74,6 +82,53 @@ export function GraphView({
|
|||||||
[features, onEditFeature]
|
[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
|
// Node action callbacks for dropdown menu
|
||||||
const nodeActionCallbacks: NodeActionCallbacks = useMemo(
|
const nodeActionCallbacks: NodeActionCallbacks = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -107,8 +162,44 @@ export function GraphView({
|
|||||||
onResumeTask?.(feature);
|
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 (
|
return (
|
||||||
@@ -120,6 +211,7 @@ export function GraphView({
|
|||||||
onSearchQueryChange={onSearchQueryChange}
|
onSearchQueryChange={onSearchQueryChange}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
nodeActionCallbacks={nodeActionCallbacks}
|
nodeActionCallbacks={nodeActionCallbacks}
|
||||||
|
onCreateDependency={handleCreateDependency}
|
||||||
backgroundStyle={backgroundImageStyle}
|
backgroundStyle={backgroundImageStyle}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
import dagre from 'dagre';
|
import dagre from 'dagre';
|
||||||
import { Node, Edge, useReactFlow } from '@xyflow/react';
|
import { Node, Edge, useReactFlow } from '@xyflow/react';
|
||||||
import { TaskNode, DependencyEdge } from './use-graph-nodes';
|
import { TaskNode, DependencyEdge } from './use-graph-nodes';
|
||||||
@@ -18,6 +18,10 @@ interface UseGraphLayoutProps {
|
|||||||
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||||
const { fitView, setNodes } = useReactFlow();
|
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(
|
const getLayoutedElements = useCallback(
|
||||||
(
|
(
|
||||||
inputNodes: TaskNode[],
|
inputNodes: TaskNode[],
|
||||||
@@ -48,12 +52,15 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
|||||||
|
|
||||||
const layoutedNodes = inputNodes.map((node) => {
|
const layoutedNodes = inputNodes.map((node) => {
|
||||||
const nodeWithPosition = dagreGraph.node(node.id);
|
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 {
|
return {
|
||||||
...node,
|
...node,
|
||||||
position: {
|
position,
|
||||||
x: nodeWithPosition.x - NODE_WIDTH / 2,
|
|
||||||
y: nodeWithPosition.y - NODE_HEIGHT / 2,
|
|
||||||
},
|
|
||||||
targetPosition: isHorizontal ? 'left' : 'top',
|
targetPosition: isHorizontal ? 'left' : 'top',
|
||||||
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||||
} as TaskNode;
|
} 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(() => {
|
const layoutedElements = useMemo(() => {
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
|
positionCache.current.clear();
|
||||||
|
lastStructureKey.current = '';
|
||||||
return { nodes: [], edges: [] };
|
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
|
// Manual re-layout function
|
||||||
const runLayout = useCallback(
|
const runLayout = useCallback(
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface TaskNodeData extends Feature {
|
|||||||
onStartTask?: () => void;
|
onStartTask?: () => void;
|
||||||
onStopTask?: () => void;
|
onStopTask?: () => void;
|
||||||
onResumeTask?: () => void;
|
onResumeTask?: () => void;
|
||||||
|
onSpawnTask?: () => void;
|
||||||
|
onDeleteTask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskNode = Node<TaskNodeData, 'task'>;
|
export type TaskNode = Node<TaskNodeData, 'task'>;
|
||||||
@@ -32,6 +34,7 @@ export type DependencyEdge = Edge<{
|
|||||||
targetStatus: Feature['status'];
|
targetStatus: Feature['status'];
|
||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export interface NodeActionCallbacks {
|
export interface NodeActionCallbacks {
|
||||||
@@ -40,6 +43,9 @@ export interface NodeActionCallbacks {
|
|||||||
onStartTask?: (featureId: string) => void;
|
onStartTask?: (featureId: string) => void;
|
||||||
onStopTask?: (featureId: string) => void;
|
onStopTask?: (featureId: string) => void;
|
||||||
onResumeTask?: (featureId: string) => void;
|
onResumeTask?: (featureId: string) => void;
|
||||||
|
onSpawnTask?: (featureId: string) => void;
|
||||||
|
onDeleteTask?: (featureId: string) => void;
|
||||||
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseGraphNodesProps {
|
interface UseGraphNodesProps {
|
||||||
@@ -112,6 +118,12 @@ export function useGraphNodes({
|
|||||||
onResumeTask: actionCallbacks?.onResumeTask
|
onResumeTask: actionCallbacks?.onResumeTask
|
||||||
? () => actionCallbacks.onResumeTask!(feature.id)
|
? () => actionCallbacks.onResumeTask!(feature.id)
|
||||||
: undefined,
|
: 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,
|
targetStatus: feature.status,
|
||||||
isHighlighted: edgeIsHighlighted,
|
isHighlighted: edgeIsHighlighted,
|
||||||
isDimmed: edgeIsDimmed,
|
isDimmed: edgeIsDimmed,
|
||||||
|
onDeleteDependency: actionCallbacks?.onDeleteDependency,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
edgeList.push(edge);
|
edgeList.push(edge);
|
||||||
|
|||||||
@@ -7,5 +7,10 @@ export {
|
|||||||
resolveDependencies,
|
resolveDependencies,
|
||||||
areDependenciesSatisfied,
|
areDependenciesSatisfied,
|
||||||
getBlockingDependencies,
|
getBlockingDependencies,
|
||||||
|
wouldCreateCircularDependency,
|
||||||
|
dependencyExists,
|
||||||
|
getAncestors,
|
||||||
|
formatAncestorContextForPrompt,
|
||||||
type DependencyResolutionResult,
|
type DependencyResolutionResult,
|
||||||
|
type AncestorContext,
|
||||||
} from './resolver.js';
|
} from './resolver.js';
|
||||||
|
|||||||
@@ -209,3 +209,188 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[]
|
|||||||
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if adding a dependency from sourceId to targetId would create a circular dependency.
|
||||||
|
* When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies.
|
||||||
|
* A cycle would occur if sourceId already depends on targetId (directly or transitively).
|
||||||
|
*
|
||||||
|
* @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>();
|
||||||
|
|
||||||
|
// Check if 'from' can reach 'to' by following dependencies
|
||||||
|
function canReach(fromId: string, toId: string): boolean {
|
||||||
|
if (fromId === toId) return true;
|
||||||
|
if (visited.has(fromId)) return false;
|
||||||
|
|
||||||
|
visited.add(fromId);
|
||||||
|
const feature = featureMap.get(fromId);
|
||||||
|
if (!feature?.dependencies) return false;
|
||||||
|
|
||||||
|
for (const depId of feature.dependencies) {
|
||||||
|
if (canReach(depId, toId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We want to add: targetId depends on sourceId (sourceId -> targetId in dependency graph)
|
||||||
|
// This would create a cycle if sourceId already depends on targetId (transitively)
|
||||||
|
// i.e., if we can reach targetId starting from sourceId by following dependencies
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context information about an ancestor feature in the dependency graph.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
* The parent task (depth=-1) is formatted with special emphasis indicating
|
||||||
|
* it was already completed and is provided for context only.
|
||||||
|
*
|
||||||
|
* @param ancestors - Array of ancestor contexts (including parent with depth=-1)
|
||||||
|
* @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 '';
|
||||||
|
|
||||||
|
// Separate parent (depth=-1) from other ancestors
|
||||||
|
const parent = selectedAncestors.find((a) => a.depth === -1);
|
||||||
|
const otherAncestors = selectedAncestors.filter((a) => a.depth !== -1);
|
||||||
|
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
// Format parent with special emphasis
|
||||||
|
if (parent) {
|
||||||
|
const parentTitle = parent.title || `Task (${parent.id.slice(0, 8)})`;
|
||||||
|
const parentParts: string[] = [];
|
||||||
|
|
||||||
|
parentParts.push(`## Parent Task Context (Already Completed)`);
|
||||||
|
parentParts.push(
|
||||||
|
`> **Note:** The following parent task has already been completed. This context is provided to help you understand the background and requirements for this sub-task. Do not re-implement the parent task - focus only on the new sub-task described below.`
|
||||||
|
);
|
||||||
|
parentParts.push(`### ${parentTitle}`);
|
||||||
|
|
||||||
|
if (parent.description) {
|
||||||
|
parentParts.push(`**Description:** ${parent.description}`);
|
||||||
|
}
|
||||||
|
if (parent.spec) {
|
||||||
|
parentParts.push(`**Specification:**\n${parent.spec}`);
|
||||||
|
}
|
||||||
|
if (parent.summary) {
|
||||||
|
parentParts.push(`**Summary:** ${parent.summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(parentParts.join('\n\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format other ancestors if any
|
||||||
|
if (otherAncestors.length > 0) {
|
||||||
|
const ancestorSections = otherAncestors.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');
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(`## Additional Ancestor Context\n\n${ancestorSections.join('\n\n---\n\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
resolveDependencies,
|
resolveDependencies,
|
||||||
areDependenciesSatisfied,
|
areDependenciesSatisfied,
|
||||||
getBlockingDependencies,
|
getBlockingDependencies,
|
||||||
|
wouldCreateCircularDependency,
|
||||||
|
dependencyExists,
|
||||||
} from '../src/resolver';
|
} from '../src/resolver';
|
||||||
import type { Feature } from '@automaker/types';
|
import type { Feature } from '@automaker/types';
|
||||||
|
|
||||||
@@ -348,4 +350,204 @@ describe('resolver.ts', () => {
|
|||||||
expect(blocking).not.toContain('Dep2');
|
expect(blocking).not.toContain('Dep2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('wouldCreateCircularDependency', () => {
|
||||||
|
it('should return false for features with no existing dependencies', () => {
|
||||||
|
const features = [createFeature('A'), createFeature('B')];
|
||||||
|
|
||||||
|
// Making B depend on A should not create a cycle
|
||||||
|
expect(wouldCreateCircularDependency(features, 'A', 'B')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for valid linear dependency chain', () => {
|
||||||
|
// A <- B <- C (C depends on B, B depends on A)
|
||||||
|
const features = [
|
||||||
|
createFeature('A'),
|
||||||
|
createFeature('B', { dependencies: ['A'] }),
|
||||||
|
createFeature('C', { dependencies: ['B'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Making D depend on C should not create a cycle
|
||||||
|
const featuresWithD = [...features, createFeature('D')];
|
||||||
|
expect(wouldCreateCircularDependency(featuresWithD, 'C', 'D')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect direct circular dependency (A -> B -> A)', () => {
|
||||||
|
// B depends on A
|
||||||
|
const features = [createFeature('A'), createFeature('B', { dependencies: ['A'] })];
|
||||||
|
|
||||||
|
// Making A depend on B would create: A -> B -> A (cycle!)
|
||||||
|
// sourceId = B (prerequisite), targetId = A (will depend on B)
|
||||||
|
// This creates a cycle because B already depends on A
|
||||||
|
expect(wouldCreateCircularDependency(features, 'B', 'A')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect transitive circular dependency (A -> B -> C -> A)', () => {
|
||||||
|
// C depends on B, B depends on A
|
||||||
|
const features = [
|
||||||
|
createFeature('A'),
|
||||||
|
createFeature('B', { dependencies: ['A'] }),
|
||||||
|
createFeature('C', { dependencies: ['B'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Making A depend on C would create: A -> C -> B -> A (cycle!)
|
||||||
|
// sourceId = C (prerequisite), targetId = A (will depend on C)
|
||||||
|
expect(wouldCreateCircularDependency(features, 'C', 'A')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect cycle in complex graph', () => {
|
||||||
|
// Graph: A <- B, A <- C, B <- C (C depends on both A and B, B depends on A)
|
||||||
|
const features = [
|
||||||
|
createFeature('A'),
|
||||||
|
createFeature('B', { dependencies: ['A'] }),
|
||||||
|
createFeature('C', { dependencies: ['A', 'B'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Making A depend on C would create a cycle
|
||||||
|
expect(wouldCreateCircularDependency(features, 'C', 'A')).toBe(true);
|
||||||
|
|
||||||
|
// Making B depend on C would also create a cycle
|
||||||
|
expect(wouldCreateCircularDependency(features, 'C', 'B')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for parallel branches', () => {
|
||||||
|
// A <- B, A <- C (B and C both depend on A, but not on each other)
|
||||||
|
const features = [
|
||||||
|
createFeature('A'),
|
||||||
|
createFeature('B', { dependencies: ['A'] }),
|
||||||
|
createFeature('C', { dependencies: ['A'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Making B depend on C should be fine (no cycle)
|
||||||
|
expect(wouldCreateCircularDependency(features, 'C', 'B')).toBe(false);
|
||||||
|
|
||||||
|
// Making C depend on B should also be fine
|
||||||
|
expect(wouldCreateCircularDependency(features, 'B', 'C')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle self-dependency check', () => {
|
||||||
|
const features = [createFeature('A')];
|
||||||
|
|
||||||
|
// A depending on itself would be a trivial cycle
|
||||||
|
expect(wouldCreateCircularDependency(features, 'A', 'A')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle feature not in list', () => {
|
||||||
|
const features = [createFeature('A')];
|
||||||
|
|
||||||
|
// Non-existent source - should return false (no path exists)
|
||||||
|
expect(wouldCreateCircularDependency(features, 'NonExistent', 'A')).toBe(false);
|
||||||
|
|
||||||
|
// Non-existent target - should return false
|
||||||
|
expect(wouldCreateCircularDependency(features, 'A', 'NonExistent')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty features list', () => {
|
||||||
|
const features: Feature[] = [];
|
||||||
|
|
||||||
|
expect(wouldCreateCircularDependency(features, 'A', 'B')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle longer transitive chains', () => {
|
||||||
|
// A <- B <- C <- D <- E
|
||||||
|
const features = [
|
||||||
|
createFeature('A'),
|
||||||
|
createFeature('B', { dependencies: ['A'] }),
|
||||||
|
createFeature('C', { dependencies: ['B'] }),
|
||||||
|
createFeature('D', { dependencies: ['C'] }),
|
||||||
|
createFeature('E', { dependencies: ['D'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Making A depend on E would create a 5-node cycle
|
||||||
|
expect(wouldCreateCircularDependency(features, 'E', 'A')).toBe(true);
|
||||||
|
|
||||||
|
// Making B depend on E would create a 4-node cycle
|
||||||
|
expect(wouldCreateCircularDependency(features, 'E', 'B')).toBe(true);
|
||||||
|
|
||||||
|
// Making E depend on A is fine (already exists transitively, but adding explicit is ok)
|
||||||
|
// Wait, E already depends on A transitively. Let's add F instead
|
||||||
|
const featuresWithF = [...features, createFeature('F')];
|
||||||
|
expect(wouldCreateCircularDependency(featuresWithF, 'E', 'F')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle diamond dependency pattern', () => {
|
||||||
|
// A
|
||||||
|
// / \
|
||||||
|
// B C
|
||||||
|
// \ /
|
||||||
|
// D
|
||||||
|
const features = [
|
||||||
|
createFeature('A'),
|
||||||
|
createFeature('B', { dependencies: ['A'] }),
|
||||||
|
createFeature('C', { dependencies: ['A'] }),
|
||||||
|
createFeature('D', { dependencies: ['B', 'C'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Making A depend on D would create a cycle through both paths
|
||||||
|
expect(wouldCreateCircularDependency(features, 'D', 'A')).toBe(true);
|
||||||
|
|
||||||
|
// Making B depend on D would create a cycle
|
||||||
|
expect(wouldCreateCircularDependency(features, 'D', 'B')).toBe(true);
|
||||||
|
|
||||||
|
// Adding E that depends on D should be fine
|
||||||
|
const featuresWithE = [...features, createFeature('E')];
|
||||||
|
expect(wouldCreateCircularDependency(featuresWithE, 'D', 'E')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dependencyExists', () => {
|
||||||
|
it('should return false when target has no dependencies', () => {
|
||||||
|
const features = [createFeature('A'), createFeature('B')];
|
||||||
|
|
||||||
|
expect(dependencyExists(features, 'A', 'B')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when direct dependency exists', () => {
|
||||||
|
const features = [createFeature('A'), createFeature('B', { dependencies: ['A'] })];
|
||||||
|
|
||||||
|
expect(dependencyExists(features, 'A', 'B')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for reverse direction', () => {
|
||||||
|
const features = [createFeature('A'), createFeature('B', { dependencies: ['A'] })];
|
||||||
|
|
||||||
|
// B depends on A, but A does not depend on B
|
||||||
|
expect(dependencyExists(features, 'B', 'A')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for transitive dependencies', () => {
|
||||||
|
// This function only checks direct dependencies, not transitive
|
||||||
|
const features = [
|
||||||
|
createFeature('A'),
|
||||||
|
createFeature('B', { dependencies: ['A'] }),
|
||||||
|
createFeature('C', { dependencies: ['B'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// C depends on B which depends on A, but C doesn't directly depend on A
|
||||||
|
expect(dependencyExists(features, 'A', 'C')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for one of multiple dependencies', () => {
|
||||||
|
const features = [
|
||||||
|
createFeature('A'),
|
||||||
|
createFeature('B'),
|
||||||
|
createFeature('C', { dependencies: ['A', 'B'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(dependencyExists(features, 'A', 'C')).toBe(true);
|
||||||
|
expect(dependencyExists(features, 'B', 'C')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when target feature does not exist', () => {
|
||||||
|
const features = [createFeature('A')];
|
||||||
|
|
||||||
|
expect(dependencyExists(features, 'A', 'NonExistent')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty dependencies array', () => {
|
||||||
|
const features = [createFeature('A'), createFeature('B', { dependencies: [] })];
|
||||||
|
|
||||||
|
expect(dependencyExists(features, 'A', 'B')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -81,6 +81,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -3766,6 +3767,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-collapsible": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
|||||||
407
pnpm-lock.yaml
generated
Normal file
407
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
cross-spawn:
|
||||||
|
specifier: ^7.0.6
|
||||||
|
version: 7.0.6
|
||||||
|
rehype-sanitize:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
|
tree-kill:
|
||||||
|
specifier: ^1.2.2
|
||||||
|
version: 1.2.2
|
||||||
|
devDependencies:
|
||||||
|
husky:
|
||||||
|
specifier: ^9.1.7
|
||||||
|
version: 9.1.7
|
||||||
|
lint-staged:
|
||||||
|
specifier: ^16.2.7
|
||||||
|
version: 16.2.7
|
||||||
|
prettier:
|
||||||
|
specifier: ^3.7.4
|
||||||
|
version: 3.7.4
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@types/hast@3.0.4':
|
||||||
|
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||||
|
|
||||||
|
'@types/unist@3.0.3':
|
||||||
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
|
'@ungap/structured-clone@1.3.0':
|
||||||
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
|
ansi-escapes@7.2.0:
|
||||||
|
resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
ansi-regex@6.2.2:
|
||||||
|
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
ansi-styles@6.2.3:
|
||||||
|
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
braces@3.0.3:
|
||||||
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
cli-cursor@5.0.0:
|
||||||
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cli-truncate@5.1.1:
|
||||||
|
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
colorette@2.0.20:
|
||||||
|
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||||
|
|
||||||
|
commander@14.0.2:
|
||||||
|
resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
emoji-regex@10.6.0:
|
||||||
|
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||||
|
|
||||||
|
environment@1.1.0:
|
||||||
|
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
eventemitter3@5.0.1:
|
||||||
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
|
||||||
|
fill-range@7.1.1:
|
||||||
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
get-east-asian-width@1.4.0:
|
||||||
|
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
hast-util-sanitize@5.0.2:
|
||||||
|
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
|
||||||
|
|
||||||
|
husky@9.1.7:
|
||||||
|
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
is-fullwidth-code-point@5.1.0:
|
||||||
|
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
is-number@7.0.0:
|
||||||
|
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||||
|
engines: {node: '>=0.12.0'}
|
||||||
|
|
||||||
|
isexe@2.0.0:
|
||||||
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
lint-staged@16.2.7:
|
||||||
|
resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==}
|
||||||
|
engines: {node: '>=20.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
listr2@9.0.5:
|
||||||
|
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
log-update@6.1.0:
|
||||||
|
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
micromatch@4.0.8:
|
||||||
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
mimic-function@5.0.1:
|
||||||
|
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
nano-spawn@2.0.0:
|
||||||
|
resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==}
|
||||||
|
engines: {node: '>=20.17'}
|
||||||
|
|
||||||
|
onetime@7.0.0:
|
||||||
|
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
path-key@3.1.1:
|
||||||
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
picomatch@2.3.1:
|
||||||
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
pidtree@0.6.0:
|
||||||
|
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
prettier@3.7.4:
|
||||||
|
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
rehype-sanitize@6.0.0:
|
||||||
|
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
|
||||||
|
|
||||||
|
restore-cursor@5.1.0:
|
||||||
|
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
rfdc@1.4.1:
|
||||||
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
|
shebang-command@2.0.0:
|
||||||
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
shebang-regex@3.0.0:
|
||||||
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
signal-exit@4.1.0:
|
||||||
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
slice-ansi@7.1.2:
|
||||||
|
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
string-argv@0.3.2:
|
||||||
|
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||||
|
engines: {node: '>=0.6.19'}
|
||||||
|
|
||||||
|
string-width@7.2.0:
|
||||||
|
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
string-width@8.1.0:
|
||||||
|
resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
strip-ansi@7.1.2:
|
||||||
|
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
to-regex-range@5.0.1:
|
||||||
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
|
engines: {node: '>=8.0'}
|
||||||
|
|
||||||
|
tree-kill@1.2.2:
|
||||||
|
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
unist-util-position@5.0.0:
|
||||||
|
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
|
||||||
|
|
||||||
|
which@2.0.2:
|
||||||
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
wrap-ansi@9.0.2:
|
||||||
|
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
yaml@2.8.2:
|
||||||
|
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
||||||
|
engines: {node: '>= 14.6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@types/hast@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
|
ansi-escapes@7.2.0:
|
||||||
|
dependencies:
|
||||||
|
environment: 1.1.0
|
||||||
|
|
||||||
|
ansi-regex@6.2.2: {}
|
||||||
|
|
||||||
|
ansi-styles@6.2.3: {}
|
||||||
|
|
||||||
|
braces@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
fill-range: 7.1.1
|
||||||
|
|
||||||
|
cli-cursor@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
restore-cursor: 5.1.0
|
||||||
|
|
||||||
|
cli-truncate@5.1.1:
|
||||||
|
dependencies:
|
||||||
|
slice-ansi: 7.1.2
|
||||||
|
string-width: 8.1.0
|
||||||
|
|
||||||
|
colorette@2.0.20: {}
|
||||||
|
|
||||||
|
commander@14.0.2: {}
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
path-key: 3.1.1
|
||||||
|
shebang-command: 2.0.0
|
||||||
|
which: 2.0.2
|
||||||
|
|
||||||
|
emoji-regex@10.6.0: {}
|
||||||
|
|
||||||
|
environment@1.1.0: {}
|
||||||
|
|
||||||
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
|
fill-range@7.1.1:
|
||||||
|
dependencies:
|
||||||
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
get-east-asian-width@1.4.0: {}
|
||||||
|
|
||||||
|
hast-util-sanitize@5.0.2:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
'@ungap/structured-clone': 1.3.0
|
||||||
|
unist-util-position: 5.0.0
|
||||||
|
|
||||||
|
husky@9.1.7: {}
|
||||||
|
|
||||||
|
is-fullwidth-code-point@5.1.0:
|
||||||
|
dependencies:
|
||||||
|
get-east-asian-width: 1.4.0
|
||||||
|
|
||||||
|
is-number@7.0.0: {}
|
||||||
|
|
||||||
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
lint-staged@16.2.7:
|
||||||
|
dependencies:
|
||||||
|
commander: 14.0.2
|
||||||
|
listr2: 9.0.5
|
||||||
|
micromatch: 4.0.8
|
||||||
|
nano-spawn: 2.0.0
|
||||||
|
pidtree: 0.6.0
|
||||||
|
string-argv: 0.3.2
|
||||||
|
yaml: 2.8.2
|
||||||
|
|
||||||
|
listr2@9.0.5:
|
||||||
|
dependencies:
|
||||||
|
cli-truncate: 5.1.1
|
||||||
|
colorette: 2.0.20
|
||||||
|
eventemitter3: 5.0.1
|
||||||
|
log-update: 6.1.0
|
||||||
|
rfdc: 1.4.1
|
||||||
|
wrap-ansi: 9.0.2
|
||||||
|
|
||||||
|
log-update@6.1.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-escapes: 7.2.0
|
||||||
|
cli-cursor: 5.0.0
|
||||||
|
slice-ansi: 7.1.2
|
||||||
|
strip-ansi: 7.1.2
|
||||||
|
wrap-ansi: 9.0.2
|
||||||
|
|
||||||
|
micromatch@4.0.8:
|
||||||
|
dependencies:
|
||||||
|
braces: 3.0.3
|
||||||
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
mimic-function@5.0.1: {}
|
||||||
|
|
||||||
|
nano-spawn@2.0.0: {}
|
||||||
|
|
||||||
|
onetime@7.0.0:
|
||||||
|
dependencies:
|
||||||
|
mimic-function: 5.0.1
|
||||||
|
|
||||||
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
|
picomatch@2.3.1: {}
|
||||||
|
|
||||||
|
pidtree@0.6.0: {}
|
||||||
|
|
||||||
|
prettier@3.7.4: {}
|
||||||
|
|
||||||
|
rehype-sanitize@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
hast-util-sanitize: 5.0.2
|
||||||
|
|
||||||
|
restore-cursor@5.1.0:
|
||||||
|
dependencies:
|
||||||
|
onetime: 7.0.0
|
||||||
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
|
shebang-command@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
shebang-regex: 3.0.0
|
||||||
|
|
||||||
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
|
slice-ansi@7.1.2:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 6.2.3
|
||||||
|
is-fullwidth-code-point: 5.1.0
|
||||||
|
|
||||||
|
string-argv@0.3.2: {}
|
||||||
|
|
||||||
|
string-width@7.2.0:
|
||||||
|
dependencies:
|
||||||
|
emoji-regex: 10.6.0
|
||||||
|
get-east-asian-width: 1.4.0
|
||||||
|
strip-ansi: 7.1.2
|
||||||
|
|
||||||
|
string-width@8.1.0:
|
||||||
|
dependencies:
|
||||||
|
get-east-asian-width: 1.4.0
|
||||||
|
strip-ansi: 7.1.2
|
||||||
|
|
||||||
|
strip-ansi@7.1.2:
|
||||||
|
dependencies:
|
||||||
|
ansi-regex: 6.2.2
|
||||||
|
|
||||||
|
to-regex-range@5.0.1:
|
||||||
|
dependencies:
|
||||||
|
is-number: 7.0.0
|
||||||
|
|
||||||
|
tree-kill@1.2.2: {}
|
||||||
|
|
||||||
|
unist-util-position@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
|
which@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
wrap-ansi@9.0.2:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 6.2.3
|
||||||
|
string-width: 7.2.0
|
||||||
|
strip-ansi: 7.1.2
|
||||||
|
|
||||||
|
yaml@2.8.2: {}
|
||||||
Reference in New Issue
Block a user