mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
When a user tries to drag a card from backlog to in_progress while at the max concurrency limit, a toast notification now appears explaining why the action was blocked and suggesting solutions. - Added sonner toast library - Show error toast with dynamic message based on max concurrency - Updated test utilities with toast helpers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
881 lines
28 KiB
TypeScript
881 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragOverlay,
|
|
DragStartEvent,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
rectIntersection,
|
|
pointerWithin,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { useAppStore, Feature, FeatureImage } from "@/store/app-store";
|
|
import { getElectronAPI } from "@/lib/electron";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
Card,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
|
import { FeatureImageUpload } from "@/components/ui/feature-image-upload";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { KanbanColumn } from "./kanban-column";
|
|
import { KanbanCard } from "./kanban-card";
|
|
import { AutoModeLog } from "./auto-mode-log";
|
|
import { AgentOutputModal } from "./agent-output-modal";
|
|
import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown, Users } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
|
|
|
type ColumnId = Feature["status"];
|
|
|
|
const COLUMNS: { id: ColumnId; title: string; color: string }[] = [
|
|
{ id: "backlog", title: "Backlog", color: "bg-zinc-500" },
|
|
{ id: "in_progress", title: "In Progress", color: "bg-yellow-500" },
|
|
{ id: "verified", title: "Verified", color: "bg-green-500" },
|
|
];
|
|
|
|
export function BoardView() {
|
|
const {
|
|
currentProject,
|
|
features,
|
|
setFeatures,
|
|
addFeature,
|
|
updateFeature,
|
|
removeFeature,
|
|
moveFeature,
|
|
runningAutoTasks,
|
|
maxConcurrency,
|
|
setMaxConcurrency,
|
|
} = useAppStore();
|
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
|
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
const [newFeature, setNewFeature] = useState({
|
|
category: "",
|
|
description: "",
|
|
steps: [""],
|
|
images: [] as FeatureImage[],
|
|
});
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isMounted, setIsMounted] = useState(false);
|
|
const [showActivityLog, setShowActivityLog] = useState(false);
|
|
const [showOutputModal, setShowOutputModal] = useState(false);
|
|
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
|
|
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(new Set());
|
|
|
|
// Make current project available globally for modal
|
|
useEffect(() => {
|
|
if (currentProject) {
|
|
(window as any).__currentProject = currentProject;
|
|
}
|
|
return () => {
|
|
(window as any).__currentProject = null;
|
|
};
|
|
}, [currentProject]);
|
|
|
|
// Auto mode hook
|
|
const autoMode = useAutoMode();
|
|
|
|
// Prevent hydration issues
|
|
useEffect(() => {
|
|
setIsMounted(true);
|
|
}, []);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
})
|
|
);
|
|
|
|
// Get unique categories from existing features for autocomplete suggestions
|
|
const categorySuggestions = useMemo(() => {
|
|
const categories = features.map((f) => f.category).filter(Boolean);
|
|
return [...new Set(categories)].sort();
|
|
}, [features]);
|
|
|
|
// Custom collision detection that prioritizes columns over cards
|
|
const collisionDetectionStrategy = useCallback((args: any) => {
|
|
// First, check if pointer is within a column
|
|
const pointerCollisions = pointerWithin(args);
|
|
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
|
COLUMNS.some((col) => col.id === collision.id)
|
|
);
|
|
|
|
// If we found a column collision, use that
|
|
if (columnCollisions.length > 0) {
|
|
return columnCollisions;
|
|
}
|
|
|
|
// Otherwise, use rectangle intersection for cards
|
|
return rectIntersection(args);
|
|
}, []);
|
|
|
|
// Load features from file
|
|
const loadFeatures = useCallback(async () => {
|
|
if (!currentProject) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const api = getElectronAPI();
|
|
const result = await api.readFile(
|
|
`${currentProject.path}/.automaker/feature_list.json`
|
|
);
|
|
|
|
if (result.success && result.content) {
|
|
const parsed = JSON.parse(result.content);
|
|
const featuresWithIds = parsed.map(
|
|
(f: any, index: number) => ({
|
|
...f,
|
|
id: f.id || `feature-${index}-${Date.now()}`,
|
|
status: f.status || "backlog",
|
|
})
|
|
);
|
|
setFeatures(featuresWithIds);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load features:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [currentProject, setFeatures]);
|
|
|
|
// Auto-show activity log when auto mode starts
|
|
useEffect(() => {
|
|
if (autoMode.isRunning && !showActivityLog) {
|
|
setShowActivityLog(true);
|
|
}
|
|
}, [autoMode.isRunning, showActivityLog]);
|
|
|
|
// Listen for auto mode feature completion and reload features
|
|
useEffect(() => {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode) return;
|
|
|
|
const unsubscribe = api.autoMode.onEvent((event) => {
|
|
if (event.type === "auto_mode_feature_complete") {
|
|
// Reload features when a feature is completed
|
|
console.log("[Board] Feature completed, reloading features...");
|
|
loadFeatures();
|
|
}
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [loadFeatures]);
|
|
|
|
useEffect(() => {
|
|
loadFeatures();
|
|
}, [loadFeatures]);
|
|
|
|
// Sync running tasks from electron backend on mount
|
|
useEffect(() => {
|
|
const syncRunningTasks = async () => {
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode?.status) return;
|
|
|
|
const status = await api.autoMode.status();
|
|
if (status.success && status.runningFeatures) {
|
|
console.log("[Board] Syncing running tasks from backend:", status.runningFeatures);
|
|
|
|
// Clear existing running tasks and add the actual running ones
|
|
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
|
|
clearRunningTasks();
|
|
|
|
// Add each running feature to the store
|
|
status.runningFeatures.forEach((featureId: string) => {
|
|
addRunningTask(featureId);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("[Board] Failed to sync running tasks:", error);
|
|
}
|
|
};
|
|
|
|
syncRunningTasks();
|
|
}, []);
|
|
|
|
// Check which features have context files
|
|
useEffect(() => {
|
|
const checkAllContexts = async () => {
|
|
const inProgressFeatures = features.filter((f) => f.status === "in_progress");
|
|
const contextChecks = await Promise.all(
|
|
inProgressFeatures.map(async (f) => ({
|
|
id: f.id,
|
|
hasContext: await checkContextExists(f.id),
|
|
}))
|
|
);
|
|
|
|
const newSet = new Set<string>();
|
|
contextChecks.forEach(({ id, hasContext }) => {
|
|
if (hasContext) {
|
|
newSet.add(id);
|
|
}
|
|
});
|
|
|
|
setFeaturesWithContext(newSet);
|
|
};
|
|
|
|
if (features.length > 0 && !isLoading) {
|
|
checkAllContexts();
|
|
}
|
|
}, [features, isLoading]);
|
|
|
|
// Save features to file
|
|
const saveFeatures = useCallback(async () => {
|
|
if (!currentProject) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
const toSave = features.map((f) => ({
|
|
id: f.id,
|
|
category: f.category,
|
|
description: f.description,
|
|
steps: f.steps,
|
|
status: f.status,
|
|
}));
|
|
await api.writeFile(
|
|
`${currentProject.path}/.automaker/feature_list.json`,
|
|
JSON.stringify(toSave, null, 2)
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to save features:", error);
|
|
}
|
|
}, [currentProject, features]);
|
|
|
|
// Save when features change (after initial load is complete)
|
|
useEffect(() => {
|
|
if (!isLoading) {
|
|
saveFeatures();
|
|
}
|
|
}, [features, saveFeatures, isLoading]);
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
const { active } = event;
|
|
const feature = features.find((f) => f.id === active.id);
|
|
if (feature) {
|
|
setActiveFeature(feature);
|
|
}
|
|
};
|
|
|
|
const handleDragEnd = async (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
setActiveFeature(null);
|
|
|
|
if (!over) return;
|
|
|
|
const featureId = active.id as string;
|
|
const overId = over.id as string;
|
|
|
|
// Find the feature being dragged
|
|
const draggedFeature = features.find((f) => f.id === featureId);
|
|
if (!draggedFeature) return;
|
|
|
|
// Only allow dragging from backlog
|
|
if (draggedFeature.status !== "backlog") {
|
|
console.log("[Board] Cannot drag feature that is already in progress or verified");
|
|
return;
|
|
}
|
|
|
|
let targetStatus: ColumnId | null = null;
|
|
|
|
// Check if we dropped on a column
|
|
const column = COLUMNS.find((c) => c.id === overId);
|
|
if (column) {
|
|
targetStatus = column.id;
|
|
} else {
|
|
// Dropped on another feature - find its column
|
|
const overFeature = features.find((f) => f.id === overId);
|
|
if (overFeature) {
|
|
targetStatus = overFeature.status;
|
|
}
|
|
}
|
|
|
|
if (!targetStatus) return;
|
|
|
|
// Check concurrency limit before moving to in_progress
|
|
if (targetStatus === "in_progress" && !autoMode.canStartNewTask) {
|
|
console.log("[Board] Cannot start new task - at max concurrency limit");
|
|
toast.error("Concurrency limit reached", {
|
|
description: `You can only have ${autoMode.maxConcurrency} task${autoMode.maxConcurrency > 1 ? "s" : ""} running at a time. Wait for a task to complete or increase the limit.`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Move the feature
|
|
moveFeature(featureId, targetStatus);
|
|
|
|
// If moved to in_progress, trigger the agent
|
|
if (targetStatus === "in_progress") {
|
|
console.log("[Board] Feature moved to in_progress, starting agent...");
|
|
await handleRunFeature(draggedFeature);
|
|
}
|
|
};
|
|
|
|
const handleAddFeature = () => {
|
|
addFeature({
|
|
category: newFeature.category || "Uncategorized",
|
|
description: newFeature.description,
|
|
steps: newFeature.steps.filter((s) => s.trim()),
|
|
status: "backlog",
|
|
images: newFeature.images,
|
|
});
|
|
setNewFeature({ category: "", description: "", steps: [""], images: [] });
|
|
setShowAddDialog(false);
|
|
};
|
|
|
|
const handleUpdateFeature = () => {
|
|
if (!editingFeature) return;
|
|
|
|
updateFeature(editingFeature.id, {
|
|
category: editingFeature.category,
|
|
description: editingFeature.description,
|
|
steps: editingFeature.steps,
|
|
});
|
|
setEditingFeature(null);
|
|
};
|
|
|
|
const handleDeleteFeature = (featureId: string) => {
|
|
if (window.confirm("Are you sure you want to delete this feature?")) {
|
|
removeFeature(featureId);
|
|
}
|
|
};
|
|
|
|
const handleRunFeature = async (feature: Feature) => {
|
|
if (!currentProject) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode) {
|
|
console.error("Auto mode API not available");
|
|
return;
|
|
}
|
|
|
|
// Call the API to run this specific feature by ID
|
|
const result = await api.autoMode.runFeature(
|
|
currentProject.path,
|
|
feature.id
|
|
);
|
|
|
|
if (result.success) {
|
|
console.log("[Board] Feature run started successfully");
|
|
// The feature status will be updated by the auto mode service
|
|
// and the UI will reload features when the agent completes (via event listener)
|
|
} else {
|
|
console.error("[Board] Failed to run feature:", result.error);
|
|
// Reload to revert the UI status change
|
|
await loadFeatures();
|
|
}
|
|
} catch (error) {
|
|
console.error("[Board] Error running feature:", error);
|
|
// Reload to revert the UI status change
|
|
await loadFeatures();
|
|
}
|
|
};
|
|
|
|
const handleVerifyFeature = async (feature: Feature) => {
|
|
if (!currentProject) return;
|
|
|
|
console.log("[Board] Verifying feature:", { id: feature.id, description: feature.description });
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode) {
|
|
console.error("Auto mode API not available");
|
|
return;
|
|
}
|
|
|
|
// Call the API to verify this specific feature by ID
|
|
const result = await api.autoMode.verifyFeature(
|
|
currentProject.path,
|
|
feature.id
|
|
);
|
|
|
|
if (result.success) {
|
|
console.log("[Board] Feature verification started successfully");
|
|
// The feature status will be updated by the auto mode service
|
|
// and the UI will reload features when verification completes
|
|
} else {
|
|
console.error("[Board] Failed to verify feature:", result.error);
|
|
await loadFeatures();
|
|
}
|
|
} catch (error) {
|
|
console.error("[Board] Error verifying feature:", error);
|
|
await loadFeatures();
|
|
}
|
|
};
|
|
|
|
const handleResumeFeature = async (feature: Feature) => {
|
|
if (!currentProject) return;
|
|
|
|
console.log("[Board] Resuming feature:", { id: feature.id, description: feature.description });
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode) {
|
|
console.error("Auto mode API not available");
|
|
return;
|
|
}
|
|
|
|
// Call the API to resume this specific feature by ID with context
|
|
const result = await api.autoMode.resumeFeature(
|
|
currentProject.path,
|
|
feature.id
|
|
);
|
|
|
|
if (result.success) {
|
|
console.log("[Board] Feature resume started successfully");
|
|
// The feature status will be updated by the auto mode service
|
|
// and the UI will reload features when resume completes
|
|
} else {
|
|
console.error("[Board] Failed to resume feature:", result.error);
|
|
await loadFeatures();
|
|
}
|
|
} catch (error) {
|
|
console.error("[Board] Error resuming feature:", error);
|
|
await loadFeatures();
|
|
}
|
|
};
|
|
|
|
const checkContextExists = async (featureId: string): Promise<boolean> => {
|
|
if (!currentProject) return false;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (!api?.autoMode?.contextExists) {
|
|
return false;
|
|
}
|
|
|
|
const result = await api.autoMode.contextExists(
|
|
currentProject.path,
|
|
featureId
|
|
);
|
|
|
|
return result.success && result.exists === true;
|
|
} catch (error) {
|
|
console.error("[Board] Error checking context:", error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const getColumnFeatures = (columnId: ColumnId) => {
|
|
return features.filter((f) => f.status === columnId);
|
|
};
|
|
|
|
const handleViewOutput = (feature: Feature) => {
|
|
setOutputFeature(feature);
|
|
setShowOutputModal(true);
|
|
};
|
|
|
|
const handleForceStopFeature = async (feature: Feature) => {
|
|
try {
|
|
await autoMode.stopFeature(feature.id);
|
|
// Move the feature back to backlog status after stopping
|
|
moveFeature(feature.id, "backlog");
|
|
toast.success("Agent stopped", {
|
|
description: `Stopped working on: ${feature.description.slice(0, 50)}${feature.description.length > 50 ? "..." : ""}`,
|
|
});
|
|
} catch (error) {
|
|
console.error("[Board] Error stopping feature:", error);
|
|
toast.error("Failed to stop agent", {
|
|
description: error instanceof Error ? error.message : "An error occurred",
|
|
});
|
|
}
|
|
};
|
|
|
|
if (!currentProject) {
|
|
return (
|
|
<div
|
|
className="flex-1 flex items-center justify-center"
|
|
data-testid="board-view-no-project"
|
|
>
|
|
<p className="text-muted-foreground">No project selected</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div
|
|
className="flex-1 flex items-center justify-center"
|
|
data-testid="board-view-loading"
|
|
>
|
|
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex-1 flex flex-col overflow-hidden content-bg relative"
|
|
data-testid="board-view"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
|
<div>
|
|
<h1 className="text-xl font-bold">Kanban Board</h1>
|
|
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
|
</div>
|
|
<div className="flex gap-2 items-center">
|
|
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
|
{isMounted && (
|
|
<div
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10"
|
|
data-testid="concurrency-slider-container"
|
|
>
|
|
<Users className="w-4 h-4 text-zinc-400" />
|
|
<Slider
|
|
value={[maxConcurrency]}
|
|
onValueChange={(value) => setMaxConcurrency(value[0])}
|
|
min={1}
|
|
max={10}
|
|
step={1}
|
|
className="w-20"
|
|
data-testid="concurrency-slider"
|
|
/>
|
|
<span
|
|
className="text-sm text-zinc-400 min-w-[2ch] text-center"
|
|
data-testid="concurrency-value"
|
|
>
|
|
{maxConcurrency}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
|
{isMounted && (
|
|
<>
|
|
{autoMode.isRunning ? (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => autoMode.stop()}
|
|
data-testid="stop-auto-mode"
|
|
>
|
|
<StopCircle className="w-4 h-4 mr-2" />
|
|
Stop Auto Mode
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => autoMode.start()}
|
|
data-testid="start-auto-mode"
|
|
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700"
|
|
>
|
|
<Play className="w-4 h-4 mr-2" />
|
|
Auto Mode
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{isMounted && autoMode.isRunning && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowActivityLog(!showActivityLog)}
|
|
data-testid="toggle-activity-log"
|
|
>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin text-purple-500" />
|
|
Activity
|
|
{showActivityLog ? (
|
|
<ChevronDown className="w-4 h-4 ml-2" />
|
|
) : (
|
|
<ChevronUp className="w-4 h-4 ml-2" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={loadFeatures}
|
|
data-testid="refresh-board"
|
|
>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
Refresh
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setShowAddDialog(true)}
|
|
data-testid="add-feature-button"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Feature
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Kanban Columns */}
|
|
<div className={cn(
|
|
"flex-1 overflow-x-auto p-4",
|
|
showActivityLog && "transition-all"
|
|
)}>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={collisionDetectionStrategy}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="flex gap-4 h-full min-w-max">
|
|
{COLUMNS.map((column) => {
|
|
const columnFeatures = getColumnFeatures(column.id);
|
|
return (
|
|
<KanbanColumn
|
|
key={column.id}
|
|
id={column.id}
|
|
title={column.title}
|
|
color={column.color}
|
|
count={columnFeatures.length}
|
|
>
|
|
<SortableContext
|
|
items={columnFeatures.map((f) => f.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{columnFeatures.map((feature) => (
|
|
<KanbanCard
|
|
key={feature.id}
|
|
feature={feature}
|
|
onEdit={() => setEditingFeature(feature)}
|
|
onDelete={() => handleDeleteFeature(feature.id)}
|
|
onViewOutput={() => handleViewOutput(feature)}
|
|
onVerify={() => handleVerifyFeature(feature)}
|
|
onResume={() => handleResumeFeature(feature)}
|
|
onForceStop={() => handleForceStopFeature(feature)}
|
|
hasContext={featuresWithContext.has(feature.id)}
|
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</KanbanColumn>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<DragOverlay>
|
|
{activeFeature && (
|
|
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
|
|
<CardHeader className="p-3">
|
|
<CardTitle className="text-sm">
|
|
{activeFeature.description}
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
{activeFeature.category}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
)}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</div>
|
|
|
|
{/* Activity Log Panel */}
|
|
{showActivityLog && (
|
|
<div className="w-96 border-l border-white/10 flex-shrink-0">
|
|
<AutoModeLog onClose={() => setShowActivityLog(false)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Feature Dialog */}
|
|
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
|
<DialogContent data-testid="add-feature-dialog">
|
|
<DialogHeader>
|
|
<DialogTitle>Add New Feature</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new feature card for the Kanban board.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="category">Category</Label>
|
|
<CategoryAutocomplete
|
|
value={newFeature.category}
|
|
onChange={(value) =>
|
|
setNewFeature({ ...newFeature, category: value })
|
|
}
|
|
suggestions={categorySuggestions}
|
|
placeholder="e.g., Core, UI, API"
|
|
data-testid="feature-category-input"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
placeholder="Describe the feature..."
|
|
value={newFeature.description}
|
|
onChange={(e) =>
|
|
setNewFeature({ ...newFeature, description: e.target.value })
|
|
}
|
|
data-testid="feature-description-input"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Steps</Label>
|
|
{newFeature.steps.map((step, index) => (
|
|
<Input
|
|
key={index}
|
|
placeholder={`Step ${index + 1}`}
|
|
value={step}
|
|
onChange={(e) => {
|
|
const steps = [...newFeature.steps];
|
|
steps[index] = e.target.value;
|
|
setNewFeature({ ...newFeature, steps });
|
|
}}
|
|
data-testid={`feature-step-${index}-input`}
|
|
/>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
setNewFeature({
|
|
...newFeature,
|
|
steps: [...newFeature.steps, ""],
|
|
})
|
|
}
|
|
data-testid="add-step-button"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Step
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setShowAddDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleAddFeature}
|
|
disabled={!newFeature.description}
|
|
data-testid="confirm-add-feature"
|
|
>
|
|
Add Feature
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Feature Dialog */}
|
|
<Dialog
|
|
open={!!editingFeature}
|
|
onOpenChange={() => setEditingFeature(null)}
|
|
>
|
|
<DialogContent data-testid="edit-feature-dialog">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Feature</DialogTitle>
|
|
<DialogDescription>Modify the feature details.</DialogDescription>
|
|
</DialogHeader>
|
|
{editingFeature && (
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-category">Category</Label>
|
|
<CategoryAutocomplete
|
|
value={editingFeature.category}
|
|
onChange={(value) =>
|
|
setEditingFeature({
|
|
...editingFeature,
|
|
category: value,
|
|
})
|
|
}
|
|
suggestions={categorySuggestions}
|
|
placeholder="e.g., Core, UI, API"
|
|
data-testid="edit-feature-category"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-description">Description</Label>
|
|
<Input
|
|
id="edit-description"
|
|
value={editingFeature.description}
|
|
onChange={(e) =>
|
|
setEditingFeature({
|
|
...editingFeature,
|
|
description: e.target.value,
|
|
})
|
|
}
|
|
data-testid="edit-feature-description"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Steps</Label>
|
|
{editingFeature.steps.map((step, index) => (
|
|
<Input
|
|
key={index}
|
|
value={step}
|
|
onChange={(e) => {
|
|
const steps = [...editingFeature.steps];
|
|
steps[index] = e.target.value;
|
|
setEditingFeature({ ...editingFeature, steps });
|
|
}}
|
|
data-testid={`edit-feature-step-${index}`}
|
|
/>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
setEditingFeature({
|
|
...editingFeature,
|
|
steps: [...editingFeature.steps, ""],
|
|
})
|
|
}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Step
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setEditingFeature(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleUpdateFeature}
|
|
data-testid="confirm-edit-feature"
|
|
>
|
|
Save Changes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Agent Output Modal */}
|
|
<AgentOutputModal
|
|
open={showOutputModal}
|
|
onClose={() => setShowOutputModal(false)}
|
|
featureDescription={outputFeature?.description || ""}
|
|
featureId={outputFeature?.id || ""}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|