mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
Merge pull request #194 from AutoMaker-Org/refactor-kanban-cards
Refactor kanban cards
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
export { KanbanCard } from "./kanban-card";
|
export { KanbanCard } from "./kanban-card/kanban-card";
|
||||||
export { KanbanColumn } from "./kanban-column";
|
export { KanbanColumn } from "./kanban-column";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<ThinkingLevel, string> = {
|
||||||
|
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<AgentTaskInfo | null>(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 (
|
||||||
|
<div className="mb-3 space-y-2 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||||
|
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||||
|
<Cpu className="w-3 h-3" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
|
||||||
|
<div className="flex items-center gap-1 text-purple-400">
|
||||||
|
<Brain className="w-3 h-3" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatThinkingLevel(feature.thinkingLevel)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent Info Panel for non-backlog cards
|
||||||
|
if (showAgentInfo && feature.status !== "backlog" && agentInfo) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3 space-y-2 overflow-hidden">
|
||||||
|
{/* Model & Phase */}
|
||||||
|
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||||
|
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||||
|
<Cpu className="w-3 h-3" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{agentInfo.currentPhase && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded-md text-[10px] font-medium",
|
||||||
|
agentInfo.currentPhase === "planning" &&
|
||||||
|
"bg-[var(--status-info-bg)] text-[var(--status-info)]",
|
||||||
|
agentInfo.currentPhase === "action" &&
|
||||||
|
"bg-[var(--status-warning-bg)] text-[var(--status-warning)]",
|
||||||
|
agentInfo.currentPhase === "verification" &&
|
||||||
|
"bg-[var(--status-success-bg)] text-[var(--status-success)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{agentInfo.currentPhase}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task List Progress */}
|
||||||
|
{agentInfo.todos.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||||
|
<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-[var(--status-success)] shrink-0" />
|
||||||
|
) : todo.status === "in_progress" ? (
|
||||||
|
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"break-words hyphens-auto line-clamp-2 leading-relaxed",
|
||||||
|
todo.status === "completed" &&
|
||||||
|
"text-muted-foreground/60 line-through",
|
||||||
|
todo.status === "in_progress" &&
|
||||||
|
"text-[var(--status-warning)]",
|
||||||
|
todo.status === "pending" && "text-muted-foreground/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{todo.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{agentInfo.todos.length > 3 && (
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
||||||
|
+{agentInfo.todos.length - 3} more
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary for waiting_approval and verified */}
|
||||||
|
{(feature.status === "waiting_approval" ||
|
||||||
|
feature.status === "verified") && (
|
||||||
|
<>
|
||||||
|
{(feature.summary || summary || agentInfo.summary) && (
|
||||||
|
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
|
||||||
|
<Sparkles className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="truncate font-medium">Summary</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsSummaryDialogOpen(true);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
|
||||||
|
title="View full summary"
|
||||||
|
data-testid={`expand-summary-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Expand className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
|
||||||
|
{feature.summary || summary || agentInfo.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!feature.summary &&
|
||||||
|
!summary &&
|
||||||
|
!agentInfo.summary &&
|
||||||
|
agentInfo.toolCallCount > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
||||||
|
<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-[var(--status-success)]" />
|
||||||
|
{
|
||||||
|
agentInfo.todos.filter((t) => t.status === "completed")
|
||||||
|
.length
|
||||||
|
}{" "}
|
||||||
|
tasks done
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 && (
|
||||||
|
<SummaryDialog
|
||||||
|
feature={feature}
|
||||||
|
agentInfo={agentInfo}
|
||||||
|
summary={summary}
|
||||||
|
isOpen={isSummaryDialogOpen}
|
||||||
|
onOpenChange={setIsSummaryDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
|
||||||
|
{isCurrentAutoTask && (
|
||||||
|
<>
|
||||||
|
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
|
||||||
|
{feature.planSpec?.status === "generated" && onApprovePlan && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApprovePlan();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`approve-plan-running-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||||
|
<span className="truncate">Approve Plan</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onViewOutput && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-output-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||||
|
<span className="truncate">Logs</span>
|
||||||
|
{shortcutKey && (
|
||||||
|
<span
|
||||||
|
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||||
|
data-testid={`shortcut-key-${feature.id}`}
|
||||||
|
>
|
||||||
|
{shortcutKey}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onForceStop && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px] px-2 shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onForceStop();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`force-stop-${feature.id}`}
|
||||||
|
>
|
||||||
|
<StopCircle className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||||
|
<>
|
||||||
|
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||||
|
{feature.planSpec?.status === "generated" && onApprovePlan && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApprovePlan();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`approve-plan-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-1" />
|
||||||
|
Approve Plan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{feature.skipTests && onManualVerify ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onManualVerify();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
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-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onResume();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
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-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onVerify();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`verify-feature-${feature.id}`}
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-3 h-3 mr-1" />
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onViewOutput && !feature.skipTests && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px] px-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-output-inprogress-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrentAutoTask && feature.status === "verified" && (
|
||||||
|
<>
|
||||||
|
{/* Logs button */}
|
||||||
|
{onViewOutput && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-xs min-w-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-output-verified-${feature.id}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
||||||
|
<>
|
||||||
|
{/* Refine prompt button */}
|
||||||
|
{onFollowUp && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px] min-w-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onFollowUp();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`follow-up-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Wand2 className="w-3 h-3 mr-1 shrink-0" />
|
||||||
|
<span className="truncate">Refine</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
|
||||||
|
{feature.prUrl && onManualVerify ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onManualVerify();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`verify-${feature.id}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
) : onManualVerify ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onManualVerify();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`mark-as-verified-${feature.id}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Mark as Verified
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!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>
|
||||||
|
{feature.planSpec?.content && onViewPlan && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs px-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewPlan();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-plan-${feature.id}`}
|
||||||
|
title="View Plan"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid={dataTestId}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
|
||||||
|
{/* Error badge */}
|
||||||
|
{feature.error && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
|
||||||
|
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]"
|
||||||
|
)}
|
||||||
|
data-testid={`error-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||||
|
<p>{feature.error}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Blocked badge */}
|
||||||
|
{blockingDependencies.length > 0 &&
|
||||||
|
!feature.error &&
|
||||||
|
!feature.skipTests &&
|
||||||
|
feature.status === "backlog" && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold",
|
||||||
|
"bg-orange-500/20 border-orange-500/50 text-orange-500"
|
||||||
|
)}
|
||||||
|
data-testid={`blocked-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Lock className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||||
|
<p className="font-medium mb-1">
|
||||||
|
Blocked by {blockingDependencies.length} incomplete{" "}
|
||||||
|
{blockingDependencies.length === 1
|
||||||
|
? "dependency"
|
||||||
|
: "dependencies"}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{blockingDependencies
|
||||||
|
.map((depId) => {
|
||||||
|
const dep = features.find((f) => f.id === depId);
|
||||||
|
return dep?.description || depId;
|
||||||
|
})
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||||
|
{/* Priority badge */}
|
||||||
|
{feature.priority && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<CardBadge
|
||||||
|
className={cn(
|
||||||
|
"bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5", // badge style from example
|
||||||
|
feature.priority === 1 &&
|
||||||
|
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]",
|
||||||
|
feature.priority === 2 &&
|
||||||
|
"bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]",
|
||||||
|
feature.priority === 3 &&
|
||||||
|
"bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]"
|
||||||
|
)}
|
||||||
|
data-testid={`priority-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
{feature.priority === 1 ? (
|
||||||
|
<span className="font-bold text-xs flex items-center gap-0.5">
|
||||||
|
H
|
||||||
|
</span>
|
||||||
|
) : feature.priority === 2 ? (
|
||||||
|
<span className="font-bold text-xs flex items-center gap-0.5">
|
||||||
|
M
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold text-xs flex items-center gap-0.5">
|
||||||
|
L
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardBadge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
<p>
|
||||||
|
{feature.priority === 1
|
||||||
|
? "High Priority"
|
||||||
|
: feature.priority === 2
|
||||||
|
? "Medium Priority"
|
||||||
|
: "Low Priority"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{/* Manual verification badge */}
|
||||||
|
{feature.skipTests && !feature.error && feature.status === "backlog" && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<CardBadge
|
||||||
|
className="bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]"
|
||||||
|
data-testid={`skip-tests-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Hand className="w-3 h-3" />
|
||||||
|
</CardBadge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
<p>Manual verification required</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Just Finished badge */}
|
||||||
|
{isJustFinished && (
|
||||||
|
<CardBadge
|
||||||
|
className="bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
|
||||||
|
data-testid={`just-finished-badge-${feature.id}`}
|
||||||
|
title="Agent just finished working on this feature"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
</CardBadge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
|
<GitBranch className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="font-mono truncate" title={feature.branchName}>
|
||||||
|
{feature.branchName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PR URL Display */}
|
||||||
|
{typeof feature.prUrl === "string" &&
|
||||||
|
/^https?:\/\//i.test(feature.prUrl) &&
|
||||||
|
(() => {
|
||||||
|
const prNumber = feature.prUrl.split("/").pop();
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
<a
|
||||||
|
href={feature.prUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] text-purple-500 hover:text-purple-400 transition-colors"
|
||||||
|
title={feature.prUrl}
|
||||||
|
data-testid={`pr-url-${feature.id}`}
|
||||||
|
>
|
||||||
|
<GitPullRequest className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="truncate max-w-[150px]">
|
||||||
|
{prNumber ? `Pull Request #${prNumber}` : "Pull Request"}
|
||||||
|
</span>
|
||||||
|
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Steps Preview */}
|
||||||
|
{showSteps && feature.steps && feature.steps.length > 0 && (
|
||||||
|
<div className="mb-3 space-y-1.5">
|
||||||
|
{feature.steps.slice(0, 3).map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
|
||||||
|
>
|
||||||
|
{feature.status === "verified" ? (
|
||||||
|
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{feature.steps.length > 3 && (
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 pl-5">
|
||||||
|
+{feature.steps.length - 3} more
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 (
|
||||||
|
<CardHeader className="p-3 pb-2 block">
|
||||||
|
{/* Running task header */}
|
||||||
|
{isCurrentAutoTask && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||||
|
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
|
||||||
|
{feature.startedAt && (
|
||||||
|
<CountUpTimer
|
||||||
|
startedAt={feature.startedAt}
|
||||||
|
className="text-[var(--status-in-progress)] text-[10px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`menu-running-${feature.id}`}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
data-testid={`edit-running-${feature.id}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Edit className="w-3 h-3 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* Model info in dropdown */}
|
||||||
|
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Cpu className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backlog header */}
|
||||||
|
{!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={handleDeleteClick}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`delete-backlog-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waiting approval / Verified header */}
|
||||||
|
{!isCurrentAutoTask &&
|
||||||
|
(feature.status === "waiting_approval" ||
|
||||||
|
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-${
|
||||||
|
feature.status === "waiting_approval"
|
||||||
|
? "waiting"
|
||||||
|
: "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-${
|
||||||
|
feature.status === "waiting_approval"
|
||||||
|
? "waiting"
|
||||||
|
: "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={handleDeleteClick}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`delete-${
|
||||||
|
feature.status === "waiting_approval"
|
||||||
|
? "waiting"
|
||||||
|
: "verified"
|
||||||
|
}-${feature.id}`}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In progress header */}
|
||||||
|
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||||
|
<>
|
||||||
|
<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-destructive"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`delete-feature-${feature.id}`}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`menu-${feature.id}`}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
data-testid={`edit-feature-${feature.id}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Edit className="w-3 h-3 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{onViewOutput && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
data-testid={`view-logs-${feature.id}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-2" />
|
||||||
|
View Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{/* Model info in dropdown */}
|
||||||
|
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Cpu className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title and description */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{isDraggable && (
|
||||||
|
<div
|
||||||
|
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
|
||||||
|
data-testid={`drag-handle-${feature.id}`}
|
||||||
|
>
|
||||||
|
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
{feature.titleGenerating ? (
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground italic">
|
||||||
|
Generating title...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : feature.title ? (
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
|
||||||
|
{feature.title}
|
||||||
|
</CardTitle>
|
||||||
|
) : null}
|
||||||
|
<CardDescription
|
||||||
|
className={cn(
|
||||||
|
"text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground",
|
||||||
|
!isDescriptionExpanded && "line-clamp-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{feature.description || feature.summary || feature.id}
|
||||||
|
</CardDescription>
|
||||||
|
{(feature.description || feature.summary || "").length > 100 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDescriptionExpanded(!isDescriptionExpanded);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1.5 transition-colors"
|
||||||
|
data-testid={`toggle-description-${feature.id}`}
|
||||||
|
>
|
||||||
|
{isDescriptionExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-3 h-3" />
|
||||||
|
<span>Less</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
<span>More</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={setIsDeleteDialogOpen}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="Delete Feature"
|
||||||
|
description="Are you sure you want to delete this feature? This action cannot be undone."
|
||||||
|
testId="delete-confirmation-dialog"
|
||||||
|
confirmTestId="confirm-delete-button"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<string, string>).borderWidth = "0px";
|
||||||
|
(borderStyle as Record<string, string>).borderColor = "transparent";
|
||||||
|
} else if (cardBorderOpacity !== 100) {
|
||||||
|
(borderStyle as Record<string, string>).borderWidth = "1px";
|
||||||
|
(borderStyle as Record<string, string>).borderColor =
|
||||||
|
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardElement = (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={isCurrentAutoTask ? style : borderStyle}
|
||||||
|
className={cn(
|
||||||
|
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
|
||||||
|
"transition-all duration-200 ease-out",
|
||||||
|
// Premium shadow system
|
||||||
|
"shadow-sm hover:shadow-md hover:shadow-black/10",
|
||||||
|
// Subtle lift on hover
|
||||||
|
"hover:-translate-y-0.5",
|
||||||
|
!isCurrentAutoTask &&
|
||||||
|
cardBorderEnabled &&
|
||||||
|
cardBorderOpacity === 100 &&
|
||||||
|
"border-border/50",
|
||||||
|
!isCurrentAutoTask &&
|
||||||
|
cardBorderEnabled &&
|
||||||
|
cardBorderOpacity !== 100 &&
|
||||||
|
"border",
|
||||||
|
!isDragging && "bg-transparent",
|
||||||
|
!glassmorphism && "backdrop-blur-[0px]!",
|
||||||
|
isDragging && "scale-105 shadow-xl shadow-black/20 rotate-1",
|
||||||
|
// Error state - using CSS variable
|
||||||
|
feature.error &&
|
||||||
|
!isCurrentAutoTask &&
|
||||||
|
"border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg",
|
||||||
|
!isDraggable && "cursor-default"
|
||||||
|
)}
|
||||||
|
data-testid={`kanban-card-${feature.id}`}
|
||||||
|
onDoubleClick={onEdit}
|
||||||
|
{...attributes}
|
||||||
|
{...(isDraggable ? listeners : {})}
|
||||||
|
>
|
||||||
|
{/* Background overlay with opacity */}
|
||||||
|
{!isDragging && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-xl bg-card -z-10",
|
||||||
|
glassmorphism && "backdrop-blur-sm"
|
||||||
|
)}
|
||||||
|
style={{ opacity: opacity / 100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Badges Row */}
|
||||||
|
<CardBadges feature={feature} />
|
||||||
|
|
||||||
|
{/* Category row */}
|
||||||
|
<div className="px-3 pt-4">
|
||||||
|
<span className="text-[11px] text-muted-foreground/70 font-medium">
|
||||||
|
{feature.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority and Manual Verification badges */}
|
||||||
|
<PriorityBadges feature={feature} />
|
||||||
|
|
||||||
|
{/* Card Header */}
|
||||||
|
<CardHeaderSection
|
||||||
|
feature={feature}
|
||||||
|
isDraggable={isDraggable}
|
||||||
|
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onViewOutput={onViewOutput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardContent className="px-3 pt-0 pb-0">
|
||||||
|
{/* Content Sections */}
|
||||||
|
<CardContentSections
|
||||||
|
feature={feature}
|
||||||
|
useWorktrees={useWorktrees}
|
||||||
|
showSteps={showSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Agent Info Panel */}
|
||||||
|
<AgentInfoPanel
|
||||||
|
feature={feature}
|
||||||
|
contextContent={contextContent}
|
||||||
|
summary={summary}
|
||||||
|
isCurrentAutoTask={isCurrentAutoTask}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<CardActions
|
||||||
|
feature={feature}
|
||||||
|
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||||
|
hasContext={hasContext}
|
||||||
|
shortcutKey={shortcutKey}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onViewOutput={onViewOutput}
|
||||||
|
onVerify={onVerify}
|
||||||
|
onResume={onResume}
|
||||||
|
onForceStop={onForceStop}
|
||||||
|
onManualVerify={onManualVerify}
|
||||||
|
onFollowUp={onFollowUp}
|
||||||
|
onImplement={onImplement}
|
||||||
|
onComplete={onComplete}
|
||||||
|
onViewPlan={onViewPlan}
|
||||||
|
onApprovePlan={onApprovePlan}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap with animated border when in progress
|
||||||
|
if (isCurrentAutoTask) {
|
||||||
|
return <div className="animated-border-wrapper">{cardElement}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cardElement;
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<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-[var(--status-success)]" />
|
||||||
|
Implementation Summary
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription
|
||||||
|
className="text-sm"
|
||||||
|
title={feature.description || feature.summary || ""}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const displayText =
|
||||||
|
feature.description || feature.summary || "No description";
|
||||||
|
return displayText.length > 100
|
||||||
|
? `${displayText.slice(0, 100)}...`
|
||||||
|
: displayText;
|
||||||
|
})()}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
|
||||||
|
<Markdown>
|
||||||
|
{feature.summary ||
|
||||||
|
summary ||
|
||||||
|
agentInfo?.summary ||
|
||||||
|
"No summary available"}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
data-testid="close-summary-button"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,6 @@ export function KanbanBoard({
|
|||||||
onMoveBackToInProgress(feature)
|
onMoveBackToInProgress(feature)
|
||||||
}
|
}
|
||||||
onFollowUp={() => onFollowUp(feature)}
|
onFollowUp={() => onFollowUp(feature)}
|
||||||
onCommit={() => onCommit(feature)}
|
|
||||||
onComplete={() => onComplete(feature)}
|
onComplete={() => onComplete(feature)}
|
||||||
onImplement={() => onImplement(feature)}
|
onImplement={() => onImplement(feature)}
|
||||||
onViewPlan={() => onViewPlan(feature)}
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
|
|||||||
@@ -2857,7 +2857,7 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
await expect(commitButton).not.toBeVisible({ timeout: 2000 });
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await setupProjectWithPath(page, testRepo.path);
|
await setupProjectWithPath(page, testRepo.path);
|
||||||
@@ -2867,7 +2867,7 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
|
|
||||||
// Create a feature
|
// Create a feature
|
||||||
await clickAddFeature(page);
|
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",
|
category: "Testing",
|
||||||
});
|
});
|
||||||
await confirmAddFeature(page);
|
await confirmAddFeature(page);
|
||||||
@@ -2880,7 +2880,7 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
const featureFilePath = path.join(featuresDir, dir, "feature.json");
|
const featureFilePath = path.join(featuresDir, dir, "feature.json");
|
||||||
if (fs.existsSync(featureFilePath)) {
|
if (fs.existsSync(featureFilePath)) {
|
||||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
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;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -2908,9 +2908,9 @@ test.describe("Worktree Integration Tests", () => {
|
|||||||
);
|
);
|
||||||
await expect(featureCard).toBeVisible({ timeout: 5000 });
|
await expect(featureCard).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Verify the Commit button is visible
|
// Verify the Mark as Verified button is visible
|
||||||
const commitButton = page.locator(`[data-testid="commit-${featureData.id}"]`);
|
const markAsVerifiedButton = page.locator(`[data-testid="mark-as-verified-${featureData.id}"]`);
|
||||||
await expect(commitButton).toBeVisible({ timeout: 5000 });
|
await expect(markAsVerifiedButton).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Verify the Verify button is NOT visible
|
// Verify the Verify button is NOT visible
|
||||||
const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`);
|
const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`);
|
||||||
|
|||||||
Reference in New Issue
Block a user