feat: implement completed features management in BoardView and KanbanCard

- Added functionality to complete and unarchive features, allowing users to manage feature statuses effectively.
- Introduced a modal to display completed features, enhancing user experience by providing a dedicated view for archived items.
- Updated KanbanCard to include buttons for completing features and managing their states, improving interactivity and workflow.
- Modified the Feature interface to include a new "completed" status, ensuring comprehensive state management across the application.
This commit is contained in:
Cody Seibert
2025-12-14 20:06:52 -05:00
parent f6c50ce336
commit 72e803b56d
3 changed files with 421 additions and 12 deletions

View File

@@ -90,6 +90,8 @@ import {
Maximize2,
Shuffle,
ImageIcon,
Archive,
ArchiveRestore,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -208,6 +210,9 @@ export function BoardView() {
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [showCompletedModal, setShowCompletedModal] = useState(false);
const [deleteCompletedFeature, setDeleteCompletedFeature] =
useState<Feature | null>(null);
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
@@ -1497,6 +1502,47 @@ export function BoardView() {
}
};
// Complete a verified feature (move to completed/archived)
const handleCompleteFeature = (feature: Feature) => {
console.log("[Board] Completing feature:", {
id: feature.id,
description: feature.description,
});
const updates = {
status: "completed" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature completed", {
description: `Archived: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
}`,
});
};
// Unarchive a completed feature (move back to verified)
const handleUnarchiveFeature = (feature: Feature) => {
console.log("[Board] Unarchiving feature:", {
id: feature.id,
description: feature.description,
});
const updates = {
status: "verified" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${feature.description.slice(
0,
50
)}${feature.description.length > 50 ? "..." : ""}`,
});
};
const checkContextExists = async (featureId: string): Promise<boolean> => {
if (!currentProject) return false;
@@ -1518,6 +1564,11 @@ export function BoardView() {
}
};
// Memoize completed features for the archive modal
const completedFeatures = useMemo(() => {
return features.filter((f) => f.status === "completed");
}, [features]);
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
const map: Record<ColumnId, Feature[]> = {
@@ -1525,6 +1576,7 @@ export function BoardView() {
in_progress: [],
waiting_approval: [],
verified: [],
completed: [], // Completed features are shown in the archive modal, not as a column
};
// Filter features by search query (case-insensitive)
@@ -1903,6 +1955,31 @@ export function BoardView() {
</TooltipContent>
</Tooltip>
{/* Completed/Archived Features Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setShowCompletedModal(true)}
className="h-8 px-2 relative"
data-testid="completed-features-button"
>
<Archive className="w-4 h-4" />
{completedFeatures.length > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{completedFeatures.length > 99
? "99+"
: completedFeatures.length}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Completed Features ({completedFeatures.length})</p>
</TooltipContent>
</Tooltip>
{/* Kanban Card Detail Level Toggle */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
@@ -2107,6 +2184,33 @@ export function BoardView() {
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
onComplete={() =>
handleCompleteFeature(feature)
}
onImplement={async () => {
// Check concurrency limit
if (!autoMode.canStartNewTask) {
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;
}
// Update with startedAt timestamp
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
console.log(
"[Board] Feature moved to in_progress via Implement button, starting agent..."
);
await handleRunFeature(feature);
}}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
@@ -2157,6 +2261,136 @@ export function BoardView() {
onOpenChange={setShowBoardBackgroundModal}
/>
{/* Completed Features Modal */}
<Dialog open={showCompletedModal} onOpenChange={setShowCompletedModal}>
<DialogContent className="max-w-5xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5 text-brand-500" />
Completed Features
</DialogTitle>
<DialogDescription>
{completedFeatures.length === 0
? "No completed features yet. Features you complete will appear here."
: `${completedFeatures.length} completed feature${
completedFeatures.length === 1 ? "" : "s"
}`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{completedFeatures.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Archive className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">No completed features</p>
<p className="text-sm">
Complete features from the Verified column to archive them
here.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{completedFeatures.map((feature) => (
<Card
key={feature.id}
className="flex flex-col"
data-testid={`completed-card-${feature.id}`}
>
<CardHeader className="p-3 pb-2 flex-1">
<CardTitle className="text-sm leading-tight line-clamp-3">
{feature.description || feature.summary || feature.id}
</CardTitle>
<CardDescription className="text-xs mt-1 truncate">
{feature.category || "Uncategorized"}
</CardDescription>
</CardHeader>
<div className="p-3 pt-0 flex gap-2">
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => handleUnarchiveFeature(feature)}
data-testid={`unarchive-${feature.id}`}
>
<ArchiveRestore className="w-3 h-3 mr-1" />
Restore
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteCompletedFeature(feature)}
data-testid={`delete-completed-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowCompletedModal(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Completed Feature Confirmation Dialog */}
<Dialog
open={!!deleteCompletedFeature}
onOpenChange={(open) => !open && setDeleteCompletedFeature(null)}
>
<DialogContent data-testid="delete-completed-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete Feature
</DialogTitle>
<DialogDescription>
Are you sure you want to permanently delete this feature?
<span className="block mt-2 font-medium text-foreground">
&quot;{deleteCompletedFeature?.description?.slice(0, 100)}
{(deleteCompletedFeature?.description?.length ?? 0) > 100
? "..."
: ""}
&quot;
</span>
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteCompletedFeature(null)}
data-testid="cancel-delete-completed-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteCompletedFeature) {
handleDeleteFeature(deleteCompletedFeature.id);
setDeleteCompletedFeature(null);
}
}}
data-testid="confirm-delete-completed-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Feature Dialog */}
<Dialog
open={showAddDialog}

View File

@@ -58,6 +58,8 @@ import {
ChevronUp,
Brain,
Flag,
Wand2,
Archive,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -131,6 +133,8 @@ interface KanbanCardProps {
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
onImplement?: () => void;
onComplete?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -162,6 +166,8 @@ export const KanbanCard = memo(function KanbanCard({
onCommit,
onRevert,
onMerge,
onImplement,
onComplete,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -526,7 +532,120 @@ export const KanbanCard = memo(function KanbanCard({
)}
</div>
)}
{!isCurrentAutoTask && (
{!isCurrentAutoTask && feature.status === "backlog" && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-backlog-${feature.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<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();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-waiting-${feature.id}`}
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{onViewOutput && (
<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();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-waiting-${feature.id}`}
title="Logs"
>
<FileText className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-waiting-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
<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();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-verified-${feature.id}`}
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{onViewOutput && (
<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();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-verified-${feature.id}`}
title="Logs"
>
<FileText className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-verified-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -552,7 +671,7 @@ export const KanbanCard = memo(function KanbanCard({
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && feature.status !== "backlog" && (
{onViewOutput && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -926,12 +1045,12 @@ export const KanbanCard = memo(function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "verified" && (
<>
{/* Logs button if context exists */}
{hasContext && onViewOutput && (
{/* Logs button - styled like Refine */}
{onViewOutput && (
<Button
variant="ghost"
variant="secondary"
size="sm"
className="h-7 text-xs"
className="flex-1 h-7 text-xs min-w-0"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
@@ -939,8 +1058,25 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Logs
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
</Button>
)}
{/* Complete button */}
{onComplete && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs min-w-0 bg-brand-500 hover:bg-brand-600"
onClick={(e) => {
e.stopPropagation();
onComplete();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`complete-${feature.id}`}
>
<Archive className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Complete</span>
</Button>
)}
</>
@@ -972,7 +1108,7 @@ export const KanbanCard = memo(function KanbanCard({
</Tooltip>
</TooltipProvider>
)}
{/* Follow-up prompt button */}
{/* Refine prompt button */}
{onFollowUp && (
<Button
variant="secondary"
@@ -985,8 +1121,8 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Follow-up</span>
<Wand2 className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Refine</span>
</Button>
)}
{/* Merge button - only show when worktree exists */}
@@ -1026,6 +1162,40 @@ export const KanbanCard = memo(function KanbanCard({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
<>
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-backlog-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
{onImplement && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onImplement();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`make-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Make
</Button>
)}
</>
)}
</div>
</CardContent>