diff --git a/apps/ui/src/components/views/board-view/components/index.ts b/apps/ui/src/components/views/board-view/components/index.ts index 49cf06ef..ba0b54cc 100644 --- a/apps/ui/src/components/views/board-view/components/index.ts +++ b/apps/ui/src/components/views/board-view/components/index.ts @@ -1,2 +1,2 @@ -export { KanbanCard } from "./kanban-card"; +export { KanbanCard } from "./kanban-card/kanban-card"; export { KanbanColumn } from "./kanban-column"; diff --git a/apps/ui/src/components/views/board-view/components/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card.tsx deleted file mode 100644 index 7406201d..00000000 --- a/apps/ui/src/components/views/board-view/components/kanban-card.tsx +++ /dev/null @@ -1,1332 +0,0 @@ -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, - GitPullRequest, - ExternalLink, - ChevronDown, - ChevronUp, - Brain, - Wand2, - Archive, - Lock, -} from "lucide-react"; -import { CountUpTimer } from "@/components/ui/count-up-timer"; -import { getElectronAPI } from "@/lib/electron"; -import { getBlockingDependencies } from "@/lib/dependency-resolver"; -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]; -} - -interface KanbanCardProps { - feature: Feature; - onEdit: () => void; - onDelete: () => void; - onViewOutput?: () => void; - onVerify?: () => void; - onResume?: () => void; - onForceStop?: () => void; - onManualVerify?: () => void; - onMoveBackToInProgress?: () => void; - onFollowUp?: () => void; - onCommit?: () => void; - onImplement?: () => void; - onComplete?: () => void; - onViewPlan?: () => void; - onApprovePlan?: () => void; - hasContext?: boolean; - isCurrentAutoTask?: boolean; - shortcutKey?: string; - contextContent?: string; - summary?: string; - opacity?: number; - glassmorphism?: boolean; - cardBorderEnabled?: boolean; - cardBorderOpacity?: number; -} - -export const KanbanCard = memo(function KanbanCard({ - feature, - onEdit, - onDelete, - onViewOutput, - onVerify, - onResume, - onForceStop, - onManualVerify, - onMoveBackToInProgress, - onFollowUp, - onCommit, - onImplement, - onComplete, - onViewPlan, - onApprovePlan, - 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 [agentInfo, setAgentInfo] = useState(null); - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); - const [currentTime, setCurrentTime] = useState(() => Date.now()); - const { - kanbanCardDetailLevel, - enableDependencyBlocking, - features, - useWorktrees, - } = useAppStore(); - - // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies) - const blockingDependencies = useMemo(() => { - if (!enableDependencyBlocking || feature.status !== "backlog") { - return []; - } - return getBlockingDependencies(feature, features); - }, [enableDependencyBlocking, feature, features]); - - const showSteps = - kanbanCardDetailLevel === "standard" || - kanbanCardDetailLevel === "detailed"; - const showAgentInfo = kanbanCardDetailLevel === "detailed"; - - 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; - return currentTime - finishedTime < twoMinutes; - }, [feature.justFinishedAt, feature.status, feature.error, currentTime]); - - useEffect(() => { - if (!feature.justFinishedAt || feature.status !== "waiting_approval") { - return; - } - - const finishedTime = new Date(feature.justFinishedAt).getTime(); - const twoMinutes = 2 * 60 * 1000; - const timeRemaining = twoMinutes - (currentTime - finishedTime); - - if (timeRemaining <= 0) { - return; - } - - const interval = setInterval(() => { - setCurrentTime(Date.now()); - }, 1000); - - return () => clearInterval(interval); - }, [feature.justFinishedAt, feature.status, currentTime]); - - useEffect(() => { - const loadContext = async () => { - if (contextContent) { - const info = parseAgentContext(contextContent); - setAgentInfo(info); - return; - } - - 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; - - 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 { - 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 { - console.debug("[KanbanCard] No context file for feature:", feature.id); - } - }; - - loadContext(); - - 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(); - }; - - const isDraggable = - feature.status === "backlog" || - feature.status === "waiting_approval" || - feature.status === "verified" || - (feature.status === "in_progress" && !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, - }; - - const borderStyle: React.CSSProperties = { ...style }; - if (!cardBorderEnabled) { - (borderStyle as Record).borderWidth = "0px"; - (borderStyle as Record).borderColor = "transparent"; - } else if (cardBorderOpacity !== 100) { - (borderStyle as Record).borderWidth = "1px"; - (borderStyle as Record).borderColor = - `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`; - } - - const cardElement = ( - - {/* Background overlay with opacity */} - {!isDragging && ( -
- )} - - {/* Compact Badge Row */} - {(feature.error || - (blockingDependencies.length > 0 && - !feature.error && - !feature.skipTests && - feature.status === "backlog") || - isJustFinished) && ( -
- {/* Error badge */} - {feature.error && ( - - - -
- -
-
- -

{feature.error}

-
-
-
- )} - - {/* Blocked badge */} - {blockingDependencies.length > 0 && - !feature.error && - !feature.skipTests && - feature.status === "backlog" && ( - - - -
- -
-
- -

- Blocked by {blockingDependencies.length} incomplete{" "} - {blockingDependencies.length === 1 - ? "dependency" - : "dependencies"} -

-

- {blockingDependencies - .map((depId) => { - const dep = features.find((f) => f.id === depId); - return dep?.description || depId; - }) - .join(", ")} -

-
-
-
- )} - - {/* Just Finished badge */} - {isJustFinished && ( -
- -
- )} -
- )} - - {/* Category row */} -
- - {feature.category} - -
- - - {/* Priority and Manual Verification badges - top left, aligned with delete button */} - {(feature.priority || - (feature.skipTests && - !feature.error && - feature.status === "backlog")) && ( -
- {/* Priority badge */} - {feature.priority && ( - - - -
- {feature.priority === 1 - ? "H" - : feature.priority === 2 - ? "M" - : "L"} -
-
- -

- {feature.priority === 1 - ? "High Priority" - : feature.priority === 2 - ? "Medium Priority" - : "Low Priority"} -

-
-
-
- )} - {/* Manual verification badge */} - {feature.skipTests && - !feature.error && - feature.status === "backlog" && ( - - - -
- -
-
- -

Manual verification required

-
-
-
- )} -
- )} - {isCurrentAutoTask && ( -
-
- - {feature.startedAt && ( - - )} -
- - - - - - { - e.stopPropagation(); - onEdit(); - }} - data-testid={`edit-running-${feature.id}`} - className="text-xs" - > - - Edit - - {/* Model info in dropdown */} -
-
- - - {formatModelName(feature.model ?? DEFAULT_MODEL)} - -
-
-
-
-
- )} - {!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}`} - className="text-xs" - > - - Edit - - {onViewOutput && ( - { - e.stopPropagation(); - onViewOutput(); - }} - data-testid={`view-logs-${feature.id}`} - className="text-xs" - > - - View Logs - - )} - {/* Model info in dropdown */} -
-
- - - {formatModelName(feature.model ?? DEFAULT_MODEL)} - -
-
-
-
-
- - )} -
- {isDraggable && ( -
- -
- )} -
- {feature.titleGenerating ? ( -
- - - Generating title... - -
- ) : feature.title ? ( - - {feature.title} - - ) : null} - - {feature.description || feature.summary || feature.id} - - {(feature.description || feature.summary || "").length > 100 && ( - - )} -
-
-
- - - {/* Target Branch Display */} - {useWorktrees && feature.branchName && ( -
- - - {feature.branchName} - -
- )} - - {/* PR URL Display */} - {typeof feature.prUrl === "string" && - /^https?:\/\//i.test(feature.prUrl) && - (() => { - const prNumber = feature.prUrl.split("/").pop(); - return ( - - ); - })()} - - {/* Steps Preview */} - {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 -

- )} -
- )} - - {/* Model/Preset Info for Backlog Cards */} - {showAgentInfo && feature.status === "backlog" && ( -
-
-
- - - {formatModelName(feature.model ?? DEFAULT_MODEL)} - -
- {feature.thinkingLevel && feature.thinkingLevel !== "none" && ( -
- - - {formatThinkingLevel(feature.thinkingLevel)} - -
- )} -
-
- )} - - {/* Agent Info Panel */} - {showAgentInfo && feature.status !== "backlog" && agentInfo && ( -
- {/* Model & Phase */} -
-
- - - {formatModelName(feature.model ?? DEFAULT_MODEL)} - -
- {agentInfo.currentPhase && ( -
- {agentInfo.currentPhase} -
- )} -
- - {/* Task List Progress */} - {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 */} - {(feature.status === "waiting_approval" || - feature.status === "verified") && ( - <> - {(feature.summary || summary || agentInfo.summary) && ( -
-
-
- - Summary -
- -
-

- {feature.summary || summary || agentInfo.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 && ( - <> - {/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */} - {feature.planSpec?.status === "generated" && onApprovePlan && ( - - )} - {onViewOutput && ( - - )} - {onForceStop && ( - - )} - - )} - {!isCurrentAutoTask && feature.status === "in_progress" && ( - <> - {/* Approve Plan button - shows when plan is generated and waiting for approval */} - {feature.planSpec?.status === "generated" && onApprovePlan && ( - - )} - {feature.skipTests && onManualVerify ? ( - - ) : hasContext && onResume ? ( - - ) : onVerify ? ( - - ) : null} - {onViewOutput && !feature.skipTests && ( - - )} - - )} - {!isCurrentAutoTask && feature.status === "verified" && ( - <> - {/* Logs button */} - {onViewOutput && ( - - )} - {/* Complete button */} - {onComplete && ( - - )} - - )} - {!isCurrentAutoTask && feature.status === "waiting_approval" && ( - <> - {/* Refine prompt button */} - {onFollowUp && ( - - )} - {/* Show Verify button if PR was created (changes are committed), otherwise show Commit button */} - {feature.prUrl && onManualVerify ? ( - - ) : onCommit ? ( - - ) : null} - - )} - {!isCurrentAutoTask && feature.status === "backlog" && ( - <> - - {feature.planSpec?.content && onViewPlan && ( - - )} - {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"} - -
- - - -
-
- - ); - - // Wrap with animated border when in progress - if (isCurrentAutoTask) { - return
{cardElement}
; - } - - return cardElement; -}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx new file mode 100644 index 00000000..5a8e083f --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -0,0 +1,283 @@ +import { useEffect, useState } from "react"; +import { Feature, ThinkingLevel, useAppStore } from "@/store/app-store"; +import { + AgentTaskInfo, + parseAgentContext, + formatModelName, + DEFAULT_MODEL, +} from "@/lib/agent-context-parser"; +import { cn } from "@/lib/utils"; +import { + Cpu, + Brain, + ListTodo, + Sparkles, + Expand, + CheckCircle2, + Circle, + Loader2, + Wrench, +} from "lucide-react"; +import { getElectronAPI } from "@/lib/electron"; +import { SummaryDialog } from "./summary-dialog"; + +/** + * 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]; +} + +interface AgentInfoPanelProps { + feature: Feature; + contextContent?: string; + summary?: string; + isCurrentAutoTask?: boolean; +} + +export function AgentInfoPanel({ + feature, + contextContent, + summary, + isCurrentAutoTask, +}: AgentInfoPanelProps) { + const { kanbanCardDetailLevel } = useAppStore(); + const [agentInfo, setAgentInfo] = useState(null); + const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); + + const showAgentInfo = kanbanCardDetailLevel === "detailed"; + + useEffect(() => { + const loadContext = async () => { + if (contextContent) { + const info = parseAgentContext(contextContent); + setAgentInfo(info); + return; + } + + if (feature.status === "backlog") { + setAgentInfo(null); + return; + } + + try { + const api = getElectronAPI(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-undef + const currentProject = (window as any).__currentProject; + if (!currentProject?.path) return; + + 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 { + 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 { + // eslint-disable-next-line no-undef + console.debug("[KanbanCard] No context file for feature:", feature.id); + } + }; + + loadContext(); + + if (isCurrentAutoTask) { + // eslint-disable-next-line no-undef + const interval = setInterval(loadContext, 3000); + return () => { + // eslint-disable-next-line no-undef + clearInterval(interval); + }; + } + }, [feature.id, feature.status, contextContent, isCurrentAutoTask]); + // Model/Preset Info for Backlog Cards + if (showAgentInfo && feature.status === "backlog") { + return ( +
+
+
+ + + {formatModelName(feature.model ?? DEFAULT_MODEL)} + +
+ {feature.thinkingLevel && feature.thinkingLevel !== "none" && ( +
+ + + {formatThinkingLevel(feature.thinkingLevel)} + +
+ )} +
+
+ ); + } + + // Agent Info Panel for non-backlog cards + if (showAgentInfo && feature.status !== "backlog" && agentInfo) { + return ( +
+ {/* Model & Phase */} +
+
+ + + {formatModelName(feature.model ?? DEFAULT_MODEL)} + +
+ {agentInfo.currentPhase && ( +
+ {agentInfo.currentPhase} +
+ )} +
+ + {/* Task List Progress */} + {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 */} + {(feature.status === "waiting_approval" || + feature.status === "verified") && ( + <> + {(feature.summary || summary || agentInfo.summary) && ( +
+
+
+ + Summary +
+ +
+

+ {feature.summary || summary || agentInfo.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 + + )} +
+ )} + + )} +
+ ); + } + + // Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet) + // This ensures the dialog can be opened from the expand button + return ( + <> + {showAgentInfo && ( + + )} + + ); +} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx new file mode 100644 index 00000000..24c0eb85 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -0,0 +1,337 @@ +import { Feature } from "@/store/app-store"; +import { Button } from "@/components/ui/button"; +import { + Edit, + PlayCircle, + RotateCcw, + StopCircle, + CheckCircle2, + FileText, + Eye, + Wand2, + Archive, +} from "lucide-react"; + +interface CardActionsProps { + feature: Feature; + isCurrentAutoTask: boolean; + hasContext?: boolean; + shortcutKey?: string; + onEdit: () => void; + onViewOutput?: () => void; + onVerify?: () => void; + onResume?: () => void; + onForceStop?: () => void; + onManualVerify?: () => void; + onFollowUp?: () => void; + onImplement?: () => void; + onComplete?: () => void; + onViewPlan?: () => void; + onApprovePlan?: () => void; +} + +export function CardActions({ + feature, + isCurrentAutoTask, + hasContext, + shortcutKey, + onEdit, + onViewOutput, + onVerify, + onResume, + onForceStop, + onManualVerify, + onFollowUp, + onImplement, + onComplete, + onViewPlan, + onApprovePlan, +}: CardActionsProps) { + return ( +
+ {isCurrentAutoTask && ( + <> + {/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */} + {feature.planSpec?.status === "generated" && onApprovePlan && ( + + )} + {onViewOutput && ( + + )} + {onForceStop && ( + + )} + + )} + {!isCurrentAutoTask && feature.status === "in_progress" && ( + <> + {/* Approve Plan button - shows when plan is generated and waiting for approval */} + {feature.planSpec?.status === "generated" && onApprovePlan && ( + + )} + {feature.skipTests && onManualVerify ? ( + + ) : hasContext && onResume ? ( + + ) : onVerify ? ( + + ) : null} + {onViewOutput && !feature.skipTests && ( + + )} + + )} + {!isCurrentAutoTask && feature.status === "verified" && ( + <> + {/* Logs button */} + {onViewOutput && ( + + )} + {/* Complete button */} + {onComplete && ( + + )} + + )} + {!isCurrentAutoTask && feature.status === "waiting_approval" && ( + <> + {/* Refine prompt button */} + {onFollowUp && ( + + )} + {/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */} + {feature.prUrl && onManualVerify ? ( + + ) : onManualVerify ? ( + + ) : null} + + )} + {!isCurrentAutoTask && feature.status === "backlog" && ( + <> + + {feature.planSpec?.content && onViewPlan && ( + + )} + {onImplement && ( + + )} + + )} +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx new file mode 100644 index 00000000..fdfa4cf6 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx @@ -0,0 +1,266 @@ +import { useEffect, useMemo, useState } from "react"; +import { Feature, useAppStore } from "@/store/app-store"; +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { AlertCircle, Lock, Hand, Sparkles } from "lucide-react"; +import { getBlockingDependencies } from "@/lib/dependency-resolver"; + +interface CardBadgeProps { + children: React.ReactNode; + className?: string; + "data-testid"?: string; + title?: string; +} + +/** + * Shared badge component matching the "Just Finished" badge style + * Used for priority badges and other card badges + */ +function CardBadge({ + children, + className, + "data-testid": dataTestId, + title, +}: CardBadgeProps) { + return ( +
+ {children} +
+ ); +} + +interface CardBadgesProps { + feature: Feature; +} + +export function CardBadges({ feature }: CardBadgesProps) { + const { enableDependencyBlocking, features } = useAppStore(); + + // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies) + const blockingDependencies = useMemo(() => { + if (!enableDependencyBlocking || feature.status !== "backlog") { + return []; + } + return getBlockingDependencies(feature, features); + }, [enableDependencyBlocking, feature, features]); + + // Status badges row (error, blocked) + const showStatusBadges = + feature.error || + (blockingDependencies.length > 0 && + !feature.error && + !feature.skipTests && + feature.status === "backlog"); + + if (!showStatusBadges) { + return null; + } + + return ( +
+ {/* Error badge */} + {feature.error && ( + + + +
+ +
+
+ +

{feature.error}

+
+
+
+ )} + + {/* Blocked badge */} + {blockingDependencies.length > 0 && + !feature.error && + !feature.skipTests && + feature.status === "backlog" && ( + + + +
+ +
+
+ +

+ Blocked by {blockingDependencies.length} incomplete{" "} + {blockingDependencies.length === 1 + ? "dependency" + : "dependencies"} +

+

+ {blockingDependencies + .map((depId) => { + const dep = features.find((f) => f.id === depId); + return dep?.description || depId; + }) + .join(", ")} +

+
+
+
+ )} +
+ ); +} + +interface PriorityBadgesProps { + feature: Feature; +} + +export function PriorityBadges({ feature }: PriorityBadgesProps) { + const [currentTime, setCurrentTime] = useState(() => Date.now()); + + 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; + return currentTime - finishedTime < twoMinutes; + }, [feature.justFinishedAt, feature.status, feature.error, currentTime]); + + useEffect(() => { + if (!feature.justFinishedAt || feature.status !== "waiting_approval") { + return; + } + + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const twoMinutes = 2 * 60 * 1000; + const timeRemaining = twoMinutes - (currentTime - finishedTime); + + if (timeRemaining <= 0) { + return; + } + + // eslint-disable-next-line no-undef + const interval = setInterval(() => { + setCurrentTime(Date.now()); + }, 1000); + + return () => { + // eslint-disable-next-line no-undef + clearInterval(interval); + }; + }, [feature.justFinishedAt, feature.status, currentTime]); + + const showPriorityBadges = + feature.priority || + (feature.skipTests && !feature.error && feature.status === "backlog") || + isJustFinished; + + if (!showPriorityBadges) { + return null; + } + + return ( +
+ {/* Priority badge */} + {feature.priority && ( + + + + + {feature.priority === 1 ? ( + + H + + ) : feature.priority === 2 ? ( + + M + + ) : ( + + L + + )} + + + +

+ {feature.priority === 1 + ? "High Priority" + : feature.priority === 2 + ? "Medium Priority" + : "Low Priority"} +

+
+
+
+ )} + {/* Manual verification badge */} + {feature.skipTests && !feature.error && feature.status === "backlog" && ( + + + + + + + + +

Manual verification required

+
+
+
+ )} + + {/* Just Finished badge */} + {isJustFinished && ( + + + + )} +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx new file mode 100644 index 00000000..07ad0552 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx @@ -0,0 +1,82 @@ +import { Feature } from "@/store/app-store"; +import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from "lucide-react"; + +interface CardContentSectionsProps { + feature: Feature; + useWorktrees: boolean; + showSteps: boolean; +} + +export function CardContentSections({ + feature, + useWorktrees, + showSteps, +}: CardContentSectionsProps) { + return ( + <> + {/* Target Branch Display */} + {useWorktrees && feature.branchName && ( +
+ + + {feature.branchName} + +
+ )} + + {/* PR URL Display */} + {typeof feature.prUrl === "string" && + /^https?:\/\//i.test(feature.prUrl) && + (() => { + const prNumber = feature.prUrl.split("/").pop(); + return ( + + ); + })()} + + {/* Steps Preview */} + {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 +

+ )} +
+ )} + + ); +} + diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx new file mode 100644 index 00000000..1d3bc41d --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -0,0 +1,330 @@ +import { useState } from "react"; +import { Feature } from "@/store/app-store"; +import { cn } from "@/lib/utils"; +import { + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + GripVertical, + Edit, + Loader2, + Trash2, + FileText, + MoreVertical, + ChevronDown, + ChevronUp, + Cpu, +} from "lucide-react"; +import { CountUpTimer } from "@/components/ui/count-up-timer"; +import { formatModelName, DEFAULT_MODEL } from "@/lib/agent-context-parser"; +import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; + +interface CardHeaderProps { + feature: Feature; + isDraggable: boolean; + isCurrentAutoTask: boolean; + onEdit: () => void; + onDelete: () => void; + onViewOutput?: () => void; +} + +export function CardHeaderSection({ + feature, + isDraggable, + isCurrentAutoTask, + onEdit, + onDelete, + onViewOutput, +}: CardHeaderProps) { + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsDeleteDialogOpen(true); + }; + + const handleConfirmDelete = () => { + onDelete(); + }; + + return ( + + {/* Running task header */} + {isCurrentAutoTask && ( +
+
+ + {feature.startedAt && ( + + )} +
+ + + + + + { + e.stopPropagation(); + onEdit(); + }} + data-testid={`edit-running-${feature.id}`} + className="text-xs" + > + + Edit + + {/* Model info in dropdown */} +
+
+ + + {formatModelName(feature.model ?? DEFAULT_MODEL)} + +
+
+
+
+
+ )} + + {/* Backlog header */} + {!isCurrentAutoTask && feature.status === "backlog" && ( +
+ +
+ )} + + {/* Waiting approval / Verified header */} + {!isCurrentAutoTask && + (feature.status === "waiting_approval" || + feature.status === "verified") && ( + <> +
+ + {onViewOutput && ( + + )} + +
+ + )} + + {/* In progress header */} + {!isCurrentAutoTask && feature.status === "in_progress" && ( + <> +
+ + + + + + + { + e.stopPropagation(); + onEdit(); + }} + data-testid={`edit-feature-${feature.id}`} + className="text-xs" + > + + Edit + + {onViewOutput && ( + { + e.stopPropagation(); + onViewOutput(); + }} + data-testid={`view-logs-${feature.id}`} + className="text-xs" + > + + View Logs + + )} + {/* Model info in dropdown */} +
+
+ + + {formatModelName(feature.model ?? DEFAULT_MODEL)} + +
+
+
+
+
+ + )} + + {/* Title and description */} +
+ {isDraggable && ( +
+ +
+ )} +
+ {feature.titleGenerating ? ( +
+ + + Generating title... + +
+ ) : feature.title ? ( + + {feature.title} + + ) : null} + + {feature.description || feature.summary || feature.id} + + {(feature.description || feature.summary || "").length > 100 && ( + + )} +
+
+ + {/* Delete Confirmation Dialog */} + +
+ ); +} + diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx new file mode 100644 index 00000000..84ab2c76 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -0,0 +1,214 @@ +import React, { memo } from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cn } from "@/lib/utils"; +import { Card, CardContent } from "@/components/ui/card"; +import { Feature, useAppStore } from "@/store/app-store"; +import { CardBadges, PriorityBadges } from "./card-badges"; +import { CardHeaderSection } from "./card-header"; +import { CardContentSections } from "./card-content-sections"; +import { AgentInfoPanel } from "./agent-info-panel"; +import { CardActions } from "./card-actions"; + +interface KanbanCardProps { + feature: Feature; + onEdit: () => void; + onDelete: () => void; + onViewOutput?: () => void; + onVerify?: () => void; + onResume?: () => void; + onForceStop?: () => void; + onManualVerify?: () => void; + onMoveBackToInProgress?: () => void; + onFollowUp?: () => void; + onImplement?: () => void; + onComplete?: () => void; + onViewPlan?: () => void; + onApprovePlan?: () => void; + hasContext?: boolean; + isCurrentAutoTask?: boolean; + shortcutKey?: string; + contextContent?: string; + summary?: string; + opacity?: number; + glassmorphism?: boolean; + cardBorderEnabled?: boolean; + cardBorderOpacity?: number; +} + +export const KanbanCard = memo(function KanbanCard({ + feature, + onEdit, + onDelete, + onViewOutput, + onVerify, + onResume, + onForceStop, + onManualVerify, + onMoveBackToInProgress: _onMoveBackToInProgress, + onFollowUp, + onImplement, + onComplete, + onViewPlan, + onApprovePlan, + hasContext, + isCurrentAutoTask, + shortcutKey, + contextContent, + summary, + opacity = 100, + glassmorphism = true, + cardBorderEnabled = true, + cardBorderOpacity = 100, +}: KanbanCardProps) { + const { kanbanCardDetailLevel, useWorktrees } = useAppStore(); + + const showSteps = + kanbanCardDetailLevel === "standard" || + kanbanCardDetailLevel === "detailed"; + + const isDraggable = + feature.status === "backlog" || + feature.status === "waiting_approval" || + feature.status === "verified" || + (feature.status === "in_progress" && !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, + }; + + const borderStyle: React.CSSProperties = { ...style }; + if (!cardBorderEnabled) { + (borderStyle as Record).borderWidth = "0px"; + (borderStyle as Record).borderColor = "transparent"; + } else if (cardBorderOpacity !== 100) { + (borderStyle as Record).borderWidth = "1px"; + (borderStyle as Record).borderColor = + `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`; + } + + const cardElement = ( + + {/* Background overlay with opacity */} + {!isDragging && ( +
+ )} + + {/* Status Badges Row */} + + + {/* Category row */} +
+ + {feature.category} + +
+ + {/* Priority and Manual Verification badges */} + + + {/* Card Header */} + + + + {/* Content Sections */} + + + {/* Agent Info Panel */} + + + {/* Actions */} + + + + ); + + // Wrap with animated border when in progress + if (isCurrentAutoTask) { + return
{cardElement}
; + } + + return cardElement; +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx new file mode 100644 index 00000000..08a0dfc8 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx @@ -0,0 +1,75 @@ +import { Feature } from "@/store/app-store"; +import { AgentTaskInfo } from "@/lib/agent-context-parser"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Markdown } from "@/components/ui/markdown"; +import { Sparkles } from "lucide-react"; + +interface SummaryDialogProps { + feature: Feature; + agentInfo: AgentTaskInfo | null; + summary?: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SummaryDialog({ + feature, + agentInfo, + summary, + isOpen, + onOpenChange, +}: SummaryDialogProps) { + return ( + + + + + + 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"} + +
+ + + +
+
+ ); +} + diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 1ebbf042..1dddffe3 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -194,7 +194,6 @@ export function KanbanBoard({ onMoveBackToInProgress(feature) } onFollowUp={() => onFollowUp(feature)} - onCommit={() => onCommit(feature)} onComplete={() => onComplete(feature)} onImplement={() => onImplement(feature)} onViewPlan={() => onViewPlan(feature)} diff --git a/apps/ui/tests/worktree-integration.spec.ts b/apps/ui/tests/worktree-integration.spec.ts index f2a808c2..c3db89c9 100644 --- a/apps/ui/tests/worktree-integration.spec.ts +++ b/apps/ui/tests/worktree-integration.spec.ts @@ -2857,7 +2857,7 @@ test.describe("Worktree Integration Tests", () => { await expect(commitButton).not.toBeVisible({ timeout: 2000 }); }); - test("feature in waiting_approval without prUrl should show Commit button", async ({ + test("feature in waiting_approval without prUrl should show Mark as Verified button", async ({ page, }) => { await setupProjectWithPath(page, testRepo.path); @@ -2867,7 +2867,7 @@ test.describe("Worktree Integration Tests", () => { // Create a feature await clickAddFeature(page); - await fillAddFeatureDialog(page, "Feature without PR for commit test", { + await fillAddFeatureDialog(page, "Feature without PR for mark as verified test", { category: "Testing", }); await confirmAddFeature(page); @@ -2880,7 +2880,7 @@ test.describe("Worktree Integration Tests", () => { const featureFilePath = path.join(featuresDir, dir, "feature.json"); if (fs.existsSync(featureFilePath)) { const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); - return data.description === "Feature without PR for commit test"; + return data.description === "Feature without PR for mark as verified test"; } return false; }); @@ -2908,9 +2908,9 @@ test.describe("Worktree Integration Tests", () => { ); await expect(featureCard).toBeVisible({ timeout: 5000 }); - // Verify the Commit button is visible - const commitButton = page.locator(`[data-testid="commit-${featureData.id}"]`); - await expect(commitButton).toBeVisible({ timeout: 5000 }); + // Verify the Mark as Verified button is visible + const markAsVerifiedButton = page.locator(`[data-testid="mark-as-verified-${featureData.id}"]`); + await expect(markAsVerifiedButton).toBeVisible({ timeout: 5000 }); // Verify the Verify button is NOT visible const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`);