Files
automaker/app/src/components/views/kanban-card.tsx
2025-12-09 19:00:21 -05:00

778 lines
27 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Feature, useAppStore } from "@/store/app-store";
import {
GripVertical,
Edit,
CheckCircle2,
Circle,
Loader2,
Trash2,
Eye,
PlayCircle,
RotateCcw,
StopCircle,
FlaskConical,
ArrowLeft,
MessageSquare,
GitCommit,
Cpu,
Wrench,
ListTodo,
Sparkles,
Expand,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
import {
parseAgentContext,
AgentTaskInfo,
formatModelName,
DEFAULT_MODEL,
} from "@/lib/agent-context-parser";
import { Markdown } from "@/components/ui/markdown";
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onCommit?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
/** Context content for extracting progress info */
contextContent?: string;
/** Feature summary from agent completion */
summary?: string;
}
export function KanbanCard({
feature,
onEdit,
onDelete,
onViewOutput,
onVerify,
onResume,
onForceStop,
onManualVerify,
onMoveBackToInProgress,
onFollowUp,
onCommit,
hasContext,
isCurrentAutoTask,
shortcutKey,
contextContent,
summary,
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const { kanbanCardDetailLevel } = useAppStore();
// Helper functions to check what should be shown based on detail level
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === "detailed";
const showProgressBar =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
// Load context file for in_progress, waiting_approval, and verified features
useEffect(() => {
const loadContext = async () => {
// Use provided context or load from file
if (contextContent) {
const info = parseAgentContext(contextContent);
setAgentInfo(info);
return;
}
// Only load for non-backlog features
if (feature.status === "backlog") {
setAgentInfo(null);
return;
}
try {
const api = getElectronAPI();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
const contextPath = `${currentProject.path}/.automaker/agents-context/${feature.id}.md`;
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
} catch {
// Context file might not exist
console.debug("[KanbanCard] No context file for feature:", feature.id);
}
};
loadContext();
// Reload context periodically while feature is running
if (isCurrentAutoTask) {
const interval = setInterval(loadContext, 3000);
return () => clearInterval(interval);
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = () => {
setIsDeleteDialogOpen(false);
onDelete();
};
const handleCancelDelete = () => {
setIsDeleteDialogOpen(false);
};
// Dragging logic:
// - Backlog items can always be dragged
// - skipTests items can be dragged even when in_progress or verified (unless currently running)
// - Non-skipTests (TDD) items in progress or verified cannot be dragged
const isDraggable =
feature.status === "backlog" || (feature.skipTests && !isCurrentAutoTask);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: feature.id,
disabled: !isDraggable,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<Card
ref={setNodeRef}
style={style}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative",
isDragging && "opacity-50 scale-105 shadow-lg",
isCurrentAutoTask &&
"border-purple-500 border-2 shadow-purple-500/50 shadow-lg animate-pulse"
)}
data-testid={`kanban-card-${feature.id}`}
{...attributes}
>
{/* Shortcut key badge for in-progress cards */}
{shortcutKey && (
<div
className="absolute top-2 left-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-muted border border-border text-muted-foreground z-10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</div>
)}
{/* Skip Tests indicator badge */}
{feature.skipTests && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
shortcutKey ? "top-2 left-10" : "top-2 left-2",
"bg-orange-500/20 border border-orange-500/50 text-orange-400"
)}
data-testid={`skip-tests-badge-${feature.id}`}
title="Manual verification required"
>
<FlaskConical className="w-3 h-3" />
<span>Manual</span>
</div>
)}
<CardHeader className="p-3 pb-2">
{isCurrentAutoTask && (
<div className="absolute top-2 right-2 flex items-center gap-2 bg-purple-500/20 border border-purple-500 rounded px-2 py-0.5">
<Loader2 className="w-4 h-4 text-purple-400 animate-spin" />
<span className="text-xs text-purple-400 font-medium">
Running...
</span>
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
className="text-purple-400"
/>
)}
</div>
)}
{/* Show timer for in_progress cards that aren't currently running */}
{!isCurrentAutoTask &&
feature.status === "in_progress" &&
feature.startedAt && (
<div className="absolute top-2 right-2">
<CountUpTimer
startedAt={feature.startedAt}
className="text-yellow-500"
/>
</div>
)}
<div className="flex items-start gap-2">
{isDraggable && (
<div
{...listeners}
className="mt-0.5 touch-none cursor-grab"
data-testid={`drag-handle-${feature.id}`}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-sm leading-tight">
{feature.description}
</CardTitle>
<CardDescription className="text-xs mt-1">
{feature.category}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
{/* Steps Preview - Show in Standard and Detailed modes */}
{showSteps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1">
{feature.steps.slice(0, 3).map((step, index) => (
<div
key={index}
className="flex items-start gap-2 text-xs text-muted-foreground"
>
{feature.status === "verified" ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
)}
<span className="truncate">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
<p className="text-xs text-muted-foreground pl-5">
+{feature.steps.length - 3} more steps
</p>
)}
</div>
)}
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
{/* Standard mode: Only show progress bar */}
{showProgressBar &&
!showAgentInfo &&
feature.status !== "backlog" &&
agentInfo &&
(isCurrentAutoTask || feature.status === "in_progress") && (
<div className="mb-3 space-y-1">
<div className="w-full h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="w-full h-full bg-primary transition-transform duration-500 ease-out origin-left"
style={{
transform: `translateX(${
agentInfo.progressPercentage - 100
}%)`,
}}
/>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{Math.round(agentInfo.progressPercentage)}%</span>
</div>
</div>
)}
{/* Detailed mode: Show all agent info */}
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
<div className="mb-3 space-y-2">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1 text-cyan-400">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(DEFAULT_MODEL)}
</span>
</div>
{agentInfo.currentPhase && (
<div
className={cn(
"px-1.5 py-0.5 rounded text-[10px] font-medium",
agentInfo.currentPhase === "planning" &&
"bg-blue-500/20 text-blue-400",
agentInfo.currentPhase === "action" &&
"bg-amber-500/20 text-amber-400",
agentInfo.currentPhase === "verification" &&
"bg-green-500/20 text-green-400"
)}
>
{agentInfo.currentPhase}
</div>
)}
</div>
{/* Progress Indicator */}
{(isCurrentAutoTask || feature.status === "in_progress") && (
<div className="space-y-1">
<div className="w-full h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="w-full h-full bg-primary transition-transform duration-500 ease-out origin-left"
style={{
transform: `translateX(${
agentInfo.progressPercentage - 100
}%)`,
}}
/>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<div className="flex items-center gap-2">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tools
</span>
{agentInfo.lastToolUsed && (
<span
className="text-zinc-500 truncate max-w-[80px]"
title={agentInfo.lastToolUsed}
>
{agentInfo.lastToolUsed}
</span>
)}
</div>
<span>{Math.round(agentInfo.progressPercentage)}%</span>
</div>
</div>
)}
{/* Task List Progress (if todos found) */}
{agentInfo.todos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<ListTodo className="w-3 h-3" />
<span>
{
agentInfo.todos.filter((t) => t.status === "completed")
.length
}
/{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 text-[10px]"
>
{todo.status === "completed" ? (
<CheckCircle2 className="w-2.5 h-2.5 text-green-500 shrink-0" />
) : todo.status === "in_progress" ? (
<Loader2 className="w-2.5 h-2.5 text-amber-400 animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-zinc-500 shrink-0" />
)}
<span
className={cn(
"truncate",
todo.status === "completed" &&
"text-zinc-500 line-through",
todo.status === "in_progress" && "text-amber-400",
todo.status === "pending" && "text-zinc-400"
)}
>
{todo.content}
</span>
</div>
))}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground pl-4">
+{agentInfo.todos.length - 3} more
</p>
)}
</div>
</div>
)}
{/* Summary for waiting_approval and verified - prioritize feature.summary from UpdateFeatureStatus */}
{(feature.status === "waiting_approval" ||
feature.status === "verified") && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1 pt-1 border-t border-white/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-[10px] text-green-400">
<Sparkles className="w-3 h-3" />
<span>Summary</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
className="p-0.5 rounded hover:bg-white/10 transition-colors text-zinc-500 hover:text-zinc-300"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-zinc-400 line-clamp-3">
{feature.summary || summary || agentInfo.summary}
</p>
</div>
)}
{/* Show tool count even without summary */}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-white/5">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-green-500" />
{
agentInfo.todos.filter(
(t) => t.status === "completed"
).length
}{" "}
tasks done
</span>
)}
</div>
)}
</>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
{isCurrentAutoTask && (
<>
{onViewOutput && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-purple-600 hover:bg-purple-700"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-output-${feature.id}`}
>
<Eye className="w-3 h-3 mr-1" />
View Output
</Button>
)}
{onForceStop && (
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onForceStop();
}}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3 mr-1" />
Stop
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
{/* skipTests features show manual verify button */}
{feature.skipTests && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-green-600 hover:bg-green-700"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : hasContext && onResume ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-blue-600 hover:bg-blue-700"
onClick={(e) => {
e.stopPropagation();
onResume();
}}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
Resume
</Button>
) : onVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-green-600 hover:bg-green-700"
onClick={(e) => {
e.stopPropagation();
onVerify();
}}
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Resume
</Button>
) : null}
{onViewOutput && !feature.skipTests && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-output-inprogress-${feature.id}`}
>
<Eye className="w-3 h-3 mr-1" />
Output
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDeleteClick}
data-testid={`delete-inprogress-feature-${feature.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
<>
{/* Move back button for skipTests verified features */}
{feature.skipTests && onMoveBackToInProgress && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-yellow-500 hover:text-yellow-500 hover:bg-yellow-500/10"
onClick={(e) => {
e.stopPropagation();
onMoveBackToInProgress();
}}
data-testid={`move-back-${feature.id}`}
>
<ArrowLeft className="w-3 h-3 mr-1" />
Back
</Button>
)}
<Button
variant="ghost"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDeleteClick}
data-testid={`delete-feature-${feature.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{/* Follow-up prompt button */}
{onFollowUp && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-blue-600 hover:bg-blue-700"
onClick={(e) => {
e.stopPropagation();
onFollowUp();
}}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1" />
Follow-up
</Button>
)}
{/* Commit and verify button */}
{onCommit && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-green-600 hover:bg-green-700"
onClick={(e) => {
e.stopPropagation();
onCommit();
}}
data-testid={`commit-${feature.id}`}
>
<GitCommit className="w-3 h-3 mr-1" />
Commit
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDeleteClick}
data-testid={`delete-waiting-feature-${feature.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
<>
<Button
variant="ghost"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDeleteClick}
data-testid={`delete-feature-${feature.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</>
)}
</div>
</CardContent>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent data-testid="delete-confirmation-dialog">
<DialogHeader>
<DialogTitle>Delete Feature</DialogTitle>
<DialogDescription>
Are you sure you want to delete this feature? This action cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={handleCancelDelete}
data-testid="cancel-delete-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
data-testid="confirm-delete-button"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Summary Modal */}
<Dialog open={isSummaryDialogOpen} onOpenChange={setIsSummaryDialogOpen}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
data-testid={`summary-dialog-${feature.id}`}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-green-400" />
Implementation Summary
</DialogTitle>
<DialogDescription className="text-sm" title={feature.description}>
{feature.description.length > 100
? `${feature.description.slice(0, 100)}...`
: feature.description}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-zinc-900/50 rounded-lg border border-white/10">
<Markdown>
{feature.summary ||
summary ||
agentInfo?.summary ||
"No summary available"}
</Markdown>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsSummaryDialogOpen(false)}
data-testid="close-summary-button"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}