feat(core): implement git worktree checkpoint system

Add comprehensive worktree management system to enable task isolation and rollback capabilities. This allows users to revert agent changes if they don't satisfy requirements or break functionality.

Key components:
- New WorktreeManager service for branch and worktree operations
- GitDiffPanel component for visualizing changes
- Enhanced UI components with worktree integration
- Auto-mode service enhancements for worktree workflow

Modified files: worktree-manager.js, git-diff-panel.tsx, main.js, preload.js, feature-loader.js, agent-output-modal.tsx, board-view.tsx, kanban-card.tsx, electron.ts, app-store.ts, electron.d.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-10 12:28:13 +01:00
parent 364adeb151
commit a78b6763de
14 changed files with 2127 additions and 334 deletions

View File

@@ -0,0 +1,571 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import {
File,
FileText,
FilePlus,
FileX,
FilePen,
ChevronDown,
ChevronRight,
Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from "lucide-react";
import { Button } from "./button";
import type { FileStatus } from "@/types/electron";
interface GitDiffPanelProps {
projectPath: string;
featureId: string;
className?: string;
/** Whether to show the panel in a compact/minimized state initially */
compact?: boolean;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: "context" | "addition" | "deletion" | "header";
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case "A":
case "?":
return <FilePlus className="w-4 h-4 text-green-500" />;
case "D":
return <FileX className="w-4 h-4 text-red-500" />;
case "M":
case "U":
return <FilePen className="w-4 h-4 text-amber-500" />;
case "R":
case "C":
return <File className="w-4 h-4 text-blue-500" />;
default:
return <FileText className="w-4 h-4 text-muted-foreground" />;
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case "A":
case "?":
return "bg-green-500/20 text-green-400 border-green-500/30";
case "D":
return "bg-red-500/20 text-red-400 border-red-500/30";
case "M":
case "U":
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
case "R":
case "C":
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
default:
return "bg-muted text-muted-foreground border-border";
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split("\n");
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// New file diff
if (line.startsWith("diff --git")) {
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
// Extract file path from diff header
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : "unknown",
hunks: [],
};
currentHunk = null;
continue;
}
// New file indicator
if (line.startsWith("new file mode")) {
if (currentFile) currentFile.isNew = true;
continue;
}
// Deleted file indicator
if (line.startsWith("deleted file mode")) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
// Renamed file indicator
if (line.startsWith("rename from") || line.startsWith("rename to")) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
// Skip index, ---/+++ lines
if (
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ")
) {
continue;
}
// Hunk header
if (line.startsWith("@@")) {
if (currentHunk && currentFile) {
currentFile.hunks.push(currentHunk);
}
// Parse line numbers from @@ -old,count +new,count @@
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: "header", content: line }],
};
continue;
}
// Diff content lines
if (currentHunk) {
if (line.startsWith("+")) {
currentHunk.lines.push({
type: "addition",
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith("-")) {
currentHunk.lines.push({
type: "deletion",
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(" ") || line === "") {
currentHunk.lines.push({
type: "context",
content: line.substring(1) || "",
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
// Don't forget the last file and hunk
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: "context" | "addition" | "deletion" | "header";
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: "bg-transparent",
addition: "bg-green-500/10",
deletion: "bg-red-500/10",
header: "bg-blue-500/10",
};
const textClass = {
context: "text-foreground-secondary",
addition: "text-green-400",
deletion: "text-red-400",
header: "text-blue-400",
};
const prefix = {
context: " ",
addition: "+",
deletion: "-",
header: "",
};
if (type === "header") {
return (
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn("flex font-mono text-xs", bgClass[type])}>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.old ?? ""}
</span>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.new ?? ""}
</span>
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
{prefix[type]}
</span>
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
{content || "\u00A0"}
</span>
</div>
);
}
function FileDiffSection({
fileDiff,
isExpanded,
onToggle,
}: {
fileDiff: ParsedFileDiff;
isExpanded: boolean;
onToggle: () => void;
}) {
const additions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
0
);
const deletions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
0
);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="flex-1 text-sm font-mono truncate text-foreground">
{fileDiff.filePath}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{fileDiff.isNew && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
new
</span>
)}
{fileDiff.isDeleted && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
deleted
</span>
)}
{fileDiff.isRenamed && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
renamed
</span>
)}
{additions > 0 && (
<span className="text-xs text-green-400">+{additions}</span>
)}
{deletions > 0 && (
<span className="text-xs text-red-400">-{deletions}</span>
)}
</div>
</button>
{isExpanded && (
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
</div>
);
}
export function GitDiffPanel({
projectPath,
featureId,
className,
compact = true,
}: GitDiffPanelProps) {
const [isExpanded, setIsExpanded] = useState(!compact);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState<string>("");
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const loadDiffs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.getDiffs) {
throw new Error("Worktree API not available");
}
const result = await api.worktree.getDiffs(projectPath, featureId);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
} else {
setError(result.error || "Failed to load diffs");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load diffs");
} finally {
setIsLoading(false);
}
}, [projectPath, featureId]);
// Load diffs when expanded
useEffect(() => {
if (isExpanded) {
loadDiffs();
}
}, [isExpanded, loadDiffs]);
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
};
const expandAllFiles = () => {
setExpandedFiles(new Set(parsedDiffs.map((d) => d.filePath)));
};
const collapseAllFiles = () => {
setExpandedFiles(new Set());
};
// Total stats
const totalAdditions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
0
),
0
);
const totalDeletions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
0
),
0
);
return (
<div
className={cn(
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
className
)}
data-testid="git-diff-panel"
>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between bg-card hover:bg-accent/50 transition-colors text-left"
data-testid="git-diff-panel-toggle"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
<GitBranch className="w-4 h-4 text-brand-500" />
<span className="font-medium text-sm text-foreground">Git Changes</span>
</div>
<div className="flex items-center gap-3 text-xs">
{!isExpanded && files.length > 0 && (
<>
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
</span>
{totalAdditions > 0 && (
<span className="text-green-400">+{totalAdditions}</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">-{totalDeletions}</span>
)}
</>
)}
</div>
</button>
{/* Content */}
{isExpanded && (
<div className="border-t border-border">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading changes...</span>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<AlertCircle className="w-5 h-5 text-amber-500" />
<span className="text-sm">{error}</span>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="mt-2"
>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div className="p-4 space-y-4">
{/* Summary bar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-wrap">
{files.map((file) => (
<div
key={file.path}
className="flex items-center gap-1.5"
title={file.path}
>
{getFileIcon(file.status)}
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border",
getStatusBadgeColor(file.status)
)}
>
{file.statusText}
</span>
</div>
))}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={expandAllFiles}
className="text-xs h-7"
>
Expand All
</Button>
<Button
variant="ghost"
size="sm"
onClick={collapseAllFiles}
className="text-xs h-7"
>
Collapse All
</Button>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="text-xs h-7"
>
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"} changed
</span>
{totalAdditions > 0 && (
<span className="text-green-400">
+{totalAdditions} additions
</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">
-{totalDeletions} deletions
</span>
)}
</div>
{/* File diffs */}
<div className="space-y-3">
{parsedDiffs.map((fileDiff) => (
<FileDiffSection
key={fileDiff.filePath}
fileDiff={fileDiff}
isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -8,9 +8,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, List, FileText } from "lucide-react";
import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import type { AutoModeEvent } from "@/types/electron";
interface AgentOutputModalProps {
@@ -22,7 +23,7 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = "parsed" | "raw";
type ViewMode = "parsed" | "raw" | "changes";
export function AgentOutputModal({
open,
@@ -34,6 +35,7 @@ export function AgentOutputModal({
const [output, setOutput] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>("");
@@ -64,6 +66,7 @@ export function AgentOutputModal({
}
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
// Ensure context directory exists
const contextDir = `${currentProject.path}/.automaker/agents-context`;
@@ -257,7 +260,19 @@ export function AgentOutputModal({
data-testid="view-mode-parsed"
>
<List className="w-3.5 h-3.5" />
Parsed
Logs
</button>
<button
onClick={() => setViewMode("changes")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "changes"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-changes"
>
<GitBranch className="w-3.5 h-3.5" />
Changes
</button>
<button
onClick={() => setViewMode("raw")}
@@ -281,34 +296,54 @@ export function AgentOutputModal({
</DialogDescription>
</DialogHeader>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh]"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading output...
{viewMode === "changes" ? (
<div className="flex-1 overflow-y-auto min-h-[400px] max-h-[60vh]">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
featureId={featureId}
compact={false}
className="border-0 rounded-lg"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading...
</div>
)}
</div>
) : (
<>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh]"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading output...
</div>
) : !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
)}
</div>
) : !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
</div>
</>
)}
</DialogContent>
</Dialog>
);

View File

@@ -1090,6 +1090,106 @@ export function BoardView() {
});
};
// Revert feature changes by removing the worktree
const handleRevertFeature = async (feature: Feature) => {
if (!currentProject) return;
console.log("[Board] Reverting feature:", {
id: feature.id,
description: feature.description,
branchName: feature.branchName,
});
try {
const api = getElectronAPI();
if (!api?.worktree?.revertFeature) {
console.error("Worktree API not available");
toast.error("Revert not available", {
description: "This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.revertFeature(
currentProject.path,
feature.id
);
if (result.success) {
console.log("[Board] Feature reverted successfully");
// Reload features to update the UI
await loadFeatures();
toast.success("Feature reverted", {
description: `All changes discarded. Moved back to backlog: ${feature.description.slice(
0,
50
)}${feature.description.length > 50 ? "..." : ""}`,
});
} else {
console.error("[Board] Failed to revert feature:", result.error);
toast.error("Failed to revert feature", {
description: result.error || "An error occurred",
});
}
} catch (error) {
console.error("[Board] Error reverting feature:", error);
toast.error("Failed to revert feature", {
description:
error instanceof Error ? error.message : "An error occurred",
});
}
};
// Merge feature worktree changes back to main branch
const handleMergeFeature = async (feature: Feature) => {
if (!currentProject) return;
console.log("[Board] Merging feature:", {
id: feature.id,
description: feature.description,
branchName: feature.branchName,
});
try {
const api = getElectronAPI();
if (!api?.worktree?.mergeFeature) {
console.error("Worktree API not available");
toast.error("Merge not available", {
description: "This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.mergeFeature(
currentProject.path,
feature.id
);
if (result.success) {
console.log("[Board] Feature merged successfully");
// Reload features to update the UI
await loadFeatures();
toast.success("Feature merged", {
description: `Changes merged to main branch: ${feature.description.slice(
0,
50
)}${feature.description.length > 50 ? "..." : ""}`,
});
} else {
console.error("[Board] Failed to merge feature:", result.error);
toast.error("Failed to merge feature", {
description: result.error || "An error occurred",
});
}
} catch (error) {
console.error("[Board] Error merging feature:", error);
toast.error("Failed to merge feature", {
description:
error instanceof Error ? error.message : "An error occurred",
});
}
};
const checkContextExists = async (featureId: string): Promise<boolean> => {
if (!currentProject) return false;
@@ -1463,6 +1563,8 @@ export function BoardView() {
}
onFollowUp={() => handleOpenFollowUp(feature)}
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id

View File

@@ -49,6 +49,9 @@ import {
FileText,
MoreVertical,
AlertCircle,
GitBranch,
Undo2,
GitMerge,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -72,6 +75,8 @@ interface KanbanCardProps {
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -93,6 +98,8 @@ export function KanbanCard({
onMoveBackToInProgress,
onFollowUp,
onCommit,
onRevert,
onMerge,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -101,9 +108,13 @@ export function KanbanCard({
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
const hasWorktree = !!feature.branchName;
// Helper functions to check what should be shown based on detail level
const showSteps =
kanbanCardDetailLevel === "standard" ||
@@ -246,11 +257,33 @@ export function KanbanCard({
<span>Errored</span>
</div>
)}
{/* Branch badge - show when feature has a worktree */}
{hasWorktree && !isCurrentAutoTask && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
// Position below error badge if present, otherwise use normal position
feature.error || feature.skipTests
? "top-8 left-2"
: shortcutKey
? "top-2 left-10"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
title={`Branch: ${feature.branchName}`}
>
<GitBranch className="w-3 h-3" />
<span className="truncate max-w-[100px]">{feature.branchName?.replace("feature/", "")}</span>
</div>
)}
<CardHeader
className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
// Add extra top padding when badges are present to prevent text overlap
(feature.skipTests || feature.error || shortcutKey) && "pt-10"
(feature.skipTests || feature.error || shortcutKey) && "pt-10",
// Add even more top padding when both badges and branch are shown
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
)}
>
{isCurrentAutoTask && (
@@ -615,6 +648,23 @@ export function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{/* Revert button - only show when worktree exists */}
{hasWorktree && onRevert && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
data-testid={`revert-${feature.id}`}
title="Discard all changes and move back to backlog"
>
<Undo2 className="w-3 h-3 mr-1" />
Revert
</Button>
)}
{/* Follow-up prompt button */}
{onFollowUp && (
<Button
@@ -631,8 +681,25 @@ export function KanbanCard({
Follow-up
</Button>
)}
{/* Commit and verify button */}
{onCommit && (
{/* Merge button - only show when worktree exists */}
{hasWorktree && onMerge && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-purple-600 hover:bg-purple-700"
onClick={(e) => {
e.stopPropagation();
onMerge();
}}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
<GitMerge className="w-3 h-3 mr-1" />
Merge
</Button>
)}
{/* Commit and verify button - show when no worktree */}
{!hasWorktree && onCommit && (
<Button
variant="default"
size="sm"
@@ -736,6 +803,49 @@ export function KanbanCard({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Revert Confirmation Dialog */}
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
<DialogContent data-testid="revert-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-400">
<Undo2 className="w-5 h-5" />
Revert Changes
</DialogTitle>
<DialogDescription>
This will discard all changes made by the agent and move the feature back to the backlog.
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
</span>
)}
<span className="block mt-2 text-red-400 font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsRevertDialogOpen(false)}
data-testid="cancel-revert-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setIsRevertDialogOpen(false);
onRevert?.();
}}
data-testid="confirm-revert-button"
>
<Undo2 className="w-4 h-4 mr-2" />
Revert Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -42,7 +42,7 @@ export interface StatResult {
}
// Auto Mode types - Import from electron.d.ts to avoid duplication
import type { AutoModeEvent, ModelDefinition, ProviderStatus } from "@/types/electron";
import type { AutoModeEvent, ModelDefinition, ProviderStatus, WorktreeAPI, WorktreeInfo, WorktreeStatus, FileDiffsResult, FileDiffResult, FileStatus } from "@/types/electron";
export interface AutoModeAPI {
start: (projectPath: string, maxConcurrency?: number) => Promise<{ success: boolean; error?: string }>;
@@ -128,6 +128,7 @@ export interface ElectronAPI {
message?: string;
error?: string;
}>;
worktree?: WorktreeAPI;
}
declare global {
@@ -422,9 +423,78 @@ export const getElectronAPI = (): ElectronAPI => {
// Mock Auto Mode API
autoMode: createMockAutoModeAPI(),
// Mock Worktree API
worktree: createMockWorktreeAPI(),
};
};
// Mock Worktree API implementation
function createMockWorktreeAPI(): WorktreeAPI {
return {
revertFeature: async (projectPath: string, featureId: string) => {
console.log("[Mock] Reverting feature:", { projectPath, featureId });
return { success: true, removedPath: `/mock/worktree/${featureId}` };
},
mergeFeature: async (projectPath: string, featureId: string, options?: object) => {
console.log("[Mock] Merging feature:", { projectPath, featureId, options });
return { success: true, mergedBranch: `feature/${featureId}` };
},
getInfo: async (projectPath: string, featureId: string) => {
console.log("[Mock] Getting worktree info:", { projectPath, featureId });
return {
success: true,
worktreePath: `/mock/worktrees/${featureId}`,
branchName: `feature/${featureId}`,
head: "abc1234",
};
},
getStatus: async (projectPath: string, featureId: string) => {
console.log("[Mock] Getting worktree status:", { projectPath, featureId });
return {
success: true,
modifiedFiles: 3,
files: ["src/feature.ts", "tests/feature.spec.ts", "README.md"],
diffStat: " 3 files changed, 50 insertions(+), 10 deletions(-)",
recentCommits: [
"abc1234 feat: implement feature",
"def5678 test: add tests for feature",
],
};
},
list: async (projectPath: string) => {
console.log("[Mock] Listing worktrees:", { projectPath });
return { success: true, worktrees: [] };
},
getDiffs: async (projectPath: string, featureId: string) => {
console.log("[Mock] Getting file diffs:", { projectPath, featureId });
return {
success: true,
diff: "diff --git a/src/feature.ts b/src/feature.ts\n+++ new file\n@@ -0,0 +1,10 @@\n+export function feature() {\n+ return 'hello';\n+}",
files: [
{ status: "A", path: "src/feature.ts", statusText: "Added" },
{ status: "M", path: "README.md", statusText: "Modified" },
],
hasChanges: true,
};
},
getFileDiff: async (projectPath: string, featureId: string, filePath: string) => {
console.log("[Mock] Getting file diff:", { projectPath, featureId, filePath });
return {
success: true,
diff: `diff --git a/${filePath} b/${filePath}\n+++ new file\n@@ -0,0 +1,5 @@\n+// New content`,
filePath,
};
},
};
}
// Mock Auto Mode state and implementation
let mockAutoModeRunning = false;
let mockRunningFeatures = new Set<string>(); // Track multiple concurrent feature verifications

View File

@@ -108,6 +108,9 @@ export interface Feature {
model?: AgentModel; // Model to use for this feature (defaults to opus)
thinkingLevel?: ThinkingLevel; // Thinking level for extended thinking (defaults to none)
error?: string; // Error message if the agent errored during processing
// Worktree info - set when a feature is being worked on in an isolated git worktree
worktreePath?: string; // Path to the worktree directory
branchName?: string; // Name of the feature branch
}
export interface AppState {

View File

@@ -383,6 +383,91 @@ export interface ElectronAPI {
message?: string;
error?: string;
}>;
// Worktree Management APIs
worktree: WorktreeAPI;
}
export interface WorktreeInfo {
worktreePath: string;
branchName: string;
head?: string;
baseBranch?: string;
}
export interface WorktreeStatus {
success: boolean;
modifiedFiles?: number;
files?: string[];
diffStat?: string;
recentCommits?: string[];
error?: string;
}
export interface FileStatus {
status: string;
path: string;
statusText: string;
}
export interface FileDiffsResult {
success: boolean;
diff?: string;
files?: FileStatus[];
hasChanges?: boolean;
error?: string;
}
export interface FileDiffResult {
success: boolean;
diff?: string;
filePath?: string;
error?: string;
}
export interface WorktreeAPI {
// Revert feature changes by removing the worktree
revertFeature: (projectPath: string, featureId: string) => Promise<{
success: boolean;
removedPath?: string;
error?: string;
}>;
// Merge feature worktree changes back to main branch
mergeFeature: (projectPath: string, featureId: string, options?: {
squash?: boolean;
commitMessage?: string;
squashMessage?: string;
}) => Promise<{
success: boolean;
mergedBranch?: string;
error?: string;
}>;
// Get worktree info for a feature
getInfo: (projectPath: string, featureId: string) => Promise<{
success: boolean;
worktreePath?: string;
branchName?: string;
head?: string;
error?: string;
}>;
// Get worktree status (changed files, commits)
getStatus: (projectPath: string, featureId: string) => Promise<WorktreeStatus>;
// List all feature worktrees
list: (projectPath: string) => Promise<{
success: boolean;
worktrees?: WorktreeInfo[];
error?: string;
}>;
// Get file diffs for a feature worktree
getDiffs: (projectPath: string, featureId: string) => Promise<FileDiffsResult>;
// Get diff for a specific file in a worktree
getFileDiff: (projectPath: string, featureId: string, filePath: string) => Promise<FileDiffResult>;
}
// Model definition type