"use client"; import { useState, useEffect, useMemo, memo } 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 { HotkeyButton } from "@/components/ui/hotkey-button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Feature, useAppStore, ThinkingLevel } from "@/store/app-store"; import { GripVertical, Edit, CheckCircle2, Circle, Loader2, Trash2, Eye, PlayCircle, RotateCcw, StopCircle, Hand, MessageSquare, GitCommit, Cpu, Wrench, ListTodo, Sparkles, Expand, FileText, MoreVertical, AlertCircle, GitBranch, Undo2, GitMerge, ChevronDown, ChevronUp, Brain, Flag, Wand2, Archive, } 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"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; /** * Formats thinking level for compact display */ function formatThinkingLevel(level: ThinkingLevel | undefined): string { if (!level || level === "none") return ""; const labels: Record = { none: "", low: "Low", medium: "Med", high: "High", ultrathink: "Ultra", }; return labels[level]; } /** * Formats priority for display */ function formatPriority(priority: number | undefined): string | null { if (!priority) return null; const labels: Record = { 1: "High", 2: "Medium", 3: "Low", }; return labels[priority] || null; } /** * Gets priority badge color classes */ function getPriorityBadgeClasses(priority: number | undefined): string { if (priority === 1) { return "bg-red-500/20 border border-red-500/50 text-red-400"; } else if (priority === 2) { return "bg-yellow-500/20 border border-yellow-500/50 text-yellow-400"; } else if (priority === 3) { return "bg-blue-500/20 border border-blue-500/50 text-blue-400"; } return ""; } interface KanbanCardProps { feature: Feature; onEdit: () => void; onDelete: () => void; onViewOutput?: () => void; onVerify?: () => void; onResume?: () => void; onForceStop?: () => void; onManualVerify?: () => void; onMoveBackToInProgress?: () => void; onFollowUp?: () => void; onCommit?: () => void; onRevert?: () => void; onMerge?: () => void; onImplement?: () => void; onComplete?: () => void; hasContext?: boolean; isCurrentAutoTask?: boolean; shortcutKey?: string; /** Context content for extracting progress info */ contextContent?: string; /** Feature summary from agent completion */ summary?: string; /** Opacity percentage (0-100) */ opacity?: number; /** Whether to use glassmorphism (backdrop-blur) effect */ glassmorphism?: boolean; /** Whether to show card borders */ cardBorderEnabled?: boolean; /** Card border opacity percentage (0-100) */ cardBorderOpacity?: number; } export const KanbanCard = memo(function KanbanCard({ feature, onEdit, onDelete, onViewOutput, onVerify, onResume, onForceStop, onManualVerify, onMoveBackToInProgress, onFollowUp, onCommit, onRevert, onMerge, onImplement, onComplete, hasContext, isCurrentAutoTask, shortcutKey, contextContent, summary, opacity = 100, glassmorphism = true, cardBorderEnabled = true, cardBorderOpacity = 100, }: KanbanCardProps) { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false); const [agentInfo, setAgentInfo] = useState(null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [currentTime, setCurrentTime] = useState(() => Date.now()); const { kanbanCardDetailLevel } = useAppStore(); // Check if feature has worktree const hasWorktree = !!feature.branchName; // Helper functions to check what should be shown based on detail level const showSteps = kanbanCardDetailLevel === "standard" || kanbanCardDetailLevel === "detailed"; const showAgentInfo = kanbanCardDetailLevel === "detailed"; // Helper to check if "just finished" badge should be shown (within 2 minutes) const isJustFinished = useMemo(() => { if ( !feature.justFinishedAt || feature.status !== "waiting_approval" || feature.error ) { return false; } const finishedTime = new Date(feature.justFinishedAt).getTime(); const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds return currentTime - finishedTime < twoMinutes; }, [feature.justFinishedAt, feature.status, feature.error, currentTime]); // Update current time periodically to check if badge should be hidden useEffect(() => { if (!feature.justFinishedAt || feature.status !== "waiting_approval") { return; } const finishedTime = new Date(feature.justFinishedAt).getTime(); const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds const timeRemaining = twoMinutes - (currentTime - finishedTime); if (timeRemaining <= 0) { // Already past 2 minutes return; } // Update time every second to check if 2 minutes have passed const interval = setInterval(() => { setCurrentTime(Date.now()); }, 1000); return () => clearInterval(interval); }, [feature.justFinishedAt, feature.status, currentTime]); // Calculate priority badge position const priorityLabel = formatPriority(feature.priority); const hasPriority = !!priorityLabel; // Calculate top position for badges (stacking vertically) const getBadgeTopPosition = (badgeIndex: number) => { return badgeIndex === 0 ? "top-2" : badgeIndex === 1 ? "top-8" : badgeIndex === 2 ? "top-14" : "top-20"; }; // Determine badge positions (must be after isJustFinished is defined) let badgeIndex = 0; const priorityBadgeIndex = hasPriority ? badgeIndex++ : -1; const skipTestsBadgeIndex = feature.skipTests && !feature.error ? badgeIndex++ : -1; const errorBadgeIndex = feature.error ? badgeIndex++ : -1; const justFinishedBadgeIndex = isJustFinished ? badgeIndex++ : -1; const branchBadgeIndex = hasWorktree && !isCurrentAutoTask ? badgeIndex++ : -1; // Total number of badges displayed const totalBadgeCount = badgeIndex; // 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; // Use features API to get agent output if (api.features) { const result = await api.features.getAgentOutput( currentProject.path, feature.id ); if (result.success && result.content) { const info = parseAgentContext(result.content); setAgentInfo(info); } } else { // Fallback to direct file read for backward compatibility const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.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 = () => { onDelete(); }; // Dragging logic: // - Backlog items can always be dragged // - skipTests items can be dragged even when in_progress or verified (unless currently running) // - waiting_approval items can always be dragged (to allow manual verification via drag) // - verified items can always be dragged (to allow moving back to waiting_approval or backlog) // - Non-skipTests (TDD) items in progress cannot be dragged (they are running) const isDraggable = feature.status === "backlog" || feature.status === "waiting_approval" || feature.status === "verified" || (feature.skipTests && !isCurrentAutoTask); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: feature.id, disabled: !isDraggable, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : undefined, }; // Calculate border style based on enabled state and opacity const borderStyle: React.CSSProperties = { ...style }; if (!cardBorderEnabled) { (borderStyle as Record).borderWidth = "0px"; (borderStyle as Record).borderColor = "transparent"; } else if (cardBorderOpacity !== 100) { // Apply border opacity using color-mix to blend the border color with transparent // The --border variable uses oklch format, so we use color-mix in oklch space // Ensure border width is set (1px is the default Tailwind border width) (borderStyle as Record).borderWidth = "1px"; ( borderStyle as Record ).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`; } const cardElement = ( {/* Background overlay with opacity - only affects background, not content */} {!isDragging && (
)} {/* Priority badge */} {hasPriority && (
{priorityLabel}
)} {/* Skip Tests indicator badge */} {feature.skipTests && !feature.error && (
Manual
)} {/* Error indicator badge */} {feature.error && (
Errored
)} {/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */} {isJustFinished && (
Fresh Baked
)} {/* Branch badge - show when feature has a worktree */} {hasWorktree && !isCurrentAutoTask && (
{feature.branchName?.replace("feature/", "")}

{feature.branchName}

)} = 4 && "pt-24" )} > {isCurrentAutoTask && (
{formatModelName(feature.model ?? DEFAULT_MODEL)} {feature.startedAt && ( )}
)} {!isCurrentAutoTask && feature.status === "backlog" && (
)} {!isCurrentAutoTask && (feature.status === "waiting_approval" || feature.status === "verified") && (
{onViewOutput && ( )}
)} {!isCurrentAutoTask && feature.status === "in_progress" && (
{ e.stopPropagation(); onEdit(); }} data-testid={`edit-feature-${feature.id}`} > Edit {onViewOutput && ( { e.stopPropagation(); onViewOutput(); }} data-testid={`view-logs-${feature.id}`} > Logs )} { e.stopPropagation(); handleDeleteClick(e as unknown as React.MouseEvent); }} data-testid={`delete-feature-${feature.id}`} > Delete
)}
{isDraggable && (
)}
{feature.description || feature.summary || feature.id} {/* Show More/Less toggle - only show when description is likely truncated */} {(feature.description || feature.summary || "").length > 100 && ( )} {feature.category}
{/* Steps Preview - Show in Standard and Detailed modes */} {showSteps && feature.steps && feature.steps.length > 0 && (
{feature.steps.slice(0, 3).map((step, index) => (
{feature.status === "verified" ? ( ) : ( )} {step}
))} {feature.steps.length > 3 && (

+{feature.steps.length - 3} more steps

)}
)} {/* Model/Preset Info for Backlog Cards - Show in Detailed mode */} {showAgentInfo && feature.status === "backlog" && (
{formatModelName(feature.model ?? DEFAULT_MODEL)}
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
{formatThinkingLevel(feature.thinkingLevel)}
)}
)} {/* Agent Info Panel - shows for in_progress, waiting_approval, verified */} {/* Detailed mode: Show all agent info */} {showAgentInfo && feature.status !== "backlog" && agentInfo && (
{/* Model & Phase */}
{formatModelName(feature.model ?? DEFAULT_MODEL)}
{agentInfo.currentPhase && (
{agentInfo.currentPhase}
)}
{/* Task List Progress (if todos found) */} {agentInfo.todos.length > 0 && (
{ agentInfo.todos.filter((t) => t.status === "completed") .length } /{agentInfo.todos.length} tasks
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
{todo.status === "completed" ? ( ) : todo.status === "in_progress" ? ( ) : ( )} {todo.content}
))} {agentInfo.todos.length > 3 && (

+{agentInfo.todos.length - 3} more

)}
)} {/* Summary for waiting_approval and verified - prioritize feature.summary from UpdateFeatureStatus */} {(feature.status === "waiting_approval" || feature.status === "verified") && ( <> {(feature.summary || summary || agentInfo.summary) && (
Summary

{feature.summary || summary || agentInfo.summary}

)} {/* Show tool count even without summary */} {!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
{agentInfo.toolCallCount} tool calls {agentInfo.todos.length > 0 && ( { agentInfo.todos.filter( (t) => t.status === "completed" ).length }{" "} tasks done )}
)} )}
)} {/* Actions */}
{isCurrentAutoTask && ( <> {onViewOutput && ( )} {onForceStop && ( )} )} {!isCurrentAutoTask && feature.status === "in_progress" && ( <> {/* skipTests features show manual verify button */} {feature.skipTests && onManualVerify ? ( ) : hasContext && onResume ? ( ) : onVerify ? ( ) : null} {onViewOutput && !feature.skipTests && ( )} )} {!isCurrentAutoTask && feature.status === "verified" && ( <> {/* Logs button - styled like Refine */} {onViewOutput && ( )} {/* Complete button */} {onComplete && ( )} )} {!isCurrentAutoTask && feature.status === "waiting_approval" && ( <> {/* Revert button - only show when worktree exists (icon only to save space) */} {hasWorktree && onRevert && (

Revert changes

)} {/* Refine prompt button */} {onFollowUp && ( )} {/* Merge button - only show when worktree exists */} {hasWorktree && onMerge && ( )} {/* Commit and verify button - show when no worktree */} {!hasWorktree && onCommit && ( )} )} {!isCurrentAutoTask && feature.status === "backlog" && ( <> {onImplement && ( )} )}
{/* Delete Confirmation Dialog */} {/* Summary Modal */} Implementation Summary {(() => { const displayText = feature.description || feature.summary || "No description"; return displayText.length > 100 ? `${displayText.slice(0, 100)}...` : displayText; })()}
{feature.summary || summary || agentInfo?.summary || "No summary available"}
{/* Revert Confirmation Dialog */} Revert Changes This will discard all changes made by the agent and move the feature back to the backlog. {feature.branchName && ( Branch{" "} {feature.branchName} {" "} will be deleted. )} This action cannot be undone. ); // Wrap with animated border when in progress if (isCurrentAutoTask) { return
{cardElement}
; } return cardElement; });