Merge branch 'main' into pull-request

This commit is contained in:
Cody Seibert
2025-12-19 16:53:00 -05:00
325 changed files with 15335 additions and 14012 deletions

View File

@@ -0,0 +1,487 @@
import { useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
FileText,
FolderOpen,
Terminal,
CheckCircle,
XCircle,
Loader2,
Play,
File,
Pencil,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
interface ToolResult {
success: boolean;
output?: string;
error?: string;
timestamp: Date;
}
interface ToolExecution {
tool: string;
input: string;
result: ToolResult | null;
isRunning: boolean;
}
export function AgentToolsView() {
const { currentProject } = useAppStore();
const api = getElectronAPI();
// Read File Tool State
const [readFilePath, setReadFilePath] = useState("");
const [readFileResult, setReadFileResult] = useState<ToolResult | null>(null);
const [isReadingFile, setIsReadingFile] = useState(false);
// Write File Tool State
const [writeFilePath, setWriteFilePath] = useState("");
const [writeFileContent, setWriteFileContent] = useState("");
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(
null
);
const [isWritingFile, setIsWritingFile] = useState(false);
// Terminal Tool State
const [terminalCommand, setTerminalCommand] = useState("ls");
const [terminalResult, setTerminalResult] = useState<ToolResult | null>(null);
const [isRunningCommand, setIsRunningCommand] = useState(false);
// Execute Read File
const handleReadFile = useCallback(async () => {
if (!readFilePath.trim()) return;
setIsReadingFile(true);
setReadFileResult(null);
try {
// Simulate agent requesting file read
console.log(`[Agent Tool] Requesting to read file: ${readFilePath}`);
const result = await api.readFile(readFilePath);
if (result.success) {
setReadFileResult({
success: true,
output: result.content,
timestamp: new Date(),
});
console.log(`[Agent Tool] File read successful: ${readFilePath}`);
} else {
setReadFileResult({
success: false,
error: result.error || "Failed to read file",
timestamp: new Date(),
});
console.log(`[Agent Tool] File read failed: ${result.error}`);
}
} catch (error) {
setReadFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsReadingFile(false);
}
}, [readFilePath, api]);
// Execute Write File
const handleWriteFile = useCallback(async () => {
if (!writeFilePath.trim() || !writeFileContent.trim()) return;
setIsWritingFile(true);
setWriteFileResult(null);
try {
// Simulate agent requesting file write
console.log(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
const result = await api.writeFile(writeFilePath, writeFileContent);
if (result.success) {
setWriteFileResult({
success: true,
output: `File written successfully: ${writeFilePath}`,
timestamp: new Date(),
});
console.log(`[Agent Tool] File write successful: ${writeFilePath}`);
} else {
setWriteFileResult({
success: false,
error: result.error || "Failed to write file",
timestamp: new Date(),
});
console.log(`[Agent Tool] File write failed: ${result.error}`);
}
} catch (error) {
setWriteFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsWritingFile(false);
}
}, [writeFilePath, writeFileContent, api]);
// Execute Terminal Command
const handleRunCommand = useCallback(async () => {
if (!terminalCommand.trim()) return;
setIsRunningCommand(true);
setTerminalResult(null);
try {
// Terminal command simulation for demonstration purposes
console.log(`[Agent Tool] Simulating command: ${terminalCommand}`);
// Simulated outputs for common commands (preview mode)
// In production, the agent executes commands via Claude SDK
const simulatedOutputs: Record<string, string> = {
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
pwd: currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
whoami: "automaker-agent",
date: new Date().toString(),
"cat package.json":
'{\n "name": "demo-project",\n "version": "1.0.0"\n}',
};
// Simulate command execution delay
await new Promise((resolve) => setTimeout(resolve, 500));
const output =
simulatedOutputs[terminalCommand.toLowerCase()] ||
`[Preview] ${terminalCommand}\n(Terminal commands are executed by the agent during feature implementation)`;
setTerminalResult({
success: true,
output: output,
timestamp: new Date(),
});
console.log(
`[Agent Tool] Command executed successfully: ${terminalCommand}`
);
} catch (error) {
setTerminalResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date(),
});
} finally {
setIsRunningCommand(false);
}
}, [terminalCommand, currentProject]);
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="agent-tools-no-project"
>
<div className="text-center">
<Wrench className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
<p className="text-muted-foreground">
Open or create a project to test agent tools.
</p>
</div>
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="agent-tools-view"
>
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
<Wrench className="w-5 h-5 text-primary" />
<div>
<h1 className="text-xl font-bold">Agent Tools</h1>
<p className="text-sm text-muted-foreground">
Test file system and terminal tools for {currentProject.name}
</p>
</div>
</div>
{/* Tools Grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{/* Read File Tool */}
<Card data-testid="read-file-tool">
<CardHeader>
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-blue-500" />
<CardTitle className="text-lg">Read File</CardTitle>
</div>
<CardDescription>
Agent requests to read a file from the filesystem
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="read-file-path">File Path</Label>
<Input
id="read-file-path"
placeholder="/path/to/file.txt"
value={readFilePath}
onChange={(e) => setReadFilePath(e.target.value)}
data-testid="read-file-path-input"
/>
</div>
<Button
onClick={handleReadFile}
disabled={isReadingFile || !readFilePath.trim()}
className="w-full"
data-testid="read-file-button"
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Reading...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Read
</>
)}
</Button>
{/* Result */}
{readFileResult && (
<div
className={cn(
"p-3 rounded-md border",
readFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
)}
data-testid="read-file-result"
>
<div className="flex items-center gap-2 mb-2">
{readFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{readFileResult.success ? "Success" : "Failed"}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{readFileResult.success
? readFileResult.output
: readFileResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Write File Tool */}
<Card data-testid="write-file-tool">
<CardHeader>
<div className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-green-500" />
<CardTitle className="text-lg">Write File</CardTitle>
</div>
<CardDescription>
Agent requests to write content to a file
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="write-file-path">File Path</Label>
<Input
id="write-file-path"
placeholder="/path/to/file.txt"
value={writeFilePath}
onChange={(e) => setWriteFilePath(e.target.value)}
data-testid="write-file-path-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="write-file-content">Content</Label>
<textarea
id="write-file-content"
placeholder="File content..."
value={writeFileContent}
onChange={(e) => setWriteFileContent(e.target.value)}
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-input bg-background resize-y"
data-testid="write-file-content-input"
/>
</div>
<Button
onClick={handleWriteFile}
disabled={
isWritingFile ||
!writeFilePath.trim() ||
!writeFileContent.trim()
}
className="w-full"
data-testid="write-file-button"
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Writing...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Write
</>
)}
</Button>
{/* Result */}
{writeFileResult && (
<div
className={cn(
"p-3 rounded-md border",
writeFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
)}
data-testid="write-file-result"
>
<div className="flex items-center gap-2 mb-2">
{writeFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{writeFileResult.success ? "Success" : "Failed"}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{writeFileResult.success
? writeFileResult.output
: writeFileResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Terminal Tool */}
<Card data-testid="terminal-tool">
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-purple-500" />
<CardTitle className="text-lg">Run Terminal</CardTitle>
</div>
<CardDescription>
Agent requests to execute a terminal command
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="terminal-command">Command</Label>
<Input
id="terminal-command"
placeholder="ls -la"
value={terminalCommand}
onChange={(e) => setTerminalCommand(e.target.value)}
data-testid="terminal-command-input"
/>
</div>
<Button
onClick={handleRunCommand}
disabled={isRunningCommand || !terminalCommand.trim()}
className="w-full"
data-testid="run-terminal-button"
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Command
</>
)}
</Button>
{/* Result */}
{terminalResult && (
<div
className={cn(
"p-3 rounded-md border",
terminalResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
)}
data-testid="terminal-result"
>
<div className="flex items-center gap-2 mb-2">
{terminalResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{terminalResult.success ? "Success" : "Failed"}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
$ {terminalCommand}
{"\n"}
{terminalResult.success
? terminalResult.output
: terminalResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
{/* Tool Log Section */}
<Card className="mt-6" data-testid="tool-log">
<CardHeader>
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
<CardDescription>
View agent tool requests and responses
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent
tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>
<li>Write File - Agent writes content to specified path</li>
<li>Run Terminal - Agent executes shell commands</li>
</ul>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,915 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore, type AgentModel } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone";
import {
Bot,
Send,
User,
Loader2,
Sparkles,
Wrench,
Trash2,
PanelLeftClose,
PanelLeft,
Paperclip,
X,
ImageIcon,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useElectronAgent } from "@/hooks/use-electron-agent";
import { SessionManager } from "@/components/session-manager";
import { Markdown } from "@/components/ui/markdown";
import type { ImageAttachment } from "@/store/app-store";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } =
useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState("");
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [currentTool, setCurrentTool] = useState<string | null>(null);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
// Scroll management for auto-scroll
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
// Input ref for auto-focus
const inputRef = useRef<HTMLInputElement>(null);
// Ref for quick create session function from SessionManager
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
// Use the Electron agent hook (only if we have a session)
const {
messages,
isProcessing,
isConnected,
sendMessage,
clearHistory,
error: agentError,
} = useElectronAgent({
sessionId: currentSessionId || "",
workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => {
setCurrentTool(toolName);
setTimeout(() => setCurrentTool(null), 2000);
},
});
// Handle session selection with persistence
const handleSelectSession = useCallback(
(sessionId: string | null) => {
setCurrentSessionId(sessionId);
// Persist the selection for this project
if (currentProject?.path) {
setLastSelectedSession(currentProject.path, sessionId);
}
},
[currentProject?.path, setLastSelectedSession]
);
// Restore last selected session when switching to Agent view or when project changes
useEffect(() => {
if (!currentProject?.path) {
// No project, reset
setCurrentSessionId(null);
initialSessionLoadedRef.current = false;
return;
}
// Only restore once per project
if (initialSessionLoadedRef.current) return;
initialSessionLoadedRef.current = true;
const lastSessionId = getLastSelectedSession(currentProject.path);
if (lastSessionId) {
console.log(
"[AgentView] Restoring last selected session:",
lastSessionId
);
setCurrentSessionId(lastSessionId);
}
}, [currentProject?.path, getLastSelectedSession]);
// Reset initialSessionLoadedRef when project changes
useEffect(() => {
initialSessionLoadedRef.current = false;
}, [currentProject?.path]);
const handleSend = useCallback(async () => {
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
const messageContent = input;
const messageImages = selectedImages;
setInput("");
setSelectedImages([]);
setShowImageDropZone(false);
await sendMessage(messageContent, messageImages);
}, [input, selectedImages, isProcessing, sendMessage]);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
}, []);
const toggleImageDropZone = useCallback(() => {
setShowImageDropZone(!showImageDropZone);
}, [showImageDropZone]);
// Helper function to convert file to base64
const fileToBase64 = useCallback((file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}, []);
// Process dropped files
const processDroppedFiles = useCallback(
async (files: FileList) => {
if (isProcessing) return;
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
const newImages: ImageAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
// Check if we've reached max files
if (newImages.length + selectedImages.length >= MAX_FILES) {
errors.push(`Maximum ${MAX_FILES} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
}
if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]);
}
},
[isProcessing, selectedImages, fileToBase64]
);
// Remove individual image
const removeImage = useCallback((imageId: string) => {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
// Drag and drop handlers for the input area
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (isProcessing || !isConnected) return;
// Check if dragged items contain files
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
}
},
[isProcessing, isConnected]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set dragOver to false if we're leaving the input container
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (isProcessing || !isConnected) return;
// Check if we have files
const files = e.dataTransfer.files;
if (files && files.length > 0) {
processDroppedFiles(files);
return;
}
// Handle file paths (from screenshots or other sources)
const items = e.dataTransfer.items;
if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
processDroppedFiles(dataTransfer.files);
}
}
}
}
},
[isProcessing, isConnected, processDroppedFiles]
);
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
// Check if clipboard contains files
const items = e.clipboardData?.items;
if (items) {
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
const file = item.getAsFile();
if (file && file.type.startsWith("image/")) {
e.preventDefault(); // Prevent default paste of file path
files.push(file);
}
}
}
if (files.length > 0) {
const dataTransfer = new DataTransfer();
files.forEach((file) => dataTransfer.items.add(file));
await processDroppedFiles(dataTransfer.files);
}
}
},
[processDroppedFiles]
);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClearChat = async () => {
if (!confirm("Are you sure you want to clear this conversation?")) return;
await clearHistory();
};
// Scroll position detection
const checkIfUserIsAtBottom = useCallback(() => {
const container = messagesContainerRef.current;
if (!container) return;
const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <=
threshold;
setIsUserAtBottom(isAtBottom);
}, []);
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = messagesContainerRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: behavior,
});
}, []);
// Handle scroll events
const handleScroll = useCallback(() => {
checkIfUserIsAtBottom();
}, [checkIfUserIsAtBottom]);
// Auto-scroll effect when messages change
useEffect(() => {
// Only auto-scroll if user was already at bottom
if (isUserAtBottom && messages.length > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom("smooth");
}, 100);
}
}, [messages, isUserAtBottom, scrollToBottom]);
// Initial scroll to bottom when session changes
useEffect(() => {
if (currentSessionId && messages.length > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom("auto");
setIsUserAtBottom(true);
}, 100);
}
}, [currentSessionId, scrollToBottom]);
// Auto-focus input when session is selected/changed
useEffect(() => {
if (currentSessionId && inputRef.current) {
// Small delay to ensure UI has updated
setTimeout(() => {
inputRef.current?.focus();
}, 200);
}
}, [currentSessionId]);
// Keyboard shortcuts for agent view
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// New session shortcut - only when in agent view with a project
if (currentProject) {
shortcutsList.push({
key: shortcuts.newSession,
action: () => {
if (quickCreateSessionRef.current) {
quickCreateSessionRef.current();
}
},
description: "Create new session",
});
}
return shortcutsList;
}, [currentProject, shortcuts]);
// Register keyboard shortcuts
useKeyboardShortcuts(agentShortcuts);
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center bg-background"
data-testid="agent-view-no-project"
>
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Sparkles className="w-8 h-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-3 text-foreground">
No Project Selected
</h2>
<p className="text-muted-foreground leading-relaxed">
Open or create a project to start working with the AI agent.
</p>
</div>
</div>
);
}
// Show welcome message if no messages yet
const displayMessages =
messages.length === 0
? [
{
id: "welcome",
role: "assistant" as const,
content:
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
timestamp: new Date().toISOString(),
},
]
: messages;
return (
<div
className="flex-1 flex overflow-hidden bg-background"
data-testid="agent-view"
>
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
projectPath={currentProject.path}
isCurrentSessionThinking={isProcessing}
onQuickCreateRef={quickCreateSessionRef}
/>
</div>
)}
{/* Chat Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => setShowSessionManager(!showSessionManager)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">
AI Agent
</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
{currentSessionId && !isConnected && " - Connecting..."}
</p>
</div>
</div>
{/* Status indicators & actions */}
<div className="flex items-center gap-3">
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs font-medium"
disabled={isProcessing}
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn(
"cursor-pointer",
selectedModel === model.id && "bg-accent"
)}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">
{model.description}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" />
<span className="font-medium">{currentTool}</span>
</div>
)}
{agentError && (
<span className="text-xs text-destructive font-medium">
{agentError}
</span>
)}
{currentSessionId && messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
Clear
</Button>
)}
</div>
</div>
{/* Messages */}
{!currentSessionId ? (
<div
className="flex-1 flex items-center justify-center bg-background"
data-testid="no-session-placeholder"
>
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-3 text-foreground">
No Session Selected
</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent
</p>
<Button
onClick={() => setShowSessionManager(true)}
variant="outline"
className="gap-2"
>
<PanelLeft className="w-4 h-4" />
{showSessionManager ? "View" : "Show"} Sessions
</Button>
</div>
</div>
) : (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
data-testid="message-list"
onScroll={handleScroll}
>
{displayMessages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-4 max-w-4xl",
message.role === "user" ? "flex-row-reverse ml-auto" : ""
)}
>
{/* Avatar */}
<div
className={cn(
"w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
message.role === "assistant"
? "bg-primary/10 ring-1 ring-primary/20"
: "bg-muted ring-1 ring-border"
)}
>
{message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
)}
</div>
{/* Message Bubble */}
<div
className={cn(
"flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-card border border-border"
)}
>
{message.role === "assistant" ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">
{message.content}
</p>
)}
{/* Display attached images for user messages */}
{message.role === "user" &&
message.images &&
message.images.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
<ImageIcon className="w-3 h-3" />
<span>
{message.images.length} image
{message.images.length > 1 ? "s" : ""} attached
</span>
</div>
<div className="flex flex-wrap gap-2">
{message.images.map((image, index) => {
// Construct proper data URL from base64 data and mime type
const dataUrl = image.data.startsWith("data:")
? image.data
: `data:${image.mimeType || "image/png"};base64,${
image.data
}`;
return (
<div
key={image.id || `img-${index}`}
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
>
<img
src={dataUrl}
alt={
image.filename ||
`Attached image ${index + 1}`
}
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
{image.filename || `Image ${index + 1}`}
</div>
</div>
);
})}
</div>
</div>
)}
<p
className={cn(
"text-[11px] mt-2 font-medium",
message.role === "user"
? "text-primary-foreground/70"
: "text-muted-foreground"
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
))}
{/* Thinking Indicator */}
{isProcessing && (
<div className="flex gap-4 max-w-4xl">
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
<Bot className="w-4 h-4 text-primary" />
</div>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "300ms" }}
/>
</div>
<span className="text-sm text-muted-foreground">
Thinking...
</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Input Area */}
{currentSessionId && (
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={handleImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-4"
disabled={isProcessing || !isConnected}
/>
)}
{/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{selectedImages.length > 0 && !showImageDropZone && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""} attached
</p>
<button
onClick={() => setSelectedImages([])}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedImages.map((image) => (
<div
key={image.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
<img
src={image.data}
alt={image.filename}
className="w-full h-full object-cover"
/>
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{image.filename}
</p>
{image.size !== undefined && (
<p className="text-[10px] text-muted-foreground">
{formatFileSize(image.size)}
</p>
)}
</div>
{/* Remove button */}
{image.id && (
<button
onClick={() => removeImage(image.id!)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
)}
{/* Text Input and Controls */}
<div
className={cn(
"flex gap-2 transition-all duration-200 rounded-xl p-1",
isDragOver && "bg-primary/5 ring-2 ring-primary/30"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="flex-1 relative">
<Input
ref={inputRef}
placeholder={
isDragOver
? "Drop your images here..."
: "Describe what you want to build..."
}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
onPaste={handlePaste}
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
"h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
"focus:ring-2 focus:ring-primary/20 focus:border-primary/50",
selectedImages.length > 0 && "border-primary/30",
isDragOver && "border-primary bg-primary/5"
)}
/>
{selectedImages.length > 0 && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""}
</div>
)}
{isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
<Paperclip className="w-3 h-3" />
Drop here
</div>
)}
</div>
{/* Image Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={toggleImageDropZone}
disabled={isProcessing || !isConnected}
className={cn(
"h-11 w-11 rounded-xl border-border",
showImageDropZone &&
"bg-primary/10 text-primary border-primary/30",
selectedImages.length > 0 && "border-primary/30 text-primary"
)}
title="Attach images"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Send Button */}
<Button
onClick={handleSend}
disabled={
(!input.trim() && selectedImages.length === 0) ||
isProcessing ||
!isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press{" "}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
Enter
</kbd>{" "}
to send
</p>
</div>
)}
</div>
</div>
);
}
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
kanbanCardDetailLevel: "minimal" | "standard" | "detailed";
onDetailLevelChange: (level: "minimal" | "standard" | "detailed") => void;
}
export function BoardControls({
isMounted,
onShowBoardBackground,
onShowCompletedModal,
completedCount,
kanbanCardDetailLevel,
onDetailLevelChange,
}: BoardControlsProps) {
if (!isMounted) return null;
return (
<TooltipProvider>
<div className="flex items-center gap-2 ml-4">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onShowBoardBackground}
className="h-8 px-2"
data-testid="board-background-button"
>
<ImageIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Board Background Settings</p>
</TooltipContent>
</Tooltip>
{/* Completed/Archived Features Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onShowCompletedModal}
className="h-8 px-2 relative"
data-testid="completed-features-button"
>
<Archive className="w-4 h-4" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{completedCount > 99 ? "99+" : completedCount}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Completed Features ({completedCount})</p>
</TooltipContent>
</Tooltip>
{/* Kanban Card Detail Level Toggle */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="kanban-detail-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("minimal")}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-minimal"
>
<Minimize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Minimal - Title & category only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("standard")}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("detailed")}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,94 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Users } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
interface BoardHeaderProps {
projectName: string;
maxConcurrency: number;
onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void;
addFeatureShortcut: KeyboardShortcut;
isMounted: boolean;
}
export function BoardHeader({
projectName,
maxConcurrency,
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
onAddFeature,
addFeatureShortcut,
isMounted,
}: BoardHeaderProps) {
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div>
<h1 className="text-xl font-bold">Kanban Board</h1>
<p className="text-sm text-muted-foreground">{projectName}</p>
</div>
<div className="flex gap-2 items-center">
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
data-testid="concurrency-slider-container"
>
<Users className="w-4 h-4 text-muted-foreground" />
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-20"
data-testid="concurrency-slider"
/>
<span
className="text-sm text-muted-foreground min-w-[2ch] text-center"
data-testid="concurrency-value"
>
{maxConcurrency}
</span>
</div>
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<Label
htmlFor="auto-mode-toggle"
className="text-sm font-medium cursor-pointer"
>
Auto Mode
</Label>
<Switch
id="auto-mode-toggle"
checked={isAutoModeRunning}
onCheckedChange={onAutoModeToggle}
data-testid="auto-mode-toggle"
/>
</div>
)}
<HotkeyButton
size="sm"
onClick={onAddFeature}
hotkey={addFeatureShortcut}
hotkeyActive={false}
data-testid="add-feature-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</HotkeyButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Search, X, Loader2 } from "lucide-react";
interface BoardSearchBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
isCreatingSpec: boolean;
creatingSpecProjectPath?: string;
currentProjectPath?: string;
}
export function BoardSearchBar({
searchQuery,
onSearchChange,
isCreatingSpec,
creatingSpecProjectPath,
currentProjectPath,
}: BoardSearchBarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
// Focus search input when "/" is pressed
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only focus if not typing in an input/textarea
if (
e.key === "/" &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
) {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div className="relative max-w-md flex-1 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search features by keyword..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 pr-12 border-border"
data-testid="kanban-search-input"
/>
{searchQuery ? (
<button
onClick={() => onSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
) : (
<span
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="kanban-search-hotkey"
>
/
</span>
)}
</div>
{/* Spec Creation Loading Badge */}
{isCreatingSpec &&
currentProjectPath === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { KanbanCard } from "./kanban-card";
export { KanbanColumn } from "./kanban-column";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
import { memo } from "react";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
interface KanbanColumnProps {
id: string;
title: string;
colorClass: string;
count: number;
children: ReactNode;
headerAction?: ReactNode;
opacity?: number;
showBorder?: boolean;
hideScrollbar?: boolean;
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
width?: number;
}
export const KanbanColumn = memo(function KanbanColumn({
id,
title,
colorClass,
count,
children,
headerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
width,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
// Use inline style for width if provided, otherwise use default w-72
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
return (
<div
ref={setNodeRef}
className={cn(
"relative flex flex-col h-full rounded-xl transition-all duration-200",
!width && "w-72", // Only apply w-72 if no custom width
showBorder && "border border-border/60",
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background"
)}
style={widthStyle}
data-testid={`kanban-column-${id}`}
>
{/* Background layer with opacity */}
<div
className={cn(
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
isOver ? "bg-accent/80" : "bg-card/80"
)}
style={{ opacity: opacity / 100 }}
/>
{/* Column Header */}
<div
className={cn(
"relative z-10 flex items-center gap-3 px-3 py-2.5",
showBorder && "border-b border-border/40"
)}
>
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0", colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
{count}
</span>
</div>
{/* Column Content */}
<div
className={cn(
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
hideScrollbar &&
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
// Smooth scrolling
"scroll-smooth"
)}
>
{children}
</div>
{/* Drop zone indicator when dragging over */}
{isOver && (
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
)}
</div>
);
});

View File

@@ -0,0 +1,22 @@
import { Feature } from "@/store/app-store";
export type ColumnId = Feature["status"];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
{
id: "in_progress",
title: "In Progress",
colorClass: "bg-[var(--status-in-progress)]",
},
{
id: "waiting_approval",
title: "Waiting Approval",
colorClass: "bg-[var(--status-waiting)]",
},
{
id: "verified",
title: "Verified",
colorClass: "bg-[var(--status-success)]",
},
];

View File

@@ -0,0 +1,545 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
import {
useAppStore,
AgentModel,
ThinkingLevel,
FeatureImage,
AIProfile,
PlanningMode,
} from "@/store/app-store";
import {
ModelSelector,
ThinkingLevelSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "@tanstack/react-router";
interface AddFeatureDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (feature: {
category: string;
description: string;
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
defaultSkipTests: boolean;
defaultBranch?: string;
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
}
export function AddFeatureDialog({
open,
onOpenChange,
onAdd,
categorySuggestions,
branchSuggestions,
branchCardCounts,
defaultSkipTests,
defaultBranch = "main",
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
}: AddFeatureDialogProps) {
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
category: "",
description: "",
steps: [""],
images: [] as FeatureImage[],
imagePaths: [] as DescriptionImagePath[],
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "",
priority: 2 as number, // Default to medium priority
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [planningMode, setPlanningMode] = useState<PlanningMode>("skip");
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Get enhancement model, planning mode defaults, and worktrees setting from store
const {
enhancementModel,
defaultPlanningMode,
defaultRequirePlanApproval,
useWorktrees,
} = useAppStore();
// Sync defaults when dialog opens
useEffect(() => {
if (open) {
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
branchName: defaultBranch || "",
}));
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
}
}, [
open,
defaultSkipTests,
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
]);
const handleAdd = () => {
if (!newFeature.description.trim()) {
setDescriptionError(true);
return;
}
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error("Please select a branch name");
return;
}
const category = newFeature.category || "Uncategorized";
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? currentBranch || ""
: newFeature.branchName || "";
onAdd({
category,
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
images: newFeature.images,
imagePaths: newFeature.imagePaths,
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
branchName: finalBranchName,
priority: newFeature.priority,
planningMode,
requirePlanApproval,
});
// Reset form
setNewFeature({
category: "",
description: "",
steps: [""],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus",
priority: 2,
thinkingLevel: "none",
branchName: "",
});
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
setDescriptionError(false);
onOpenChange(false);
};
const handleDialogClose = (open: boolean) => {
onOpenChange(open);
if (!open) {
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
setDescriptionError(false);
}
};
const handleEnhanceDescription = async () => {
if (!newFeature.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
newFeature.description,
enhancementMode,
enhancementModel
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
toast.success("Description enhanced!");
} else {
toast.error(result?.error || "Failed to enhance description");
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
} finally {
setIsEnhancing(false);
}
};
const handleModelSelect = (model: AgentModel) => {
setNewFeature({
...newFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? newFeature.thinkingLevel
: "none",
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
setNewFeature({
...newFeature,
model,
thinkingLevel,
});
};
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent
compact={!isMaximized}
data-testid="add-feature-dialog"
onPointerDownOutside={(e: CustomEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
onInteractOutside={(e: CustomEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>
Create a new feature card for the Kanban board.
</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="prompt"
className="py-4 flex-1 min-h-0 flex flex-col"
>
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="prompt" data-testid="tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
Prompt
</TabsTrigger>
<TabsTrigger value="model" data-testid="tab-model">
<Settings2 className="w-4 h-4 mr-2" />
Model
</TabsTrigger>
<TabsTrigger value="options" data-testid="tab-options">
<SlidersHorizontal className="w-4 h-4 mr-2" />
Options
</TabsTrigger>
</TabsList>
{/* Prompt Tab */}
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
value={newFeature.description}
onChange={(value) => {
setNewFeature({ ...newFeature, description: value });
if (value.trim()) {
setDescriptionError(false);
}
}}
images={newFeature.imagePaths}
onImagesChange={(images) =>
setNewFeature({ ...newFeature, imagePaths: images })
}
placeholder="Describe the feature..."
previewMap={newFeaturePreviewMap}
onPreviewMapChange={setNewFeaturePreviewMap}
autoFocus
error={descriptionError}
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[200px] justify-between"
>
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhanceDescription}
disabled={!newFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category (optional)</Label>
<CategoryAutocomplete
value={newFeature.category}
onChange={(value) =>
setNewFeature({ ...newFeature, category: value })
}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="feature-category-input"
/>
</div>
{useWorktrees && (
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={newFeature.branchName}
onBranchNameChange={(value) =>
setNewFeature({ ...newFeature, branchName: value })
}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="feature"
/>
)}
{/* Priority Selector */}
<PrioritySelector
selectedPriority={newFeature.priority}
onPrioritySelect={(priority) =>
setNewFeature({ ...newFeature, priority })
}
testIdPrefix="priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Simple Mode Active
</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
data-testid="show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showAdvancedOptions ? "Hide" : "Show"} Advanced
</Button>
</div>
)}
{/* Quick Select Profile Section */}
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={newFeature.model}
selectedThinkingLevel={newFeature.thinkingLevel}
onSelect={handleProfileSelect}
showManageLink
onManageLinkClick={() => {
onOpenChange(false);
navigate({ to: "/profiles" });
}}
/>
{/* Separator */}
{aiProfiles.length > 0 &&
(!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showAdvancedOptions) && (
<>
<ModelSelector
selectedModel={newFeature.model}
onModelSelect={handleModelSelect}
/>
{newModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={newFeature.thinkingLevel}
onLevelSelect={(level) =>
setNewFeature({ ...newFeature, thinkingLevel: level })
}
/>
)}
</>
)}
</TabsContent>
{/* Options Tab */}
<TabsContent
value="options"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
onModeChange={setPlanningMode}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
featureDescription={newFeature.description}
testIdPrefix="add-feature"
compact
/>
<div className="border-t border-border my-4" />
{/* Testing Section */}
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) =>
setNewFeature({ ...newFeature, skipTests })
}
steps={newFeature.steps}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<HotkeyButton
onClick={handleAdd}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
disabled={
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
}
>
Add Feature
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,408 @@
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 { TaskProgressPanel } from "@/components/ui/task-progress-panel";
import { useAppStore } from "@/store/app-store";
import type { AutoModeEvent } from "@/types/electron";
interface AgentOutputModalProps {
open: boolean;
onClose: () => void;
featureDescription: string;
featureId: string;
/** The status of the feature - used to determine if spinner should be shown */
featureStatus?: string;
/** Called when a number key (0-9) is pressed while the modal is open */
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = "parsed" | "raw" | "changes";
export function AgentOutputModal({
open,
onClose,
featureDescription,
featureId,
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
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>("");
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
useEffect(() => {
if (autoScrollRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [output]);
// Load existing output from file
useEffect(() => {
if (!open) return;
const loadOutput = async () => {
const api = getElectronAPI();
if (!api) return;
setIsLoading(true);
try {
// Get current project path from store (we'll need to pass this)
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) {
setIsLoading(false);
return;
}
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
if (result.success) {
setOutput(result.content || "");
} else {
setOutput("");
}
} else {
setOutput("");
}
} catch (error) {
console.error("Failed to load output:", error);
setOutput("");
} finally {
setIsLoading(false);
}
};
loadOutput();
}, [open, featureId]);
// Listen to auto mode events and update output
useEffect(() => {
if (!open) return;
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only (skip events without featureId)
if ("featureId" in event && event.featureId !== featureId) {
return;
}
let newContent = "";
switch (event.type) {
case "auto_mode_progress":
newContent = event.content || "";
break;
case "auto_mode_tool":
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}\n` : ""
}`;
break;
case "auto_mode_phase":
const phaseEmoji =
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
newContent = `\n${phaseEmoji} ${event.message}\n`;
break;
case "auto_mode_error":
newContent = `\n❌ Error: ${event.error}\n`;
break;
case "auto_mode_ultrathink_preparation":
// Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`;
if (event.warnings && event.warnings.length > 0) {
prepContent += `\n⚠ Warnings:\n`;
event.warnings.forEach((warning: string) => {
prepContent += `${warning}\n`;
});
}
if (event.recommendations && event.recommendations.length > 0) {
prepContent += `\n💡 Recommendations:\n`;
event.recommendations.forEach((rec: string) => {
prepContent += `${rec}\n`;
});
}
if (event.estimatedCost !== undefined) {
prepContent += `\n💰 Estimated Cost: ~$${event.estimatedCost.toFixed(
2
)} per execution\n`;
}
if (event.estimatedTime) {
prepContent += `\n⏱ Estimated Time: ${event.estimatedTime}\n`;
}
newContent = prepContent;
break;
case "planning_started":
// Show when planning mode begins
if ("mode" in event && "message" in event) {
const modeLabel =
event.mode === "lite"
? "Lite"
: event.mode === "spec"
? "Spec"
: "Full";
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
}
break;
case "plan_approval_required":
// Show when plan requires approval
if ("planningMode" in event) {
newContent = `\n⏸ Plan generated - waiting for your approval...\n`;
}
break;
case "plan_approved":
// Show when plan is manually approved
if ("hasEdits" in event) {
newContent = event.hasEdits
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
: `\n✅ Plan approved - continuing to implementation...\n`;
}
break;
case "plan_auto_approved":
// Show when plan is auto-approved
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
break;
case "plan_revision_requested":
// Show when user requests plan revision
if ("planVersion" in event) {
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
}
break;
case "auto_mode_task_started":
// Show when a task starts
if ("taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
}
break;
case "auto_mode_task_complete":
// Show task completion progress
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
}
break;
case "auto_mode_phase_complete":
// Show phase completion for full mode
if ("phaseNumber" in event) {
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
}
break;
case "auto_mode_feature_complete":
const emoji = event.passes ? "✅" : "⚠️";
newContent = `\n${emoji} Task completed: ${event.message}\n`;
// Close the modal when the feature is verified (passes = true)
if (event.passes) {
// Small delay to show the completion message before closing
setTimeout(() => {
onClose();
}, 1500);
}
break;
}
if (newContent) {
// Only update local state - server is the single source of truth for file writes
setOutput((prev) => prev + newContent);
}
});
return () => {
unsubscribe();
};
}, [open, featureId]);
// Handle scroll to detect if user scrolled up
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScrollRef.current = isAtBottom;
};
// Handle number key presses while modal is open
useEffect(() => {
if (!open || !onNumberKeyPress) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Check if a number key (0-9) was pressed without modifiers
if (
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
/^[0-9]$/.test(event.key)
) {
event.preventDefault();
onNumberKeyPress(event.key);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [open, onNumberKeyPress]);
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode("parsed")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "parsed"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-parsed"
>
<List className="w-3.5 h-3.5" />
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")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "raw"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-raw"
>
<FileText className="w-3.5 h-3.5" />
Raw
</button>
</div>
</div>
<DialogDescription
className="mt-1 max-h-24 overflow-y-auto break-words"
data-testid="agent-output-description"
>
{featureDescription}
</DialogDescription>
</DialogHeader>
{/* Task Progress Panel - shows when tasks are being executed */}
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
className="flex-shrink-0 mx-1"
/>
{viewMode === "changes" ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
featureId={featureId}
compact={false}
useWorktrees={useWorktrees}
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] scrollbar-visible"
>
{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>
<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

@@ -0,0 +1,56 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Archive } from "lucide-react";
interface ArchiveAllVerifiedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
verifiedCount: number;
onConfirm: () => void;
}
export function ArchiveAllVerifiedDialog({
open,
onOpenChange,
verifiedCount,
onConfirm,
}: ArchiveAllVerifiedDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="archive-all-verified-dialog">
<DialogHeader>
<DialogTitle>Archive All Verified Features</DialogTitle>
<DialogDescription>
Are you sure you want to archive all verified features? They will be
moved to the archive box.
{verifiedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{verifiedCount} feature(s) will be archived.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="default" onClick={onConfirm} data-testid="confirm-archive-all-verified">
<Archive className="w-4 h-4 mr-2" />
Archive All
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,162 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { GitCommit, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CommitWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCommitted: () => void;
}
export function CommitWorktreeDialog({
open,
onOpenChange,
worktree,
onCommitted,
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCommit = async () => {
if (!worktree || !message.trim()) return;
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.commit) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.commit(worktree.path, message);
if (result.success && result.result) {
if (result.result.committed) {
toast.success("Changes committed", {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
});
onCommitted();
onOpenChange(false);
setMessage("");
} else {
toast.info("No changes to commit", {
description: result.result.message,
});
}
} else {
setError(result.error || "Failed to commit changes");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to commit");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) {
handleCommit();
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
Commit Changes
</DialogTitle>
<DialogDescription>
Commit changes in the{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>{" "}
worktree.
{worktree.changedFilesCount && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? "s" : ""} changed)
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="commit-message">Commit Message</Label>
<Textarea
id="commit-message"
placeholder="Describe your changes..."
value={message}
onChange={(e) => {
setMessage(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
className="min-h-[100px] font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<p className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd+Enter</kbd> to commit
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={isLoading || !message.trim()}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Committing...
</>
) : (
<>
<GitCommit className="w-4 h-4 mr-2" />
Commit
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,103 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ArchiveRestore, Trash2 } from "lucide-react";
import { Feature } from "@/store/app-store";
interface CompletedFeaturesModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
completedFeatures: Feature[];
onUnarchive: (feature: Feature) => void;
onDelete: (feature: Feature) => void;
}
export function CompletedFeaturesModal({
open,
onOpenChange,
completedFeatures,
onUnarchive,
onDelete,
}: CompletedFeaturesModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-6xl max-h-[90vh] flex flex-col"
data-testid="completed-features-modal"
>
<DialogHeader>
<DialogTitle>Completed Features</DialogTitle>
<DialogDescription>
{completedFeatures.length === 0
? "No completed features yet."
: `${completedFeatures.length} completed feature${
completedFeatures.length > 1 ? "s" : ""
}`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{completedFeatures.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<ArchiveRestore className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No completed features</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{completedFeatures.map((feature) => (
<Card
key={feature.id}
className="flex flex-col"
data-testid={`completed-card-${feature.id}`}
>
<CardHeader className="p-3 pb-2 flex-1">
<CardTitle className="text-sm leading-tight line-clamp-3">
{feature.description || feature.summary || feature.id}
</CardTitle>
<CardDescription className="text-xs mt-1 truncate">
{feature.category || "Uncategorized"}
</CardDescription>
</CardHeader>
<div className="p-3 pt-0 flex gap-2">
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => onUnarchive(feature)}
data-testid={`unarchive-${feature.id}`}
>
<ArchiveRestore className="w-3 h-3 mr-1" />
Restore
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(feature)}
data-testid={`delete-completed-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,151 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { GitBranchPlus, Loader2 } from "lucide-react";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CreateBranchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCreated: () => void;
}
export function CreateBranchDialog({
open,
onOpenChange,
worktree,
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setBranchName("");
setError(null);
}
}, [open]);
const handleCreate = async () => {
if (!worktree || !branchName.trim()) return;
// Basic validation
const invalidChars = /[\s~^:?*[\]\\]/;
if (invalidChars.test(branchName)) {
setError("Branch name contains invalid characters");
return;
}
setIsCreating(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error("Branch API not available");
return;
}
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
if (result.success && result.result) {
toast.success(result.result.message);
onCreated();
onOpenChange(false);
} else {
setError(result.error || "Failed to create branch");
}
} catch (err) {
console.error("Create branch failed:", err);
setError("Failed to create branch");
} finally {
setIsCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranchPlus className="w-5 h-5" />
Create New Branch
</DialogTitle>
<DialogDescription>
Create a new branch from <span className="font-mono text-foreground">{worktree?.branch || "current branch"}</span>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
placeholder="feature/my-new-feature"
value={branchName}
onChange={(e) => {
setBranchName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && branchName.trim() && !isCreating) {
handleCreate();
}
}}
disabled={isCreating}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!branchName.trim() || isCreating}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create Branch"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,390 @@
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { GitPullRequest, Loader2, ExternalLink } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CreatePRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCreated: (prUrl?: string) => void;
}
export function CreatePRDialog({
open,
onOpenChange,
worktree,
onCreated,
}: CreatePRDialogProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [baseBranch, setBaseBranch] = useState("main");
const [commitMessage, setCommitMessage] = useState("");
const [isDraft, setIsDraft] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Reset state when dialog opens or worktree changes
useEffect(() => {
if (open) {
// Reset form fields
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
// Also reset result states when opening for a new worktree
// This prevents showing stale PR URLs from previous worktrees
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
// Reset operation tracking
operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
}
}, [open, worktree?.path]);
const handleCreate = async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.createPR) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.createPR(worktree.path, {
commitMessage: commitMessage || undefined,
prTitle: title || worktree.branch,
prBody: body || `Changes from branch ${worktree.branch}`,
baseBranch,
draft: isDraft,
});
if (result.success && result.result) {
if (result.result.prCreated && result.result.prUrl) {
setPrUrl(result.result.prUrl);
// Mark operation as completed for refresh on close
operationCompletedRef.current = true;
toast.success("Pull request created!", {
description: `PR created from ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
// Don't call onCreated() here - keep dialog open to show success message
// onCreated() will be called when user closes the dialog
} else {
// Branch was pushed successfully
const prError = result.result.prError;
const hasBrowserUrl = !!result.result.browserUrl;
// Check if we should show browser fallback
if (!result.result.prCreated && hasBrowserUrl) {
// If gh CLI is not available, show browser fallback UI
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed successfully
operationCompletedRef.current = true;
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
});
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
setIsLoading(false);
return; // Don't close dialog, show browser fallback UI
}
// gh CLI is available but failed - show error with browser option
if (prError) {
// Parse common gh CLI errors for better messages
let errorMessage = prError;
if (prError.includes("No commits between")) {
errorMessage = "No new commits to create PR. Make sure your branch has changes compared to the base branch.";
} else if (prError.includes("already exists")) {
errorMessage = "A pull request already exists for this branch.";
} else if (prError.includes("not logged in") || prError.includes("auth")) {
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
}
// Show error but also provide browser option
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed even though PR creation failed
operationCompletedRef.current = true;
toast.error("PR creation failed", {
description: errorMessage,
duration: 8000,
});
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
setIsLoading(false);
return;
}
}
// Show success toast for push
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
});
// No browser URL available, just close
if (!result.result.prCreated) {
if (!hasBrowserUrl) {
toast.info("PR not created", {
description: "Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.",
duration: 8000,
});
}
}
onCreated();
onOpenChange(false);
}
} else {
setError(result.error || "Failed to create pull request");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create PR");
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
// Only call onCreated() if an actual operation completed
// This prevents unnecessary refreshes when user cancels
if (operationCompletedRef.current) {
// Pass the PR URL if one was created
onCreated(prUrl || undefined);
}
onOpenChange(false);
// State reset is handled by useEffect when open becomes false
};
if (!worktree) return null;
const shouldShowBrowserFallback = showBrowserFallback && browserUrl;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />
Create Pull Request
</DialogTitle>
<DialogDescription>
Push changes and create a pull request from{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
</DialogDescription>
</DialogHeader>
{prUrl ? (
<div className="py-6 text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10">
<GitPullRequest className="w-8 h-8 text-green-500" />
</div>
<div>
<h3 className="text-lg font-semibold">Pull Request Created!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your PR is ready for review
</p>
</div>
<div className="flex gap-2 justify-center">
<Button
onClick={() => window.open(prUrl, "_blank")}
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</div>
</div>
) : shouldShowBrowserFallback ? (
<div className="py-6 text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/10">
<GitPullRequest className="w-8 h-8 text-blue-500" />
</div>
<div>
<h3 className="text-lg font-semibold">Branch Pushed!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your changes have been pushed to GitHub.
<br />
Click below to create a pull request in your browser.
</p>
</div>
<div className="space-y-3">
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, "_blank");
}
}}
className="gap-2 w-full"
size="lg"
>
<ExternalLink className="w-4 h-4" />
Create PR in Browser
</Button>
<div className="p-2 bg-muted rounded text-xs break-all font-mono">
{browserUrl}
</div>
<p className="text-xs text-muted-foreground">
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
</p>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</DialogFooter>
</div>
</div>
) : (
<>
<div className="grid gap-4 py-4">
{worktree.hasChanges && (
<div className="grid gap-2">
<Label htmlFor="commit-message">
Commit Message{" "}
<span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="commit-message"
placeholder="Leave empty to auto-generate"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{worktree.changedFilesCount} uncommitted file(s) will be
committed
</p>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="pr-title">PR Title</Label>
<Input
id="pr-title"
placeholder={worktree.branch}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="pr-body">Description</Label>
<Textarea
id="pr-body"
placeholder="Describe the changes in this PR..."
value={body}
onChange={(e) => setBody(e.target.value)}
className="min-h-[80px]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<Input
id="base-branch"
placeholder="main"
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
className="font-mono text-sm"
/>
</div>
<div className="flex items-end">
<div className="flex items-center space-x-2">
<Checkbox
id="draft"
checked={isDraft}
onCheckedChange={(checked) => setIsDraft(checked === true)}
/>
<Label htmlFor="draft" className="cursor-pointer">
Create as draft
</Label>
</div>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<GitPullRequest className="w-4 h-4 mr-2" />
Create PR
</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,170 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GitBranch, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface CreatedWorktreeInfo {
path: string;
branch: string;
}
interface CreateWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
onCreated: (worktree: CreatedWorktreeInfo) => void;
}
export function CreateWorktreeDialog({
open,
onOpenChange,
projectPath,
onCreated,
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError("Branch name is required");
return;
}
// Validate branch name (git-compatible)
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError(
"Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes."
);
return;
}
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
toast.success(
`Worktree created for branch "${result.worktree.branch}"`,
{
description: result.worktree.isNew
? "New branch created"
: "Using existing branch",
}
);
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
onOpenChange(false);
setBranchName("");
} else {
setError(result.error || "Failed to create worktree");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create worktree");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading && branchName.trim()) {
handleCreate();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" />
Create New Worktree
</DialogTitle>
<DialogDescription>
Create a new git worktree with its own branch. This allows you to
work on multiple features in parallel.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
placeholder="feature/my-new-feature"
value={branchName}
onChange={(e) => {
setBranchName(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>Examples:</p>
<ul className="list-disc list-inside pl-2 space-y-0.5">
<li>
<code className="bg-muted px-1 rounded">feature/user-auth</code>
</li>
<li>
<code className="bg-muted px-1 rounded">fix/login-bug</code>
</li>
<li>
<code className="bg-muted px-1 rounded">hotfix/security-patch</code>
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={isLoading || !branchName.trim()}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<GitBranch className="w-4 h-4 mr-2" />
Create Worktree
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,53 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
interface DeleteAllVerifiedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
verifiedCount: number;
onConfirm: () => void;
}
export function DeleteAllVerifiedDialog({
open,
onOpenChange,
verifiedCount,
onConfirm,
}: DeleteAllVerifiedDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="delete-all-verified-dialog">
<DialogHeader>
<DialogTitle>Delete All Verified Features</DialogTitle>
<DialogDescription>
Are you sure you want to delete all verified features? This action
cannot be undone.
{verifiedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{verifiedCount} feature(s) will be deleted.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-all-verified">
<Trash2 className="w-4 h-4 mr-2" />
Delete All
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,66 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { Feature } from "@/store/app-store";
interface DeleteCompletedFeatureDialogProps {
feature: Feature | null;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteCompletedFeatureDialog({
feature,
onClose,
onConfirm,
}: DeleteCompletedFeatureDialogProps) {
if (!feature) return null;
return (
<Dialog open={!!feature} onOpenChange={(open) => !open && onClose()}>
<DialogContent data-testid="delete-completed-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete Feature
</DialogTitle>
<DialogDescription>
Are you sure you want to permanently delete this feature?
<span className="block mt-2 font-medium text-foreground">
&quot;{feature.description?.slice(0, 100)}
{(feature.description?.length ?? 0) > 100 ? "..." : ""}&quot;
</span>
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={onClose}
data-testid="cancel-delete-completed-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
data-testid="confirm-delete-completed-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Loader2, Trash2, AlertTriangle, FileWarning } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface DeleteWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
}
export function DeleteWorktreeDialog({
open,
onOpenChange,
projectPath,
worktree,
onDeleted,
affectedFeatureCount = 0,
}: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleDelete = async () => {
if (!worktree) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.delete) {
toast.error("Worktree API not available");
return;
}
const result = await api.worktree.delete(
projectPath,
worktree.path,
deleteBranch
);
if (result.success) {
toast.success(`Worktree deleted`, {
description: deleteBranch
? `Branch "${worktree.branch}" was also deleted`
: `Branch "${worktree.branch}" was kept`,
});
onDeleted(worktree, deleteBranch);
onOpenChange(false);
setDeleteBranch(false);
} else {
toast.error("Failed to delete worktree", {
description: result.error,
});
}
} catch (err) {
toast.error("Failed to delete worktree", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsLoading(false);
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-destructive" />
Delete Worktree
</DialogTitle>
<DialogDescription className="space-y-3">
<span>
Are you sure you want to delete the worktree for branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
?
</span>
{affectedFeatureCount > 0 && (
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20 mt-2">
<FileWarning className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "}
{affectedFeatureCount !== 1 ? "are" : "is"} assigned to this
branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be
unassigned and moved to the main worktree.
</span>
</div>
)}
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted
change(s). These will be lost if you proceed.
</span>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2 py-4">
<Checkbox
id="delete-branch"
checked={deleteBranch}
onCheckedChange={(checked) => setDeleteBranch(checked === true)}
/>
<Label htmlFor="delete-branch" className="text-sm cursor-pointer">
Also delete the branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
</Label>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,232 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Feature } from "@/store/app-store";
import { AlertCircle, CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
interface DependencyTreeDialogProps {
open: boolean;
onClose: () => void;
feature: Feature | null;
allFeatures: Feature[];
}
export function DependencyTreeDialog({
open,
onClose,
feature,
allFeatures,
}: DependencyTreeDialogProps) {
const [dependencyTree, setDependencyTree] = useState<{
dependencies: Feature[];
dependents: Feature[];
}>({ dependencies: [], dependents: [] });
useEffect(() => {
if (!feature) return;
// Find features this depends on
const dependencies = (feature.dependencies || [])
.map((depId) => allFeatures.find((f) => f.id === depId))
.filter((f): f is Feature => f !== undefined);
// Find features that depend on this one
const dependents = allFeatures.filter((f) =>
f.dependencies?.includes(feature.id)
);
setDependencyTree({ dependencies, dependents });
}, [feature, allFeatures]);
if (!feature) return null;
const getStatusIcon = (status: Feature["status"]) => {
switch (status) {
case "completed":
case "verified":
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case "in_progress":
case "waiting_approval":
return <Circle className="w-4 h-4 text-blue-500 fill-blue-500/20" />;
default:
return <Circle className="w-4 h-4 text-muted-foreground/50" />;
}
};
const getPriorityBadge = (priority?: number) => {
if (!priority) return null;
return (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-medium",
priority === 1 && "bg-red-500/20 text-red-500",
priority === 2 && "bg-yellow-500/20 text-yellow-500",
priority === 3 && "bg-blue-500/20 text-blue-500"
)}
>
P{priority}
</span>
);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Dependency Tree</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* Current Feature */}
<div className="border-2 border-primary rounded-lg p-4 bg-primary/5">
<div className="flex items-center gap-3 mb-2">
{getStatusIcon(feature.status)}
<h3 className="font-semibold text-sm">Current Feature</h3>
{getPriorityBadge(feature.priority)}
</div>
<p className="text-sm text-muted-foreground">{feature.description}</p>
<p className="text-xs text-muted-foreground/70 mt-2">
Category: {feature.category}
</p>
</div>
{/* Dependencies (what this feature needs) */}
<div>
<div className="flex items-center gap-2 mb-3">
<h3 className="font-semibold text-sm">
Dependencies ({dependencyTree.dependencies.length})
</h3>
<span className="text-xs text-muted-foreground">
This feature requires:
</span>
</div>
{dependencyTree.dependencies.length === 0 ? (
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
No dependencies - this feature can be started independently
</div>
) : (
<div className="space-y-2">
{dependencyTree.dependencies.map((dep) => (
<div
key={dep.id}
className={cn(
"border rounded-lg p-3 transition-colors",
dep.status === "completed" || dep.status === "verified"
? "bg-green-500/5 border-green-500/20"
: "bg-muted/30 border-border"
)}
>
<div className="flex items-center gap-3 mb-1">
{getStatusIcon(dep.status)}
<span className="text-sm font-medium flex-1">
{dep.description.slice(0, 100)}
{dep.description.length > 100 && "..."}
</span>
{getPriorityBadge(dep.priority)}
</div>
<div className="flex items-center gap-3 ml-7">
<span className="text-xs text-muted-foreground">
{dep.category}
</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
dep.status === "completed" || dep.status === "verified"
? "bg-green-500/20 text-green-600"
: dep.status === "in_progress"
? "bg-blue-500/20 text-blue-600"
: "bg-muted text-muted-foreground"
)}
>
{dep.status.replace(/_/g, " ")}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Dependents (what depends on this feature) */}
<div>
<div className="flex items-center gap-2 mb-3">
<h3 className="font-semibold text-sm">
Dependents ({dependencyTree.dependents.length})
</h3>
<span className="text-xs text-muted-foreground">
Features blocked by this:
</span>
</div>
{dependencyTree.dependents.length === 0 ? (
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
No dependents - no other features are waiting on this one
</div>
) : (
<div className="space-y-2">
{dependencyTree.dependents.map((dependent) => (
<div
key={dependent.id}
className="border rounded-lg p-3 bg-muted/30"
>
<div className="flex items-center gap-3 mb-1">
{getStatusIcon(dependent.status)}
<span className="text-sm font-medium flex-1">
{dependent.description.slice(0, 100)}
{dependent.description.length > 100 && "..."}
</span>
{getPriorityBadge(dependent.priority)}
</div>
<div className="flex items-center gap-3 ml-7">
<span className="text-xs text-muted-foreground">
{dependent.category}
</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
dependent.status === "completed" ||
dependent.status === "verified"
? "bg-green-500/20 text-green-600"
: dependent.status === "in_progress"
? "bg-blue-500/20 text-blue-600"
: "bg-muted text-muted-foreground"
)}
>
{dependent.status.replace(/_/g, " ")}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Warning for incomplete dependencies */}
{dependencyTree.dependencies.some(
(d) => d.status !== "completed" && d.status !== "verified"
) && (
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-yellow-700 dark:text-yellow-500">
Incomplete Dependencies
</p>
<p className="text-yellow-600 dark:text-yellow-400 mt-1">
This feature has dependencies that aren't completed yet.
Consider completing them first for a smoother implementation.
</p>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,550 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
GitBranch,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
import {
Feature,
AgentModel,
ThinkingLevel,
AIProfile,
useAppStore,
PlanningMode,
} from "@/store/app-store";
import {
ModelSelector,
ThinkingLevelSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DependencyTreeDialog } from "./dependency-tree-dialog";
interface EditFeatureDialogProps {
feature: Feature | null;
onClose: () => void;
onUpdate: (
featureId: string,
updates: {
category: string;
description: string;
steps: string[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
allFeatures: Feature[];
}
export function EditFeatureDialog({
feature,
onClose,
onUpdate,
categorySuggestions,
branchSuggestions,
branchCardCounts,
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
// If feature has no branchName, default to using current branch
return !feature?.branchName;
});
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
useEffect(() => {
setEditingFeature(feature);
if (feature) {
setPlanningMode(feature.planningMode ?? 'skip');
setRequirePlanApproval(feature.requirePlanApproval ?? false);
// If feature has no branchName, default to using current branch
setUseCurrentBranch(!feature.branchName);
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
}
}, [feature]);
const handleUpdate = () => {
if (!editingFeature) return;
// Validate branch selection when "other branch" is selected and branch selector is enabled
const isBranchSelectorEnabled = editingFeature.status === "backlog";
if (
useWorktrees &&
isBranchSelectorEnabled &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
) {
toast.error("Please select a branch name");
return;
}
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none";
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? (currentBranch || "")
: editingFeature.branchName || "";
const updates = {
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
skipTests: editingFeature.skipTests ?? false,
model: selectedModel,
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
branchName: finalBranchName,
priority: editingFeature.priority ?? 2,
planningMode,
requirePlanApproval,
};
onUpdate(editingFeature.id, updates);
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
onClose();
};
const handleDialogClose = (open: boolean) => {
if (!open) {
onClose();
}
};
const handleModelSelect = (model: AgentModel) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: "none",
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
model,
thinkingLevel,
});
};
const handleEnhanceDescription = async () => {
if (!editingFeature?.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementModel
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!");
} else {
toast.error(result?.error || "Failed to enhance description");
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
} finally {
setIsEnhancing(false);
}
};
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
if (!editingFeature) {
return null;
}
return (
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
<DialogContent
compact={!isMaximized}
data-testid="edit-feature-dialog"
onPointerDownOutside={(e: CustomEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
onInteractOutside={(e: CustomEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>Modify the feature details.</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="prompt"
className="py-4 flex-1 min-h-0 flex flex-col"
>
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
Prompt
</TabsTrigger>
<TabsTrigger value="model" data-testid="edit-tab-model">
<Settings2 className="w-4 h-4 mr-2" />
Model
</TabsTrigger>
<TabsTrigger value="options" data-testid="edit-tab-options">
<SlidersHorizontal className="w-4 h-4 mr-2" />
Options
</TabsTrigger>
</TabsList>
{/* Prompt Tab */}
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
value={editingFeature.description}
onChange={(value) =>
setEditingFeature({
...editingFeature,
description: value,
})
}
images={editingFeature.imagePaths ?? []}
onImagesChange={(images) =>
setEditingFeature({
...editingFeature,
imagePaths: images,
})
}
placeholder="Describe the feature..."
previewMap={editFeaturePreviewMap}
onPreviewMapChange={setEditFeaturePreviewMap}
data-testid="edit-feature-description"
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[180px] justify-between"
>
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhanceDescription}
disabled={!editingFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Category (optional)</Label>
<CategoryAutocomplete
value={editingFeature.category}
onChange={(value) =>
setEditingFeature({
...editingFeature,
category: value,
})
}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="edit-feature-category"
/>
</div>
{useWorktrees && (
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ""}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
branchName: value,
})
}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== "backlog"}
testIdPrefix="edit-feature"
/>
)}
{/* Priority Selector */}
<PrioritySelector
selectedPriority={editingFeature.priority ?? 2}
onPrioritySelect={(priority) =>
setEditingFeature({
...editingFeature,
priority,
})
}
testIdPrefix="edit-priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Simple Mode Active
</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setShowEditAdvancedOptions(!showEditAdvancedOptions)
}
data-testid="edit-show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
</Button>
</div>
)}
{/* Quick Select Profile Section */}
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={editingFeature.model ?? "opus"}
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
onSelect={handleProfileSelect}
testIdPrefix="edit-profile-quick-select"
/>
{/* Separator */}
{aiProfiles.length > 0 &&
(!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<>
<ModelSelector
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
onModelSelect={handleModelSelect}
testIdPrefix="edit-model-select"
/>
{editModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={editingFeature.thinkingLevel ?? "none"}
onLevelSelect={(level) =>
setEditingFeature({
...editingFeature,
thinkingLevel: level,
})
}
testIdPrefix="edit-thinking-level"
/>
)}
</>
)}
</TabsContent>
{/* Options Tab */}
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
onModeChange={setPlanningMode}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
featureDescription={editingFeature.description}
testIdPrefix="edit-feature"
compact
/>
<div className="border-t border-border my-4" />
{/* Testing Section */}
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>
setEditingFeature({ ...editingFeature, skipTests })
}
steps={editingFeature.steps}
onStepsChange={(steps) =>
setEditingFeature({ ...editingFeature, steps })
}
testIdPrefix="edit"
/>
</TabsContent>
</Tabs>
<DialogFooter className="sm:!justify-between">
<Button
variant="outline"
onClick={() => setShowDependencyTree(true)}
className="gap-2 h-10"
>
<GitBranch className="w-4 h-4" />
View Dependency Tree
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === "backlog" &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
}
>
Save Changes
</HotkeyButton>
</div>
</DialogFooter>
</DialogContent>
<DependencyTreeDialog
open={showDependencyTree}
onClose={() => setShowDependencyTree(false)}
feature={editingFeature}
allFeatures={allFeatures}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,539 @@
import { useEffect, useRef, useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Loader2,
Lightbulb,
Download,
StopCircle,
ChevronDown,
ChevronRight,
RefreshCw,
Shield,
Zap,
} from "lucide-react";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
import { useAppStore, Feature } from "@/store/app-store";
import { toast } from "sonner";
interface FeatureSuggestionsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
// Props to persist state across dialog open/close
suggestions: FeatureSuggestion[];
setSuggestions: (suggestions: FeatureSuggestion[]) => void;
isGenerating: boolean;
setIsGenerating: (generating: boolean) => void;
}
// Configuration for each suggestion type
const suggestionTypeConfig: Record<SuggestionType, {
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}> = {
features: {
label: "Feature Suggestions",
icon: Lightbulb,
description: "Discover missing features and improvements",
color: "text-yellow-500",
},
refactoring: {
label: "Refactoring Suggestions",
icon: RefreshCw,
description: "Find code smells and refactoring opportunities",
color: "text-blue-500",
},
security: {
label: "Security Suggestions",
icon: Shield,
description: "Identify security vulnerabilities and issues",
color: "text-red-500",
},
performance: {
label: "Performance Suggestions",
icon: Zap,
description: "Discover performance bottlenecks and optimizations",
color: "text-green-500",
},
};
export function FeatureSuggestionsDialog({
open,
onClose,
projectPath,
suggestions,
setSuggestions,
isGenerating,
setIsGenerating,
}: FeatureSuggestionsDialogProps) {
const [progress, setProgress] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false);
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const { features, setFeatures } = useAppStore();
// Initialize selectedIds when suggestions change
useEffect(() => {
if (suggestions.length > 0 && selectedIds.size === 0) {
setSelectedIds(new Set(suggestions.map((s) => s.id)));
}
}, [suggestions, selectedIds.size]);
// Auto-scroll progress when new content arrives
useEffect(() => {
if (autoScrollRef.current && scrollRef.current && isGenerating) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [progress, isGenerating]);
// Listen for suggestion events when dialog is open
useEffect(() => {
if (!open) return;
const api = getElectronAPI();
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
if (event.type === "suggestions_progress") {
setProgress((prev) => [...prev, event.content || ""]);
} else if (event.type === "suggestions_tool") {
const toolName = event.tool || "Unknown Tool";
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
} else if (event.type === "suggestions_complete") {
setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) {
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info("No suggestions generated. Try again.");
}
} else if (event.type === "suggestions_error") {
setIsGenerating(false);
toast.error(`Error: ${event.error}`);
}
});
return () => {
unsubscribe();
};
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions for a specific type
const handleGenerate = useCallback(async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error("Suggestions API not available");
return;
}
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || "Failed to start generation");
setIsGenerating(false);
}
} catch (error) {
console.error("Failed to generate suggestions:", error);
toast.error("Failed to start generation");
setIsGenerating(false);
}
}, [projectPath, setIsGenerating, setSuggestions]);
// Stop generating
const handleStop = useCallback(async () => {
const api = getElectronAPI();
if (!api?.suggestions) return;
try {
await api.suggestions.stop();
setIsGenerating(false);
toast.info("Generation stopped");
} catch (error) {
console.error("Failed to stop generation:", error);
}
}, [setIsGenerating]);
// Toggle suggestion selection
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Toggle expand/collapse for a suggestion
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Select/deselect all
const toggleSelectAll = useCallback(() => {
if (selectedIds.size === suggestions.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(suggestions.map((s) => s.id)));
}
}, [selectedIds.size, suggestions]);
// Import selected suggestions as features
const handleImport = useCallback(async () => {
if (selectedIds.size === 0) {
toast.warning("No suggestions selected");
return;
}
setIsImporting(true);
try {
const api = getElectronAPI();
const selectedSuggestions = suggestions.filter((s) =>
selectedIds.has(s.id)
);
// Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category,
description: s.description,
steps: s.steps,
status: "backlog" as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
}));
// Create each new feature using the features API
if (api.features) {
for (const feature of newFeatures) {
await api.features.create(projectPath, feature);
}
}
// Merge with existing features for store update
const updatedFeatures = [...features, ...newFeatures];
// Update store
setFeatures(updatedFeatures);
toast.success(`Imported ${newFeatures.length} features to backlog!`);
// Clear suggestions after importing
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
onClose();
} catch (error) {
console.error("Failed to import features:", error);
toast.error("Failed to import features");
} finally {
setIsImporting(false);
}
}, [selectedIds, suggestions, features, setFeatures, setSuggestions, projectPath, onClose]);
// Handle scroll to detect if user scrolled up
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScrollRef.current = isAtBottom;
};
// Go back to type selection
const handleBackToSelection = useCallback(() => {
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
setCurrentSuggestionType(null);
}, [setSuggestions]);
const hasStarted = progress.length > 0 || suggestions.length > 0;
const hasSuggestions = suggestions.length > 0;
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[70vw] max-w-[70vw] max-h-[85vh] flex flex-col"
data-testid="feature-suggestions-dialog"
>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
{currentConfig ? (
<>
<currentConfig.icon className={`w-5 h-5 ${currentConfig.color}`} />
{currentConfig.label}
</>
) : (
<>
<Lightbulb className="w-5 h-5 text-yellow-500" />
AI Suggestions
</>
)}
</DialogTitle>
<DialogDescription>
{currentConfig
? currentConfig.description
: "Analyze your project to discover improvements. Choose a suggestion type below."}
</DialogDescription>
</DialogHeader>
{!hasStarted ? (
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions.
Choose what type of analysis you want to perform:
</p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(Object.entries(suggestionTypeConfig) as [SuggestionType, typeof suggestionTypeConfig[SuggestionType]][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">{config.label.replace(" Suggestions", "")}</div>
<div className="text-xs text-muted-foreground mt-1">
{config.description}
</div>
</div>
</Button>
);
}
)}
</div>
</div>
) : isGenerating ? (
// Generating state - show progress
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Analyzing project...
</div>
<Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</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-[200px] max-h-[400px]"
>
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join("")}
</div>
</div>
</div>
) : hasSuggestions ? (
// Results state - show suggestions list
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length
? "Deselect All"
: "Select All"}
</Button>
</div>
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto space-y-2 min-h-[200px] max-h-[400px] pr-2"
>
{suggestions.map((suggestion) => {
const isSelected = selectedIds.has(suggestion.id);
const isExpanded = expandedIds.has(suggestion.id);
return (
<div
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
data-testid={`suggestion-${suggestion.id}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={suggestion.id}
checked={isSelected}
onCheckedChange={() => toggleSelection(suggestion.id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => toggleExpanded(suggestion.id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
#{suggestion.priority}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{suggestion.category}
</span>
</div>
<Label
htmlFor={suggestion.id}
className="text-sm font-medium cursor-pointer"
>
{suggestion.description}
</Label>
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">
{suggestion.reasoning}
</p>
)}
{suggestion.steps.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Implementation Steps:
</p>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-0.5">
{suggestion.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
// No results state
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<p className="text-muted-foreground mb-4">
No suggestions were generated. Try running the analysis again.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back to Selection
</Button>
{currentSuggestionType && (
<Button onClick={() => handleGenerate(currentSuggestionType)}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
)}
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
{hasSuggestions && (
<div className="flex gap-2 w-full justify-between">
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back
</Button>
{currentSuggestionType && (
<Button variant="outline" onClick={() => handleGenerate(currentSuggestionType)}>
{currentConfig && <currentConfig.icon className="w-4 h-4 mr-2" />}
Regenerate
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? "s" : ""}
</HotkeyButton>
</div>
</div>
)}
{!hasSuggestions && !isGenerating && hasStarted && (
<Button variant="ghost" onClick={onClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,120 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare } from "lucide-react";
import { Feature } from "@/store/app-store";
interface FollowUpDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
feature: Feature | null;
prompt: string;
imagePaths: DescriptionImagePath[];
previewMap: ImagePreviewMap;
onPromptChange: (prompt: string) => void;
onImagePathsChange: (paths: DescriptionImagePath[]) => void;
onPreviewMapChange: (map: ImagePreviewMap) => void;
onSend: () => void;
isMaximized: boolean;
}
export function FollowUpDialog({
open,
onOpenChange,
feature,
prompt,
imagePaths,
previewMap,
onPromptChange,
onImagePathsChange,
onPreviewMapChange,
onSend,
isMaximized,
}: FollowUpDialogProps) {
const handleClose = (open: boolean) => {
if (!open) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
compact={!isMaximized}
data-testid="follow-up-dialog"
onKeyDown={(e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
e.preventDefault();
onSend();
}
}}
>
<DialogHeader>
<DialogTitle>Follow-Up Prompt</DialogTitle>
<DialogDescription>
Send additional instructions to continue working on this feature.
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 100)}
{feature.description.length > 100 ? "..." : ""}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
<div className="space-y-2">
<Label htmlFor="follow-up-prompt">Instructions</Label>
<DescriptionImageDropZone
value={prompt}
onChange={onPromptChange}
images={imagePaths}
onImagesChange={onImagePathsChange}
placeholder="Describe what needs to be fixed or changed..."
previewMap={previewMap}
onPreviewMapChange={onPreviewMapChange}
/>
</div>
<p className="text-xs text-muted-foreground">
The agent will continue from where it left off, using the existing
context. You can attach screenshots to help explain the issue.
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
}}
>
Cancel
</Button>
<HotkeyButton
onClick={onSend}
disabled={!prompt.trim()}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-follow-up"
>
<MessageSquare className="w-4 h-4 mr-2" />
Send Follow-Up
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,9 @@
export { AddFeatureDialog } from "./add-feature-dialog";
export { AgentOutputModal } from "./agent-output-modal";
export { CompletedFeaturesModal } from "./completed-features-modal";
export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog";
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
export { EditFeatureDialog } from "./edit-feature-dialog";
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
export { FollowUpDialog } from "./follow-up-dialog";
export { PlanApprovalDialog } from "./plan-approval-dialog";

View File

@@ -0,0 +1,220 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Markdown } from "@/components/ui/markdown";
import { Label } from "@/components/ui/label";
import { Feature } from "@/store/app-store";
import { Check, RefreshCw, Edit2, Eye, Loader2 } from "lucide-react";
interface PlanApprovalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
feature: Feature | null;
planContent: string;
onApprove: (editedPlan?: string) => void;
onReject: (feedback?: string) => void;
isLoading?: boolean;
viewOnly?: boolean;
}
export function PlanApprovalDialog({
open,
onOpenChange,
feature,
planContent,
onApprove,
onReject,
isLoading = false,
viewOnly = false,
}: PlanApprovalDialogProps) {
const [isEditMode, setIsEditMode] = useState(false);
const [editedPlan, setEditedPlan] = useState(planContent);
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
const [rejectFeedback, setRejectFeedback] = useState("");
// Reset state when dialog opens or plan content changes
useEffect(() => {
if (open) {
setEditedPlan(planContent);
setIsEditMode(false);
setShowRejectFeedback(false);
setRejectFeedback("");
}
}, [open, planContent]);
const handleApprove = () => {
// Only pass edited plan if it was modified
const wasEdited = editedPlan !== planContent;
onApprove(wasEdited ? editedPlan : undefined);
};
const handleReject = () => {
if (showRejectFeedback) {
onReject(rejectFeedback.trim() || undefined);
} else {
setShowRejectFeedback(true);
}
};
const handleCancelReject = () => {
setShowRejectFeedback(false);
setRejectFeedback("");
};
const handleClose = (open: boolean) => {
if (!open && !isLoading) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="max-w-4xl"
data-testid="plan-approval-dialog"
>
<DialogHeader>
<DialogTitle>{viewOnly ? "View Plan" : "Review Plan"}</DialogTitle>
<DialogDescription>
{viewOnly
? "View the generated plan for this feature."
: "Review the generated plan before implementation begins."}
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 150)}
{feature.description.length > 150 ? "..." : ""}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
{/* Mode Toggle - Only show when not in viewOnly mode */}
{!viewOnly && (
<div className="flex items-center justify-between mb-3">
<Label className="text-sm text-muted-foreground">
{isEditMode ? "Edit Mode" : "View Mode"}
</Label>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditMode(!isEditMode)}
disabled={isLoading}
>
{isEditMode ? (
<>
<Eye className="w-4 h-4 mr-2" />
View
</>
) : (
<>
<Edit2 className="w-4 h-4 mr-2" />
Edit
</>
)}
</Button>
</div>
)}
{/* Plan Content */}
<div className="flex-1 overflow-y-auto max-h-[70vh] border border-border rounded-lg">
{isEditMode && !viewOnly ? (
<Textarea
value={editedPlan}
onChange={(e) => setEditedPlan(e.target.value)}
className="min-h-[400px] h-full w-full border-0 rounded-lg resize-none font-mono text-sm"
placeholder="Enter plan content..."
disabled={isLoading}
/>
) : (
<div className="p-4 overflow-auto">
<Markdown>{editedPlan || "No plan content available."}</Markdown>
</div>
)}
</div>
{/* Revision Feedback Section - Only show when not in viewOnly mode */}
{showRejectFeedback && !viewOnly && (
<div className="mt-4 space-y-2">
<Label htmlFor="reject-feedback">What changes would you like?</Label>
<Textarea
id="reject-feedback"
value={rejectFeedback}
onChange={(e) => setRejectFeedback(e.target.value)}
placeholder="Describe the changes you'd like to see in the plan..."
className="min-h-[80px]"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Leave empty to cancel the feature, or provide feedback to regenerate the plan.
</p>
</div>
)}
</div>
<DialogFooter className="flex-shrink-0 gap-2">
{viewOnly ? (
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
) : showRejectFeedback ? (
<>
<Button
variant="ghost"
onClick={handleCancelReject}
disabled={isLoading}
>
Back
</Button>
<Button
variant="secondary"
onClick={handleReject}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
{rejectFeedback.trim() ? "Revise Plan" : "Cancel Feature"}
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={handleReject}
disabled={isLoading}
>
<RefreshCw className="w-4 h-4 mr-2" />
Request Changes
</Button>
<Button
onClick={handleApprove}
disabled={isLoading}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Check className="w-4 h-4 mr-2" />
)}
Approve
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,10 @@
export { useBoardFeatures } from "./use-board-features";
export { useBoardDragDrop } from "./use-board-drag-drop";
export { useBoardActions } from "./use-board-actions";
export { useBoardKeyboardShortcuts } from "./use-board-keyboard-shortcuts";
export { useBoardColumnFeatures } from "./use-board-column-features";
export { useBoardEffects } from "./use-board-effects";
export { useBoardBackground } from "./use-board-background";
export { useBoardPersistence } from "./use-board-persistence";
export { useFollowUpState } from "./use-follow-up-state";
export { useSuggestionsState } from "./use-suggestions-state";

View File

@@ -0,0 +1,896 @@
import { useCallback } from "react";
import {
Feature,
FeatureImage,
AgentModel,
ThinkingLevel,
PlanningMode,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useAutoMode } from "@/hooks/use-auto-mode";
import { truncateDescription } from "@/lib/utils";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
features: Feature[];
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
setShowOutputModal: (show: boolean) => void;
setOutputFeature: (feature: Feature | null) => void;
followUpFeature: Feature | null;
followUpPrompt: string;
followUpImagePaths: DescriptionImagePath[];
setFollowUpFeature: (feature: Feature | null) => void;
setFollowUpPrompt: (prompt: string) => void;
setFollowUpImagePaths: (paths: DescriptionImagePath[]) => void;
setFollowUpPreviewMap: (map: Map<string, string>) => void;
setShowFollowUpDialog: (show: boolean) => void;
inProgressFeaturesForShortcuts: Feature[];
outputFeature: Feature | null;
projectPath: string | null;
onWorktreeCreated?: () => void;
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
}
export function useBoardActions({
currentProject,
features,
runningAutoTasks,
loadFeatures,
persistFeatureCreate,
persistFeatureUpdate,
persistFeatureDelete,
saveCategory,
setEditingFeature,
setShowOutputModal,
setOutputFeature,
followUpFeature,
followUpPrompt,
followUpImagePaths,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setShowFollowUpDialog,
inProgressFeaturesForShortcuts,
outputFeature,
projectPath,
onWorktreeCreated,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const {
addFeature,
updateFeature,
removeFeature,
moveFeature,
useWorktrees,
enableDependencyBlocking,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore();
const autoMode = useAutoMode();
// Worktrees are created when adding/editing features with a branch name
// This ensures the worktree exists before the feature starts execution
const handleAddFeature = useCallback(
async (featureData: {
category: string;
description: string;
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => {
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
// Non-empty string is the actual branch name (for non-primary worktrees)
const finalBranchName = featureData.branchName || undefined;
// If worktrees enabled and a branch is specified, create the worktree now
// This ensures the worktree exists before the feature starts
if (useWorktrees && finalBranchName && currentProject) {
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
if (result.success) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
}`
);
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
});
}
}
} catch (error) {
console.error("[Board] Error creating worktree:", error);
toast.error("Failed to create worktree", {
description:
error instanceof Error ? error.message : "An error occurred",
});
}
}
const newFeatureData = {
...featureData,
status: "backlog" as const,
branchName: finalBranchName,
};
const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category);
},
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
);
const handleUpdateFeature = useCallback(
async (
featureId: string,
updates: {
category: string;
description: string;
steps: string[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
}
) => {
const finalBranchName = updates.branchName || undefined;
// If worktrees enabled and a branch is specified, create the worktree now
// This ensures the worktree exists before the feature starts
if (useWorktrees && finalBranchName && currentProject) {
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
if (result.success) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
}`
);
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
});
}
}
} catch (error) {
console.error("[Board] Error creating worktree:", error);
toast.error("Failed to create worktree", {
description:
error instanceof Error ? error.message : "An error occurred",
});
}
}
const finalUpdates = {
...updates,
branchName: finalBranchName,
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
if (updates.category) {
saveCategory(updates.category);
}
setEditingFeature(null);
},
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
);
const handleDeleteFeature = useCallback(
async (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (!feature) return;
const isRunning = runningAutoTasks.includes(featureId);
if (isRunning) {
try {
await autoMode.stopFeature(featureId);
toast.success("Agent stopped", {
description: `Stopped and deleted: ${truncateDescription(
feature.description
)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature before delete:", error);
toast.error("Failed to stop agent", {
description: "The feature will still be deleted.",
});
}
}
if (feature.imagePaths && feature.imagePaths.length > 0) {
try {
const api = getElectronAPI();
for (const imagePathObj of feature.imagePaths) {
try {
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(
`[Board] Failed to delete image ${imagePathObj.path}:`,
error
);
}
}
} catch (error) {
console.error(
`[Board] Error deleting images for feature ${featureId}:`,
error
);
}
}
removeFeature(featureId);
persistFeatureDelete(featureId);
},
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
);
const handleRunFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
return;
}
// Server derives workDir from feature.branchName at execution time
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
useWorktrees
// No worktreePath - server derives from feature.branchName
);
if (result.success) {
console.log(
"[Board] Feature run started successfully, branch:",
feature.branchName || "default"
);
} else {
console.error("[Board] Failed to run feature:", result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error running feature:", error);
await loadFeatures();
}
},
[currentProject, useWorktrees, loadFeatures]
);
const handleStartImplementation = useCallback(
async (feature: Feature) => {
if (!autoMode.canStartNewTask) {
toast.error("Concurrency limit reached", {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? "s" : ""
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
}
// Check for blocking dependencies and show warning if enabled
if (enableDependencyBlocking) {
const blockingDeps = getBlockingDependencies(feature, features);
if (blockingDeps.length > 0) {
const depDescriptions = blockingDeps
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep ? truncateDescription(dep.description, 40) : depId;
})
.join(", ");
toast.warning("Starting feature with incomplete dependencies", {
description: `This feature depends on: ${depDescriptions}`,
});
}
}
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(feature);
return true;
},
[
autoMode,
enableDependencyBlocking,
features,
updateFeature,
persistFeatureUpdate,
handleRunFeature,
]
);
const handleVerifyFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
return;
}
const result = await api.autoMode.verifyFeature(
currentProject.path,
feature.id
);
if (result.success) {
console.log("[Board] Feature verification started successfully");
} else {
console.error("[Board] Failed to verify feature:", result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error verifying feature:", error);
await loadFeatures();
}
},
[currentProject, loadFeatures]
);
const handleResumeFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
return;
}
const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id,
useWorktrees
);
if (result.success) {
console.log("[Board] Feature resume started successfully");
} else {
console.error("[Board] Failed to resume feature:", result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error resuming feature:", error);
await loadFeatures();
}
},
[currentProject, loadFeatures, useWorktrees]
);
const handleManualVerify = useCallback(
(feature: Feature) => {
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, {
status: "verified",
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${truncateDescription(
feature.description
)}`,
});
},
[moveFeature, persistFeatureUpdate]
);
const handleMoveBackToInProgress = useCallback(
(feature: Feature) => {
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${truncateDescription(
feature.description
)}`,
});
},
[updateFeature, persistFeatureUpdate]
);
const handleOpenFollowUp = useCallback(
(feature: Feature) => {
setFollowUpFeature(feature);
setFollowUpPrompt("");
setFollowUpImagePaths([]);
setShowFollowUpDialog(true);
},
[
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setShowFollowUpDialog,
]
);
const handleSendFollowUp = useCallback(async () => {
if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return;
const featureId = followUpFeature.id;
const featureDescription = followUpFeature.description;
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
console.error("Follow-up feature API not available");
toast.error("Follow-up not available", {
description: "This feature is not available in the current version.",
});
return;
}
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
justFinishedAt: undefined,
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt("");
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(
featureDescription
)}`,
});
const imagePaths = followUpImagePaths.map((img) => img.path);
// Server derives workDir from feature.branchName at execution time
api.autoMode
.followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
imagePaths
// No worktreePath - server derives from feature.branchName
)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
toast.error("Failed to send follow-up", {
description:
error instanceof Error ? error.message : "An error occurred",
});
loadFeatures();
});
}, [
currentProject,
followUpFeature,
followUpPrompt,
followUpImagePaths,
updateFeature,
persistFeatureUpdate,
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
loadFeatures,
]);
const handleCommitFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.autoMode?.commitFeature) {
console.error("Commit feature API not available");
toast.error("Commit not available", {
description:
"This feature is not available in the current version.",
});
return;
}
// Server derives workDir from feature.branchName
const result = await api.autoMode.commitFeature(
currentProject.path,
feature.id
// No worktreePath - server derives from feature.branchName
);
if (result.success) {
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(
feature.description
)}`,
});
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else {
console.error("[Board] Failed to commit feature:", result.error);
toast.error("Failed to commit feature", {
description: result.error || "An error occurred",
});
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error committing feature:", error);
toast.error("Failed to commit feature", {
description:
error instanceof Error ? error.message : "An error occurred",
});
await loadFeatures();
}
},
[
currentProject,
moveFeature,
persistFeatureUpdate,
loadFeatures,
onWorktreeCreated,
]
);
const handleMergeFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
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) {
await loadFeatures();
toast.success("Feature merged", {
description: `Changes merged to main branch: ${truncateDescription(
feature.description
)}`,
});
} 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",
});
}
},
[currentProject, loadFeatures]
);
const handleCompleteFeature = useCallback(
(feature: Feature) => {
const updates = {
status: "completed" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature completed", {
description: `Archived: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
);
const handleUnarchiveFeature = useCallback(
(feature: Feature) => {
const updates = {
status: "verified" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${truncateDescription(
feature.description
)}`,
});
},
[updateFeature, persistFeatureUpdate]
);
const handleViewOutput = useCallback(
(feature: Feature) => {
setOutputFeature(feature);
setShowOutputModal(true);
},
[setOutputFeature, setShowOutputModal]
);
const handleOutputModalNumberKeyPress = useCallback(
(key: string) => {
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
const targetFeature = inProgressFeaturesForShortcuts[index];
if (!targetFeature) {
return;
}
if (targetFeature.id === outputFeature?.id) {
setShowOutputModal(false);
} else {
setOutputFeature(targetFeature);
}
},
[
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
);
const handleForceStopFeature = useCallback(
async (feature: Feature) => {
try {
await autoMode.stopFeature(feature.id);
const targetStatus =
feature.skipTests && feature.status === "waiting_approval"
? "waiting_approval"
: "backlog";
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
// Must await to ensure file is written before user can restart
await persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
description:
targetStatus === "waiting_approval"
? `Stopped commit - returned to waiting approval: ${truncateDescription(
feature.description
)}`
: `Stopped working on: ${truncateDescription(
feature.description
)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature:", error);
toast.error("Failed to stop agent", {
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
[autoMode, moveFeature, persistFeatureUpdate]
);
const handleStartNextFeatures = useCallback(async () => {
// Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list
const primaryBranch = projectPath
? getPrimaryWorktreeBranch(projectPath)
: null;
const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false;
// Determine the feature's branch (default to primary branch if not set)
const featureBranch = f.branchName || primaryBranch || "main";
// If no worktree is selected (currentWorktreeBranch is null or matches primary),
// show features with no branch or primary branch
if (
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
) {
return (
!f.branchName ||
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
);
}
// Otherwise, only show features matching the selected worktree branch
return featureBranch === currentWorktreeBranch;
});
const availableSlots =
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
if (availableSlots <= 0) {
toast.error("Concurrency limit reached", {
description:
"Wait for a task to complete or increase the concurrency limit.",
});
return;
}
if (backlogFeatures.length === 0) {
const isOnPrimaryBranch =
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info("Backlog empty", {
description: !isOnPrimaryBranch
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
});
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
// Features with blocking dependencies are sorted to the end
const sortedBacklog = [...backlogFeatures].sort((a, b) => {
const aBlocked = enableDependencyBlocking
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked = enableDependencyBlocking
? getBlockingDependencies(b, features).length > 0
: false;
// Blocked features go to the end
if (aBlocked && !bBlocked) return 1;
if (!aBlocked && bBlocked) return -1;
// Within same blocked/unblocked group, sort by priority
return (a.priority || 999) - (b.priority || 999);
});
// Find the first feature without blocking dependencies
const featureToStart = sortedBacklog.find((f) => {
if (!enableDependencyBlocking) return true;
return getBlockingDependencies(f, features).length === 0;
});
if (!featureToStart) {
toast.info("No eligible features", {
description:
"All backlog features have unmet dependencies. Complete their dependencies first.",
});
return;
}
// Start only one feature per keypress (user must press again for next)
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
await handleStartImplementation(featureToStart);
}, [
features,
runningAutoTasks,
handleStartImplementation,
currentWorktreeBranch,
projectPath,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
enableDependencyBlocking,
]);
const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
for (const feature of verifiedFeatures) {
const isRunning = runningAutoTasks.includes(feature.id);
if (isRunning) {
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error(
"[Board] Error stopping feature before archive:",
error
);
}
}
// Archive the feature by setting status to completed
const updates = {
status: "completed" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
toast.success("All verified features archived", {
description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [
features,
runningAutoTasks,
autoMode,
updateFeature,
persistFeatureUpdate,
]);
return {
handleAddFeature,
handleUpdateFeature,
handleDeleteFeature,
handleStartImplementation,
handleVerifyFeature,
handleResumeFeature,
handleManualVerify,
handleMoveBackToInProgress,
handleOpenFollowUp,
handleSendFollowUp,
handleCommitFeature,
handleMergeFeature,
handleCompleteFeature,
handleUnarchiveFeature,
handleViewOutput,
handleOutputModalNumberKeyPress,
handleForceStopFeature,
handleStartNextFeatures,
handleArchiveAllVerified,
};
}

View File

@@ -0,0 +1,47 @@
import { useMemo } from "react";
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
interface UseBoardBackgroundProps {
currentProject: { path: string; id: string } | null;
}
export function useBoardBackground({ currentProject }: UseBoardBackgroundProps) {
const boardBackgroundByProject = useAppStore(
(state) => state.boardBackgroundByProject
);
// Get background settings for current project
const backgroundSettings = useMemo(() => {
return (
(currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings
);
}, [currentProject, boardBackgroundByProject]);
// Build background image style if image exists
const backgroundImageStyle = useMemo(() => {
if (!backgroundSettings.imagePath || !currentProject) {
return {};
}
return {
backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || "http://localhost:3008"
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${
backgroundSettings.imageVersion
? `&v=${backgroundSettings.imageVersion}`
: ""
})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
} as React.CSSProperties;
}, [backgroundSettings, currentProject]);
return {
backgroundSettings,
backgroundImageStyle,
};
}

View File

@@ -0,0 +1,158 @@
import { useMemo, useCallback } from "react";
import { Feature, useAppStore } from "@/store/app-store";
import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
type ColumnId = Feature["status"];
interface UseBoardColumnFeaturesProps {
features: Feature[];
runningAutoTasks: string[];
searchQuery: string;
currentWorktreePath: string | null; // Currently selected worktree path
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
projectPath: string | null; // Main project path (for main worktree)
}
export function useBoardColumnFeatures({
features,
runningAutoTasks,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
}: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
const map: Record<ColumnId, Feature[]> = {
backlog: [],
in_progress: [],
waiting_approval: [],
verified: [],
completed: [], // Completed features are shown in the archive modal, not as a column
};
// Filter features by search query (case-insensitive)
const normalizedQuery = searchQuery.toLowerCase().trim();
const filteredFeatures = normalizedQuery
? features.filter(
(f) =>
f.description.toLowerCase().includes(normalizedQuery) ||
f.category?.toLowerCase().includes(normalizedQuery)
)
: features;
// Determine the effective worktree path and branch for filtering
// If currentWorktreePath is null, we're on the main worktree
const effectiveWorktreePath = currentWorktreePath || projectPath;
// Use the branch name from the selected worktree
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
// should contain the main branch's actual name, defaulting to "main"
// If we're selecting a non-main worktree but can't find it, currentWorktreeBranch is null
// In that case, we can't do branch-based filtering, so we'll handle it specially below
const effectiveBranch = currentWorktreeBranch;
filteredFeatures.forEach((f) => {
// If feature has a running agent, always show it in "in_progress"
const isRunning = runningAutoTasks.includes(f.id);
// Check if feature matches the current worktree by branchName
// Features without branchName are considered unassigned (show only on primary worktree)
const featureBranch = f.branchName;
let matchesWorktree: boolean;
if (!featureBranch) {
// No branch assigned - show only on primary worktree
const isViewingPrimary = currentWorktreePath === null;
matchesWorktree = isViewingPrimary;
} else if (effectiveBranch === null) {
// We're viewing main but branch hasn't been initialized yet
// (worktrees disabled or haven't loaded yet).
// Show features assigned to primary worktree's branch.
matchesWorktree = projectPath
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
: false;
} else {
// Match by branch name
matchesWorktree = featureBranch === effectiveBranch;
}
if (isRunning) {
// Only show running tasks if they match the current worktree
if (matchesWorktree) {
map.in_progress.push(f);
}
} else {
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
const status = f.status as ColumnId;
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === "backlog") {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
}
}
}
});
// Apply dependency-aware sorting to backlog
// This ensures features appear in dependency order (dependencies before dependents)
// Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog);
// Get all features to check blocking dependencies against
const allFeatures = features;
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
// Sort blocked features to the end of the backlog
// This keeps the dependency order within each group (unblocked/blocked)
if (enableDependencyBlocking) {
const unblocked: Feature[] = [];
const blocked: Feature[] = [];
for (const f of orderedFeatures) {
if (getBlockingDependencies(f, allFeatures).length > 0) {
blocked.push(f);
} else {
unblocked.push(f);
}
}
map.backlog = [...unblocked, ...blocked];
} else {
map.backlog = orderedFeatures;
}
}
return map;
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
return columnFeaturesMap[columnId];
},
[columnFeaturesMap]
);
// Memoize completed features for the archive modal
const completedFeatures = useMemo(() => {
return features.filter((f) => f.status === "completed");
}, [features]);
return {
columnFeaturesMap,
getColumnFeatures,
completedFeatures,
};
}

View File

@@ -0,0 +1,230 @@
import { useState, useCallback } from "react";
import { DragStartEvent, DragEndEvent } from "@dnd-kit/core";
import { Feature } from "@/store/app-store";
import { useAppStore } from "@/store/app-store";
import { toast } from "sonner";
import { COLUMNS, ColumnId } from "../constants";
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
runningAutoTasks: string[];
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>;
}
export function useBoardDragDrop({
features,
currentProject,
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
}: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature } = useAppStore();
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
// at execution time based on feature.branchName
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const { active } = event;
const feature = features.find((f) => f.id === active.id);
if (feature) {
setActiveFeature(feature);
}
},
[features]
);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
setActiveFeature(null);
if (!over) return;
const featureId = active.id as string;
const overId = over.id as string;
// Find the feature being dragged
const draggedFeature = features.find((f) => f.id === featureId);
if (!draggedFeature) return;
// Check if this is a running task (non-skipTests, TDD)
const isRunningTask = runningAutoTasks.includes(featureId);
// Determine if dragging is allowed based on status and skipTests
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - verified items can always be dragged (to allow moving back to waiting_approval)
// - in_progress items can be dragged (but not if they're currently running)
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
if (draggedFeature.status === "in_progress") {
// Only allow dragging in_progress if it's not currently running
if (isRunningTask) {
console.log(
"[Board] Cannot drag feature - currently running"
);
return;
}
}
let targetStatus: ColumnId | null = null;
// Check if we dropped on a column
const column = COLUMNS.find((c) => c.id === overId);
if (column) {
targetStatus = column.id;
} else {
// Dropped on another feature - find its column
const overFeature = features.find((f) => f.id === overId);
if (overFeature) {
targetStatus = overFeature.status;
}
}
if (!targetStatus) return;
// Same column, nothing to do
if (targetStatus === draggedFeature.status) return;
// Handle different drag scenarios
// Note: Worktrees are created server-side at execution time based on feature.branchName
if (draggedFeature.status === "backlog") {
// From backlog
if (targetStatus === "in_progress") {
// Use helper function to handle concurrency check and start implementation
// Server will derive workDir from feature.branchName
await handleStartImplementation(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
}
} else if (draggedFeature.status === "waiting_approval") {
// waiting_approval features can be dragged to verified for manual verification
// NOTE: This check must come BEFORE skipTests check because waiting_approval
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") {
moveFeature(featureId, "verified");
// Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, {
status: "verified",
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
// Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
justFinishedAt: undefined,
});
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
} else if (draggedFeature.status === "in_progress") {
// Handle in_progress features being moved
if (targetStatus === "backlog") {
// Allow moving in_progress cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (
targetStatus === "verified" &&
draggedFeature.skipTests
) {
// Manual verify via drag (only for skipTests features)
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
description: `Marked as verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
} else if (draggedFeature.skipTests) {
// skipTests feature being moved between verified and waiting_approval
if (
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
// Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
} else if (draggedFeature.status === "verified") {
// Handle verified TDD (non-skipTests) features being moved back
if (targetStatus === "waiting_approval") {
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
}
},
[
features,
runningAutoTasks,
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
]
);
return {
activeFeature,
handleDragStart,
handleDragEnd,
};
}

View File

@@ -0,0 +1,154 @@
import { useEffect } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null;
specCreatingForProject: string | null;
setSpecCreatingForProject: (path: string | null) => void;
setSuggestionsCount: (count: number) => void;
setFeatureSuggestions: (suggestions: any[]) => void;
setIsGeneratingSuggestions: (generating: boolean) => void;
checkContextExists: (featureId: string) => Promise<boolean>;
features: any[];
isLoading: boolean;
setFeaturesWithContext: (set: Set<string>) => void;
}
export function useBoardEffects({
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features,
isLoading,
setFeaturesWithContext,
}: UseBoardEffectsProps) {
// Make current project available globally for modal
useEffect(() => {
if (currentProject) {
(window as any).__currentProject = currentProject;
}
return () => {
(window as any).__currentProject = null;
};
}, [currentProject]);
// Listen for suggestions events to update count (persists even when dialog is closed)
useEffect(() => {
const api = getElectronAPI();
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event) => {
if (event.type === "suggestions_complete" && event.suggestions) {
setSuggestionsCount(event.suggestions.length);
setFeatureSuggestions(event.suggestions);
setIsGeneratingSuggestions(false);
} else if (event.type === "suggestions_error") {
setIsGeneratingSuggestions(false);
}
});
return () => {
unsubscribe();
};
}, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]);
// Subscribe to spec regeneration events to clear creating state on completion
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
"[BoardView] Spec regeneration event:",
event.type,
"for project:",
event.projectPath
);
if (event.projectPath !== specCreatingForProject) {
return;
}
if (event.type === "spec_regeneration_complete") {
setSpecCreatingForProject(null);
} else if (event.type === "spec_regeneration_error") {
setSpecCreatingForProject(null);
}
});
return () => {
unsubscribe();
};
}, [specCreatingForProject, setSpecCreatingForProject]);
// Sync running tasks from electron backend on mount
useEffect(() => {
if (!currentProject) return;
const syncRunningTasks = async () => {
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const status = await api.autoMode.status(currentProject.path);
if (status.success) {
const projectId = currentProject.id;
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log(
"[Board] Syncing running tasks from backend:",
status.runningFeatures
);
clearRunningTasks(projectId);
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(projectId, featureId);
});
}
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);
}
};
syncRunningTasks();
}, [currentProject]);
// Check which features have context files
useEffect(() => {
const checkAllContexts = async () => {
const featuresWithPotentialContext = features.filter(
(f) =>
f.status === "in_progress" ||
f.status === "waiting_approval" ||
f.status === "verified"
);
const contextChecks = await Promise.all(
featuresWithPotentialContext.map(async (f) => ({
id: f.id,
hasContext: await checkContextExists(f.id),
}))
);
const newSet = new Set<string>();
contextChecks.forEach(({ id, hasContext }) => {
if (hasContext) {
newSet.add(id);
}
});
setFeaturesWithContext(newSet);
};
if (features.length > 0 && !isLoading) {
checkAllContexts();
}
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
}

View File

@@ -0,0 +1,273 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface UseBoardFeaturesProps {
currentProject: { path: string; id: string } | null;
}
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const { features, setFeatures } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
// Track previous project path to detect project switches
const prevProjectPathRef = useRef<string | null>(null);
const isInitialLoadRef = useRef(true);
const isSwitchingProjectRef = useRef(false);
// Load features using features API
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch =
previousPath !== null && currentPath !== previousPath;
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
// If project switched, mark it but don't clear features yet
// We'll clear after successful API load to prevent data loss
if (isProjectSwitch) {
console.log(
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`
);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
// Update the ref to track current project
prevProjectPathRef.current = currentPath;
// Only show loading spinner on initial load to prevent board flash during reloads
if (isInitialLoadRef.current) {
setIsLoading(true);
}
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
// Keep cached features if API is unavailable
return;
}
const result = await api.features.getAll(currentProject.path);
if (result.success && result.features) {
const featuresWithIds = result.features.map(
(f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || "backlog",
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || "opus",
thinkingLevel: f.thinkingLevel || "none",
})
);
// Successfully loaded features - now safe to set them
setFeatures(featuresWithIds);
// Only clear categories on project switch AFTER successful load
if (isProjectSwitch) {
setPersistedCategories([]);
}
} else if (!result.success && result.error) {
console.error("[BoardView] API returned error:", result.error);
// If it's a new project or the error indicates no features found,
// that's expected - start with empty array
if (isProjectSwitch) {
setFeatures([]);
setPersistedCategories([]);
}
// Otherwise keep cached features
}
} catch (error) {
console.error("Failed to load features:", error);
// On error, keep existing cached features for the current project
// Only clear on project switch if we have no features from server
if (isProjectSwitch && cachedFeatures.length === 0) {
setFeatures([]);
setPersistedCategories([]);
}
} finally {
setIsLoading(false);
isInitialLoadRef.current = false;
isSwitchingProjectRef.current = false;
}
}, [currentProject, setFeatures]);
// Load persisted categories from file
const loadCategories = useCallback(async () => {
if (!currentProject) return;
try {
const api = getElectronAPI();
const result = await api.readFile(
`${currentProject.path}/.automaker/categories.json`
);
if (result.success && result.content) {
const parsed = JSON.parse(result.content);
if (Array.isArray(parsed)) {
setPersistedCategories(parsed);
}
} else {
// File doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
} catch (error) {
console.error("Failed to load categories:", error);
// If file doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
}, [currentProject]);
// Save a new category to the persisted categories file
const saveCategory = useCallback(
async (category: string) => {
if (!currentProject || !category.trim()) return;
try {
const api = getElectronAPI();
// Read existing categories
let categories: string[] = [...persistedCategories];
// Add new category if it doesn't exist
if (!categories.includes(category)) {
categories.push(category);
categories.sort(); // Keep sorted
// Write back to file
await api.writeFile(
`${currentProject.path}/.automaker/categories.json`,
JSON.stringify(categories, null, 2)
);
// Update state
setPersistedCategories(categories);
}
} catch (error) {
console.error("Failed to save category:", error);
}
},
[currentProject, persistedCategories]
);
// Subscribe to spec regeneration complete events to refresh kanban board
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
// Refresh the kanban board when spec regeneration completes for the current project
if (
event.type === "spec_regeneration_complete" &&
currentProject &&
event.projectPath === currentProject.path
) {
console.log(
"[BoardView] Spec regeneration complete, refreshing features"
);
loadFeatures();
}
});
return () => {
unsubscribe();
};
}, [currentProject, loadFeatures]);
// Listen for auto mode feature completion and errors to reload features
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode || !currentProject) return;
const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id;
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId =
("projectId" in event && event.projectId) || projectId;
if (event.type === "auto_mode_feature_complete") {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
const audio = new Audio("/sounds/ding.mp3");
audio
.play()
.catch((err) => console.warn("Could not play ding sound:", err));
}
} else if (event.type === "plan_approval_required") {
// Reload features when plan is generated and requires approval
// This ensures the feature card shows the "Approve Plan" button
console.log("[Board] Plan approval required, reloading features...");
loadFeatures();
} else if (event.type === "auto_mode_error") {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log(
"[Board] Feature error, reloading features...",
event.error
);
// Remove from running tasks so it moves to the correct column
if (event.featureId) {
removeRunningTask(eventProjectId, event.featureId);
}
loadFeatures();
// Check for authentication errors and show a more helpful message
const isAuthError =
event.errorType === "authentication" ||
(event.error &&
(event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")));
if (isAuthError) {
toast.error("Authentication Failed", {
description:
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
duration: 10000,
});
} else {
toast.error("Agent encountered an error", {
description: event.error || "Check the logs for details",
});
}
}
});
return unsubscribe;
}, [loadFeatures, currentProject]);
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
// Load persisted categories on mount
useEffect(() => {
loadCategories();
}, [loadCategories]);
return {
features,
isLoading,
persistedCategories,
loadFeatures,
loadCategories,
saveCategory,
};
}

View File

@@ -0,0 +1,78 @@
import { useMemo, useRef, useEffect } from "react";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { Feature } from "@/store/app-store";
interface UseBoardKeyboardShortcutsProps {
features: Feature[];
runningAutoTasks: string[];
onAddFeature: () => void;
onStartNextFeatures: () => void;
onViewOutput: (feature: Feature) => void;
}
export function useBoardKeyboardShortcuts({
features,
runningAutoTasks,
onAddFeature,
onStartNextFeatures,
onViewOutput,
}: UseBoardKeyboardShortcutsProps) {
const shortcuts = useKeyboardShortcutsConfig();
// Get in-progress features for keyboard shortcuts (memoized for shortcuts)
const inProgressFeaturesForShortcuts = useMemo(() => {
return features.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === "in_progress";
});
}, [features, runningAutoTasks]);
// Ref to hold the start next callback (to avoid dependency issues)
const startNextFeaturesRef = useRef<() => void>(() => {});
// Update ref when callback changes
useEffect(() => {
startNextFeaturesRef.current = onStartNextFeatures;
}, [onStartNextFeatures]);
// Keyboard shortcuts for this view
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [
{
key: shortcuts.addFeature,
action: onAddFeature,
description: "Add new feature",
},
{
key: shortcuts.startNext,
action: () => startNextFeaturesRef.current(),
description: "Start next features from backlog",
},
];
// Add shortcuts for in-progress cards (1-9 and 0 for 10th)
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
// Keys 1-9 for first 9 cards, 0 for 10th card
const key = index === 9 ? "0" : String(index + 1);
shortcutsList.push({
key,
action: () => {
onViewOutput(feature);
},
description: `View output for in-progress card ${index + 1}`,
});
});
return shortcutsList;
}, [inProgressFeaturesForShortcuts, shortcuts, onAddFeature, onViewOutput]);
useKeyboardShortcuts(boardShortcuts);
return {
inProgressFeaturesForShortcuts,
};
}

View File

@@ -0,0 +1,90 @@
import { useCallback } from "react";
import { Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
interface UseBoardPersistenceProps {
currentProject: { path: string; id: string } | null;
}
export function useBoardPersistence({
currentProject,
}: UseBoardPersistenceProps) {
const { updateFeature } = useAppStore();
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
async (featureId: string, updates: Partial<Feature>) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
const result = await api.features.update(
currentProject.path,
featureId,
updates
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature update:", error);
}
},
[currentProject, updateFeature]
);
// Persist feature creation to API
const persistFeatureCreate = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
const result = await api.features.create(currentProject.path, feature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature creation:", error);
}
},
[currentProject, updateFeature]
);
// Persist feature deletion to API
const persistFeatureDelete = useCallback(
async (featureId: string) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
await api.features.delete(currentProject.path, featureId);
} catch (error) {
console.error("Failed to persist feature deletion:", error);
}
},
[currentProject]
);
return {
persistFeatureCreate,
persistFeatureUpdate,
persistFeatureDelete,
};
}

View File

@@ -0,0 +1,48 @@
import { useState, useCallback } from "react";
import { Feature } from "@/store/app-store";
import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
export function useFollowUpState() {
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState("");
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt("");
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
}, []);
const handleFollowUpDialogChange = useCallback((open: boolean) => {
if (!open) {
resetFollowUpState();
} else {
setShowFollowUpDialog(open);
}
}, [resetFollowUpState]);
return {
// State
showFollowUpDialog,
followUpFeature,
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
// Setters
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
// Helpers
resetFollowUpState,
handleFollowUpDialogChange,
};
}

View File

@@ -0,0 +1,34 @@
import { useState, useCallback } from "react";
import type { FeatureSuggestion } from "@/lib/electron";
export function useSuggestionsState() {
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);
const [suggestionsCount, setSuggestionsCount] = useState(0);
const [featureSuggestions, setFeatureSuggestions] = useState<FeatureSuggestion[]>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
const updateSuggestions = useCallback((suggestions: FeatureSuggestion[]) => {
setFeatureSuggestions(suggestions);
setSuggestionsCount(suggestions.length);
}, []);
const closeSuggestionsDialog = useCallback(() => {
setShowSuggestionsDialog(false);
}, []);
return {
// State
showSuggestionsDialog,
suggestionsCount,
featureSuggestions,
isGeneratingSuggestions,
// Setters
setShowSuggestionsDialog,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
// Helpers
updateSuggestions,
closeSuggestionsDialog,
};
}

View File

@@ -0,0 +1,251 @@
import {
DndContext,
DragOverlay,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { KanbanColumn, KanbanCard } from "./components";
import { Feature } from "@/store/app-store";
import { FastForward, Lightbulb, Archive } from "lucide-react";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useResponsiveKanban } from "@/hooks/use-responsive-kanban";
import { COLUMNS, ColumnId } from "./constants";
interface KanbanBoardProps {
sensors: any;
collisionDetectionStrategy: (args: any) => any;
onDragStart: (event: any) => void;
onDragEnd: (event: any) => void;
activeFeature: Feature | null;
getColumnFeatures: (columnId: ColumnId) => Feature[];
backgroundImageStyle: React.CSSProperties;
backgroundSettings: {
columnOpacity: number;
columnBorderEnabled: boolean;
hideScrollbar: boolean;
cardOpacity: number;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
};
onEdit: (feature: Feature) => void;
onDelete: (featureId: string) => void;
onViewOutput: (feature: Feature) => void;
onVerify: (feature: Feature) => void;
onResume: (feature: Feature) => void;
onForceStop: (feature: Feature) => void;
onManualVerify: (feature: Feature) => void;
onMoveBackToInProgress: (feature: Feature) => void;
onFollowUp: (feature: Feature) => void;
onCommit: (feature: Feature) => void;
onComplete: (feature: Feature) => void;
onImplement: (feature: Feature) => void;
onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
onStartNextFeatures: () => void;
onShowSuggestions: () => void;
suggestionsCount: number;
onArchiveAllVerified: () => void;
}
export function KanbanBoard({
sensors,
collisionDetectionStrategy,
onDragStart,
onDragEnd,
activeFeature,
getColumnFeatures,
backgroundImageStyle,
backgroundSettings,
onEdit,
onDelete,
onViewOutput,
onVerify,
onResume,
onForceStop,
onManualVerify,
onMoveBackToInProgress,
onFollowUp,
onCommit,
onComplete,
onImplement,
onViewPlan,
onApprovePlan,
featuresWithContext,
runningAutoTasks,
shortcuts,
onStartNextFeatures,
onShowSuggestions,
suggestionsCount,
onArchiveAllVerified,
}: KanbanBoardProps) {
// Use responsive column widths based on window size
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
return (
<div
className="flex-1 overflow-x-auto px-4 pb-4 relative"
style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="flex gap-5 h-full py-1 justify-center">
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
return (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === "verified" &&
columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Archive className="w-3 h-3 mr-1" />
Archive All
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={onShowSuggestions}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={onStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Make
</HotkeyButton>
)}
</div>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey =
index === 9 ? "0" : String(index + 1);
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => onEdit(feature)}
onDelete={() => onDelete(feature.id)}
onViewOutput={() => onViewOutput(feature)}
onVerify={() => onVerify(feature)}
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() =>
onMoveBackToInProgress(feature)
}
onFollowUp={() => onFollowUp(feature)}
onCommit={() => onCommit(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={
backgroundSettings.cardGlassmorphism
}
cardBorderEnabled={
backgroundSettings.cardBorderEnabled
}
cardBorderOpacity={
backgroundSettings.cardBorderOpacity
}
/>
);
})}
</SortableContext>
</KanbanColumn>
);
})}
</div>
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
}}
>
{activeFeature && (
<Card
className="rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform"
style={{ width: `${columnWidth}px` }}
>
<CardHeader className="p-3">
<CardTitle className="text-sm font-medium line-clamp-2">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
)}
</DragOverlay>
</DndContext>
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { Label } from "@/components/ui/label";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface BranchSelectorProps {
useCurrentBranch: boolean;
onUseCurrentBranchChange: (useCurrent: boolean) => void;
branchName: string;
onBranchNameChange: (branchName: string) => void;
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
currentBranch?: string;
disabled?: boolean;
testIdPrefix?: string;
}
export function BranchSelector({
useCurrentBranch,
onUseCurrentBranchChange,
branchName,
onBranchNameChange,
branchSuggestions,
branchCardCounts,
currentBranch,
disabled = false,
testIdPrefix = "branch",
}: BranchSelectorProps) {
// Validate: if "other branch" is selected, branch name is required
const isBranchRequired = !useCurrentBranch;
const hasError = isBranchRequired && !branchName.trim();
return (
<div className="space-y-2">
<Label id={`${testIdPrefix}-label`}>Target Branch</Label>
<RadioGroup
value={useCurrentBranch ? "current" : "other"}
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
disabled={disabled}
data-testid={`${testIdPrefix}-radio-group`}
aria-labelledby={`${testIdPrefix}-label`}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="current" id={`${testIdPrefix}-current`} />
<Label
htmlFor={`${testIdPrefix}-current`}
className="font-normal cursor-pointer"
>
Use current selected branch
{currentBranch && (
<span className="text-muted-foreground ml-1">
({currentBranch})
</span>
)}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="other" id={`${testIdPrefix}-other`} />
<Label
htmlFor={`${testIdPrefix}-other`}
className="font-normal cursor-pointer"
>
Other branch
</Label>
</div>
</RadioGroup>
{!useCurrentBranch && (
<div className="ml-6 space-y-1">
<BranchAutocomplete
value={branchName}
onChange={onBranchNameChange}
branches={branchSuggestions}
branchCardCounts={branchCardCounts}
placeholder="Select or create branch..."
data-testid={`${testIdPrefix}-input`}
disabled={disabled}
error={hasError}
/>
{hasError && (
<p className="text-xs text-destructive">
Branch name is required when "Other branch" is selected.
</p>
)}
</div>
)}
{disabled ? (
<p className="text-xs text-muted-foreground">
Branch cannot be changed after work has started.
</p>
) : (
<p className="text-xs text-muted-foreground">
{useCurrentBranch
? "Work will be done in the currently selected branch. A worktree will be created if needed."
: "Work will be done in this branch. A worktree will be created if needed."}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
export * from "./model-constants";
export * from "./model-selector";
export * from "./thinking-level-selector";
export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";
export * from "./branch-selector";
export * from "./planning-mode-selector";

View File

@@ -0,0 +1,70 @@
import { AgentModel, ThinkingLevel } from "@/store/app-store";
import {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
} from "lucide-react";
export type ModelOption = {
id: AgentModel;
label: string;
description: string;
badge?: string;
provider: "claude";
};
export const CLAUDE_MODELS: ModelOption[] = [
{
id: "haiku",
label: "Claude Haiku",
description: "Fast and efficient for simple tasks.",
badge: "Speed",
provider: "claude",
},
{
id: "sonnet",
label: "Claude Sonnet",
description: "Balanced performance with strong reasoning.",
badge: "Balanced",
provider: "claude",
},
{
id: "opus",
label: "Claude Opus",
description: "Most capable model for complex work.",
badge: "Premium",
provider: "claude",
},
];
export const THINKING_LEVELS: ThinkingLevel[] = [
"none",
"low",
"medium",
"high",
"ultrathink",
];
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
none: "None",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
};
// Profile icon mapping
export const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
};

View File

@@ -0,0 +1,55 @@
import { Label } from "@/components/ui/label";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
import { AgentModel } from "@/store/app-store";
import { CLAUDE_MODELS, ModelOption } from "./model-constants";
interface ModelSelectorProps {
selectedModel: AgentModel;
onModelSelect: (model: AgentModel) => void;
testIdPrefix?: string;
}
export function ModelSelector({
selectedModel,
onModelSelect,
testIdPrefix = "model-select",
}: ModelSelectorProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Claude (SDK)
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
Native
</span>
</div>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
const shortName = option.label.replace("Claude ", "");
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
isSelected
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
{shortName}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,343 @@
"use client";
import { useState } from "react";
import {
Zap, ClipboardList, FileText, ScrollText,
Loader2, Check, Eye, RefreshCw, Sparkles
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { PlanSpec } from "@/store/app-store";
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
// Re-export for backwards compatibility
export type { ParsedTask, PlanSpec } from "@/store/app-store";
interface PlanningModeSelectorProps {
mode: PlanningMode;
onModeChange: (mode: PlanningMode) => void;
requireApproval?: boolean;
onRequireApprovalChange?: (require: boolean) => void;
planSpec?: PlanSpec;
onGenerateSpec?: () => void;
onApproveSpec?: () => void;
onRejectSpec?: () => void;
onViewSpec?: () => void;
isGenerating?: boolean;
featureDescription?: string; // For auto-generation context
testIdPrefix?: string;
compact?: boolean; // For use in dialogs vs settings
}
const modes = [
{
value: 'skip' as const,
label: 'Skip',
description: 'Direct implementation, no upfront planning',
icon: Zap,
color: 'text-emerald-500',
bgColor: 'bg-emerald-500/10',
borderColor: 'border-emerald-500/30',
badge: 'Default',
},
{
value: 'lite' as const,
label: 'Lite',
description: 'Think through approach, create task list',
icon: ClipboardList,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
borderColor: 'border-blue-500/30',
},
{
value: 'spec' as const,
label: 'Spec',
description: 'Generate spec with acceptance criteria',
icon: FileText,
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
borderColor: 'border-purple-500/30',
badge: 'Approval Required',
},
{
value: 'full' as const,
label: 'Full',
description: 'Comprehensive spec with phased plan',
icon: ScrollText,
color: 'text-amber-500',
bgColor: 'bg-amber-500/10',
borderColor: 'border-amber-500/30',
badge: 'Approval Required',
},
];
export function PlanningModeSelector({
mode,
onModeChange,
requireApproval,
onRequireApprovalChange,
planSpec,
onGenerateSpec,
onApproveSpec,
onRejectSpec,
onViewSpec,
isGenerating = false,
featureDescription,
testIdPrefix = 'planning',
compact = false,
}: PlanningModeSelectorProps) {
const [showPreview, setShowPreview] = useState(false);
const selectedMode = modes.find(m => m.value === mode);
const requiresApproval = mode === 'spec' || mode === 'full';
const canGenerate = requiresApproval && featureDescription?.trim() && !isGenerating;
const hasSpec = planSpec && planSpec.content;
return (
<div className="space-y-4">
{/* Header with icon */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
selectedMode?.bgColor || "bg-muted"
)}>
{selectedMode && <selectedMode.icon className={cn("h-4 w-4", selectedMode.color)} />}
</div>
<div>
<Label className="text-sm font-medium">Planning Mode</Label>
<p className="text-xs text-muted-foreground">
Choose how much upfront planning before implementation
</p>
</div>
</div>
{/* Quick action buttons when spec/full mode */}
{requiresApproval && hasSpec && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onViewSpec}
className="h-7 px-2"
>
<Eye className="h-3.5 w-3.5 mr-1" />
View
</Button>
</div>
)}
</div>
{/* Mode Selection Cards */}
<div
className={cn(
"grid gap-2",
compact ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-4"
)}
>
{modes.map((m) => {
const isSelected = mode === m.value;
const Icon = m.icon;
return (
<button
key={m.value}
type="button"
onClick={() => onModeChange(m.value)}
data-testid={`${testIdPrefix}-mode-${m.value}`}
className={cn(
"flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200",
"border-2 hover:border-primary/50",
isSelected
? cn("border-primary", m.bgColor)
: "border-border/50 bg-card/50 hover:bg-accent/30"
)}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
isSelected ? m.bgColor : "bg-muted"
)}>
<Icon className={cn(
"h-5 w-5 transition-colors",
isSelected ? m.color : "text-muted-foreground"
)} />
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1">
<span className={cn(
"font-medium text-sm",
isSelected ? "text-foreground" : "text-muted-foreground"
)}>
{m.label}
</span>
{m.badge && (
<span className={cn(
"text-[9px] px-1 py-0.5 rounded font-medium",
m.badge === 'Default'
? "bg-emerald-500/15 text-emerald-500"
: "bg-amber-500/15 text-amber-500"
)}>
{m.badge === 'Default' ? 'Default' : 'Review'}
</span>
)}
</div>
{!compact && (
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">
{m.description}
</p>
)}
</div>
</button>
);
})}
</div>
{/* Require Approval Checkbox - Only show when mode !== 'skip' */}
{mode !== 'skip' && onRequireApprovalChange && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<Checkbox
id="require-approval"
checked={requireApproval}
onCheckedChange={(checked) => onRequireApprovalChange(checked === true)}
data-testid={`${testIdPrefix}-require-approval-checkbox`}
/>
<Label
htmlFor="require-approval"
className="text-sm text-muted-foreground cursor-pointer"
>
Manually approve plan before implementation
</Label>
</div>
)}
{/* Spec Preview/Actions Panel - Only for spec/full modes */}
{requiresApproval && (
<div className={cn(
"rounded-xl border transition-all duration-300",
planSpec?.status === 'approved'
? "border-emerald-500/30 bg-emerald-500/5"
: planSpec?.status === 'generated'
? "border-amber-500/30 bg-amber-500/5"
: "border-border/50 bg-muted/30"
)}>
<div className="p-4 space-y-3">
{/* Status indicator */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...</span>
</>
) : planSpec?.status === 'approved' ? (
<>
<Check className="h-4 w-4 text-emerald-500" />
<span className="text-sm text-emerald-500 font-medium">Spec Approved</span>
</>
) : planSpec?.status === 'generated' ? (
<>
<Eye className="h-4 w-4 text-amber-500" />
<span className="text-sm text-amber-500 font-medium">Spec Ready for Review</span>
</>
) : (
<>
<Sparkles className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Spec will be generated when feature starts
</span>
</>
)}
</div>
{/* Auto-generate toggle area */}
{!planSpec?.status && canGenerate && onGenerateSpec && (
<Button
variant="outline"
size="sm"
onClick={onGenerateSpec}
disabled={isGenerating}
className="h-7"
>
<Sparkles className="h-3.5 w-3.5 mr-1" />
Pre-generate
</Button>
)}
</div>
{/* Spec content preview */}
{hasSpec && (
<div className="space-y-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(!showPreview)}
className="w-full justify-between h-8 px-2"
>
<span className="text-xs text-muted-foreground">
{showPreview ? 'Hide Preview' : 'Show Preview'}
</span>
<Eye className="h-3.5 w-3.5" />
</Button>
{showPreview && (
<div className="rounded-lg bg-background/80 border border-border/50 p-3 max-h-48 overflow-y-auto">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
{planSpec.content}
</pre>
</div>
)}
</div>
)}
{/* Action buttons when spec is generated */}
{planSpec?.status === 'generated' && (
<div className="flex items-center gap-2 pt-2 border-t border-border/30">
<Button
variant="outline"
size="sm"
onClick={onRejectSpec}
className="flex-1"
>
Request Changes
</Button>
<Button
size="sm"
onClick={onApproveSpec}
className="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white"
>
<Check className="h-3.5 w-3.5 mr-1" />
Approve Spec
</Button>
</div>
)}
{/* Regenerate option when approved */}
{planSpec?.status === 'approved' && onGenerateSpec && (
<div className="flex items-center justify-end pt-2 border-t border-border/30">
<Button
variant="ghost"
size="sm"
onClick={onGenerateSpec}
className="h-7"
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
Regenerate
</Button>
</div>
)}
</div>
</div>
)}
{/* Info text for non-approval modes */}
{!requiresApproval && (
<p className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
{mode === 'skip'
? "The agent will start implementing immediately without creating a plan or spec."
: "The agent will create a planning outline before implementing, but won't wait for approval."}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface PrioritySelectorProps {
selectedPriority: number;
onPrioritySelect: (priority: number) => void;
testIdPrefix?: string;
}
export function PrioritySelector({
selectedPriority,
onPrioritySelect,
testIdPrefix = "priority",
}: PrioritySelectorProps) {
return (
<div className="space-y-2">
<Label>Priority</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
selectedPriority === 1
? "bg-red-500/20 text-red-500 border-2 border-red-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
)}
data-testid={`${testIdPrefix}-high-button`}
>
High
</button>
<button
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
selectedPriority === 2
? "bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
)}
data-testid={`${testIdPrefix}-medium-button`}
>
Medium
</button>
<button
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
selectedPriority === 3
? "bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
)}
data-testid={`${testIdPrefix}-low-button`}
>
Low
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { Label } from "@/components/ui/label";
import { Brain, UserCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { AgentModel, ThinkingLevel, AIProfile } from "@/store/app-store";
import { PROFILE_ICONS } from "./model-constants";
interface ProfileQuickSelectProps {
profiles: AIProfile[];
selectedModel: AgentModel;
selectedThinkingLevel: ThinkingLevel;
onSelect: (model: AgentModel, thinkingLevel: ThinkingLevel) => void;
testIdPrefix?: string;
showManageLink?: boolean;
onManageLinkClick?: () => void;
}
export function ProfileQuickSelect({
profiles,
selectedModel,
selectedThinkingLevel,
onSelect,
testIdPrefix = "profile-quick-select",
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
if (profiles.length === 0) {
return null;
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<UserCircle className="w-4 h-4 text-brand-500" />
Quick Select Profile
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
Presets
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{profiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon
? PROFILE_ICONS[profile.icon]
: Brain;
const isSelected =
selectedModel === profile.model &&
selectedThinkingLevel === profile.thinkingLevel;
return (
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile.model, profile.thinkingLevel)}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
isSelected
? "bg-brand-500/10 border-brand-500 text-foreground"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`${testIdPrefix}-${profile.id}`}
>
<div className="w-7 h-7 rounded flex items-center justify-center shrink-0 bg-primary/10">
{IconComponent && (
<IconComponent className="w-4 h-4 text-primary" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{profile.model}
{profile.thinkingLevel !== "none" &&
` + ${profile.thinkingLevel}`}
</p>
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Or customize below.
{showManageLink && onManageLinkClick && (
<>
{" "}
Manage profiles in{" "}
<button
type="button"
onClick={onManageLinkClick}
className="text-brand-500 hover:underline"
>
AI Profiles
</button>
</>
)}
</p>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FlaskConical, Plus } from "lucide-react";
interface TestingTabContentProps {
skipTests: boolean;
onSkipTestsChange: (skipTests: boolean) => void;
steps: string[];
onStepsChange: (steps: string[]) => void;
testIdPrefix?: string;
}
export function TestingTabContent({
skipTests,
onSkipTestsChange,
steps,
onStepsChange,
testIdPrefix = "",
}: TestingTabContentProps) {
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests";
const handleStepChange = (index: number, value: string) => {
const newSteps = [...steps];
newSteps[index] = value;
onStepsChange(newSteps);
};
const handleAddStep = () => {
onStepsChange([...steps, ""]);
};
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id={checkboxId}
checked={!skipTests}
onCheckedChange={(checked) => onSkipTestsChange(checked !== true)}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}skip-tests-checkbox`}
/>
<div className="flex items-center gap-2">
<Label htmlFor={checkboxId} className="text-sm cursor-pointer">
Enable automated testing
</Label>
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
</div>
</div>
<p className="text-xs text-muted-foreground">
When enabled, this feature will use automated TDD. When disabled, it
will require manual verification.
</p>
{/* Verification Steps - Only shown when skipTests is enabled */}
{skipTests && (
<div className="space-y-2 pt-2 border-t border-border">
<Label>Verification Steps</Label>
<p className="text-xs text-muted-foreground mb-2">
Add manual steps to verify this feature works correctly.
</p>
{steps.map((step, index) => (
<Input
key={index}
value={step}
placeholder={`Verification step ${index + 1}`}
onChange={(e) => handleStepChange(index, e.target.value)}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`}
/>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddStep}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}add-step-button`}
>
<Plus className="w-4 h-4 mr-2" />
Add Verification Step
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { Label } from "@/components/ui/label";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
import { ThinkingLevel } from "@/store/app-store";
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from "./model-constants";
interface ThinkingLevelSelectorProps {
selectedLevel: ThinkingLevel;
onLevelSelect: (level: ThinkingLevel) => void;
testIdPrefix?: string;
}
export function ThinkingLevelSelector({
selectedLevel,
onLevelSelect,
testIdPrefix = "thinking-level",
}: ThinkingLevelSelectorProps) {
return (
<div className="space-y-2 pt-2 border-t border-border">
<Label className="flex items-center gap-2 text-sm">
<Brain className="w-3.5 h-3.5 text-muted-foreground" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map((level) => (
<button
key={level}
type="button"
onClick={() => onLevelSelect(level)}
className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
selectedLevel === level
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`${testIdPrefix}-${level}`}
>
{THINKING_LEVEL_LABELS[level]}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
GitBranch,
RefreshCw,
GitBranchPlus,
Check,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo } from "../types";
interface BranchSwitchDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
onOpenChange: (open: boolean) => void;
onFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
}
export function BranchSwitchDropdown({
worktree,
isSelected,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
onOpenChange,
onFilterChange,
onSwitchBranch,
onCreateBranch,
}: BranchSwitchDropdownProps) {
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
title="Switch branch"
>
<GitBranch className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter branches..."
value={branchFilter}
onChange={(e) => onFilterChange(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
className="h-7 pl-7 text-xs"
autoFocus
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
Loading branches...
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (
<DropdownMenuItem disabled className="text-xs">
{branchFilter ? "No matching branches" : "No branches found"}
</DropdownMenuItem>
) : (
filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => onSwitchBranch(worktree, branch.name)}
disabled={isSwitching || branch.name === worktree.branch}
className="text-xs font-mono"
>
{branch.name === worktree.branch ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
</DropdownMenuItem>
))
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,3 @@
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
export { WorktreeTab } from "./worktree-tab";

View File

@@ -0,0 +1,194 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Trash2,
MoreHorizontal,
GitCommit,
GitPullRequest,
ExternalLink,
Download,
Upload,
Play,
Square,
Globe,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, DevServerInfo } from "../types";
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
defaultEditorName: string;
aheadCount: number;
behindCount: number;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
}
export function WorktreeActionsDropdown({
worktree,
isSelected,
defaultEditorName,
aheadCount,
behindCount,
isPulling,
isPushing,
isStartingDevServer,
isDevServerRunning,
devServerInfo,
onOpenChange,
onPull,
onPush,
onOpenInEditor,
onCommit,
onCreatePR,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeActionsDropdownProps) {
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-l-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{isDevServerRunning ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
>
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onStopDevServer(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Square className="w-3.5 h-3.5 mr-2" />
Stop Dev Server
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : (
<>
<DropdownMenuItem
onClick={() => onStartDevServer(worktree)}
disabled={isStartingDevServer}
className="text-xs"
>
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => onPull(worktree)}
disabled={isPulling}
className="text-xs"
>
<Download
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
/>
{isPulling ? "Pulling..." : "Pull"}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onPush(worktree)}
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree)}
className="text-xs"
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
<DropdownMenuSeparator />
{worktree.hasChanges && (
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
<GitCommit className="w-3.5 h-3.5 mr-2" />
Commit Changes
</DropdownMenuItem>
)}
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
{(!worktree.isMain || worktree.hasChanges) && (
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request
</DropdownMenuItem>
)}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete Worktree
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,259 @@
import { Button } from "@/components/ui/button";
import { RefreshCw, Globe, Loader2, CircleDot } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
interface WorktreeTabProps {
worktree: WorktreeInfo;
cardCount?: number; // Number of unarchived cards for this branch
hasChanges?: boolean; // Whether the worktree has uncommitted changes
changedFilesCount?: number; // Number of files with uncommitted changes
isSelected: boolean;
isRunning: boolean;
isActivating: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
defaultEditorName: string;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
onBranchFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
}
export function WorktreeTab({
worktree,
cardCount,
hasChanges,
changedFilesCount,
isSelected,
isRunning,
isActivating,
isDevServerRunning,
devServerInfo,
defaultEditorName,
branches,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
isPulling,
isPushing,
isStartingDevServer,
aheadCount,
behindCount,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
onBranchFilterChange,
onSwitchBranch,
onCreateBranch,
onPull,
onPush,
onOpenInEditor,
onCommit,
onCreatePR,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
// Determine border color based on state:
// - Running features: cyan border (high visibility, indicates active work)
// - Uncommitted changes: amber border (warning state, needs attention)
// - Both: cyan takes priority (running is more important to see)
const getBorderClasses = () => {
if (isRunning) {
return "ring-2 ring-cyan-500 ring-offset-1 ring-offset-background";
}
if (hasChanges) {
return "ring-2 ring-amber-500 ring-offset-1 ring-offset-background";
}
return "";
};
const borderClasses = getBorderClasses();
return (
<div className={cn("flex items-center rounded-md", borderClasses)}>
{worktree.isMain ? (
<>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title="Click to preview main"
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{cardCount}
</span>
)}
{hasChanges && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
isSelected
? "bg-amber-500 text-amber-950 border-amber-400"
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
)}>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? "!"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</Button>
<BranchSwitchDropdown
worktree={worktree}
isSelected={isSelected}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onOpenChange={onBranchDropdownOpenChange}
onFilterChange={onBranchFilterChange}
onSwitchBranch={onSwitchBranch}
onCreateBranch={onCreateBranch}
/>
</>
) : (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
!worktree.hasWorktree && !isSelected && "opacity-70"
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title={
worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{cardCount}
</span>
)}
{hasChanges && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
isSelected
? "bg-amber-500 text-amber-950 border-amber-400"
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
)}>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? "!"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</Button>
)}
{isDevServerRunning && (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
"text-green-500"
)}
onClick={() => onOpenDevServerUrl(worktree)}
title={`Open dev server (port ${devServerInfo?.port})`}
>
<Globe className="w-3 h-3" />
</Button>
)}
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
defaultEditorName={defaultEditorName}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
onOpenInEditor={onOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
/>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { useWorktrees } from "./use-worktrees";
export { useDevServers } from "./use-dev-servers";
export { useBranches } from "./use-branches";
export { useWorktreeActions } from "./use-worktree-actions";
export { useDefaultEditor } from "./use-default-editor";
export { useRunningFeatures } from "./use-running-features";

View File

@@ -0,0 +1,53 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import type { BranchInfo } from "../types";
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn("List branches API not available");
return;
}
const result = await api.worktree.listBranches(worktreePath);
if (result.success && result.result) {
setBranches(result.result.branches);
setAheadCount(result.result.aheadCount || 0);
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error("Failed to fetch branches:", error);
} finally {
setIsLoadingBranches(false);
}
}, []);
const resetBranchFilter = useCallback(() => {
setBranchFilter("");
}, []);
const filteredBranches = branches.filter((b) =>
b.name.toLowerCase().includes(branchFilter.toLowerCase())
);
return {
branches,
filteredBranches,
aheadCount,
behindCount,
isLoadingBranches,
branchFilter,
setBranchFilter,
resetBranchFilter,
fetchBranches,
};
}

View File

@@ -0,0 +1,30 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
export function useDefaultEditor() {
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const fetchDefaultEditor = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getDefaultEditor) {
return;
}
const result = await api.worktree.getDefaultEditor();
if (result.success && result.result?.editorName) {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error("Failed to fetch default editor:", error);
}
}, []);
useEffect(() => {
fetchDefaultEditor();
}, [fetchDefaultEditor]);
return {
defaultEditorName,
};
}

View File

@@ -0,0 +1,153 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { normalizePath } from "@/lib/utils";
import { toast } from "sonner";
import type { DevServerInfo, WorktreeInfo } from "../types";
interface UseDevServersOptions {
projectPath: string;
}
export function useDevServers({ projectPath }: UseDevServersOptions) {
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
new Map()
);
const fetchDevServers = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.listDevServers) {
return;
}
const result = await api.worktree.listDevServers();
if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server);
}
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
}
}, []);
useEffect(() => {
fetchDevServers();
}, [fetchDevServers]);
const getWorktreeKey = useCallback(
(worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path;
return path ? normalizePath(path) : path;
},
[projectPath]
);
const handleStartDevServer = useCallback(
async (worktree: WorktreeInfo) => {
if (isStartingDevServer) return;
setIsStartingDevServer(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
return;
}
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) {
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(normalizePath(targetPath), {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
});
return next;
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
}
} catch (error) {
console.error("Start dev server failed:", error);
toast.error("Failed to start dev server");
} finally {
setIsStartingDevServer(false);
}
},
[isStartingDevServer, projectPath]
);
const handleStopDevServer = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
return;
}
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.stopDevServer(targetPath);
if (result.success) {
setRunningDevServers((prev) => {
const next = new Map(prev);
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
} else {
toast.error(result.error || "Failed to stop dev server");
}
} catch (error) {
console.error("Stop dev server failed:", error);
toast.error("Failed to stop dev server");
}
},
[projectPath]
);
const handleOpenDevServerUrl = useCallback(
(worktree: WorktreeInfo) => {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
}
},
[projectPath, runningDevServers]
);
const isDevServerRunning = useCallback(
(worktree: WorktreeInfo) => {
return runningDevServers.has(getWorktreeKey(worktree));
},
[runningDevServers, getWorktreeKey]
);
const getDevServerInfo = useCallback(
(worktree: WorktreeInfo) => {
return runningDevServers.get(getWorktreeKey(worktree));
},
[runningDevServers, getWorktreeKey]
);
return {
isStartingDevServer,
runningDevServers,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
handleStopDevServer,
handleOpenDevServerUrl,
};
}

View File

@@ -0,0 +1,37 @@
import { useCallback } from "react";
import type { WorktreeInfo, FeatureInfo } from "../types";
interface UseRunningFeaturesOptions {
runningFeatureIds: string[];
features: FeatureInfo[];
}
export function useRunningFeatures({
runningFeatureIds,
features,
}: UseRunningFeaturesOptions) {
const hasRunningFeatures = useCallback(
(worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;
return runningFeatureIds.some((featureId) => {
const feature = features.find((f) => f.id === featureId);
if (!feature) return false;
// Match by branchName only (worktreePath is no longer stored)
if (feature.branchName) {
return worktree.branch === feature.branchName;
}
// No branch assigned - belongs to main worktree
return worktree.isMain;
});
},
[runningFeatureIds, features]
);
return {
hasRunningFeatures,
};
}

View File

@@ -0,0 +1,132 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
fetchBranches: (worktreePath: string) => Promise<void>;
}
export function useWorktreeActions({
fetchWorktrees,
fetchBranches,
}: UseWorktreeActionsOptions) {
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const handleSwitchBranch = useCallback(
async (worktree: WorktreeInfo, branchName: string) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error("Switch branch API not available");
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
} finally {
setIsSwitching(false);
}
},
[isSwitching, fetchWorktrees]
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
if (isPulling) return;
setIsPulling(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error("Pull API not available");
return;
}
const result = await api.worktree.pull(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to pull latest changes");
}
} catch (error) {
console.error("Pull failed:", error);
toast.error("Failed to pull latest changes");
} finally {
setIsPulling(false);
}
},
[isPulling, fetchWorktrees]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
if (isPushing) return;
setIsPushing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error("Push API not available");
return;
}
const result = await api.worktree.push(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to push changes");
}
} catch (error) {
console.error("Push failed:", error);
toast.error("Failed to push changes");
} finally {
setIsPushing(false);
}
},
[isPushing, fetchBranches, fetchWorktrees]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
return;
}
const result = await api.worktree.openInEditor(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
}
}, []);
return {
isPulling,
isPushing,
isSwitching,
isActivating,
setIsActivating,
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInEditor,
};
}

View File

@@ -0,0 +1,104 @@
import { useState, useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import type { WorktreeInfo } from "../types";
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
}
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => {
if (!projectPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn("Worktree API not available");
return;
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
console.error("Failed to fetch worktrees:", error);
return undefined;
} finally {
setIsLoading(false);
}
}, [projectPath, setWorktreesInStore]);
useEffect(() => {
fetchWorktrees();
}, [fetchWorktrees]);
useEffect(() => {
if (refreshTrigger > 0) {
fetchWorktrees().then((removedWorktrees) => {
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
});
}
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
useEffect(() => {
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
// Find the primary worktree and get its branch name
// Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch);
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
},
[projectPath, setCurrentWorktree]
);
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain);
return {
isLoading,
worktrees,
currentWorktree,
currentWorktreePath,
selectedWorktree,
useWorktreesEnabled,
fetchWorktrees,
handleSelectWorktree,
};
}

View File

@@ -0,0 +1,8 @@
export { WorktreePanel } from "./worktree-panel";
export type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
FeatureInfo,
WorktreePanelProps,
} from "./types";

View File

@@ -0,0 +1,40 @@
export interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean;
hasWorktree: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
export interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
export interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
}
export interface FeatureInfo {
id: string;
branchName?: string;
}
export interface WorktreePanelProps {
projectPath: string;
onCreateWorktree: () => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
refreshTrigger?: number;
}

View File

@@ -0,0 +1,187 @@
import { Button } from "@/components/ui/button";
import { GitBranch, Plus, RefreshCw } from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import {
useWorktrees,
useDevServers,
useBranches,
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from "./hooks";
import { WorktreeTab } from "./components";
export function WorktreePanel({
projectPath,
onCreateWorktree,
onDeleteWorktree,
onCommit,
onCreatePR,
onCreateBranch,
onRemovedWorktrees,
runningFeatureIds = [],
features = [],
branchCardCounts,
refreshTrigger = 0,
}: WorktreePanelProps) {
const {
isLoading,
worktrees,
currentWorktree,
currentWorktreePath,
useWorktreesEnabled,
fetchWorktrees,
handleSelectWorktree,
} = useWorktrees({ projectPath, refreshTrigger, onRemovedWorktrees });
const {
isStartingDevServer,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
handleStopDevServer,
handleOpenDevServerUrl,
} = useDevServers({ projectPath });
const {
branches,
filteredBranches,
aheadCount,
behindCount,
isLoadingBranches,
branchFilter,
setBranchFilter,
resetBranchFilter,
fetchBranches,
} = useBranches();
const {
isPulling,
isPushing,
isSwitching,
isActivating,
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInEditor,
} = useWorktreeActions({
fetchWorktrees,
fetchBranches,
});
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds,
features,
});
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
};
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
if (!useWorktreesEnabled) {
return null;
}
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
<div className="flex items-center gap-2 flex-wrap">
{worktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
MessageSquare,
Archive,
Trash2,
MoreVertical,
Search,
ChevronLeft,
ArchiveRestore,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
export function ChatHistory() {
const {
chatSessions,
currentProject,
currentChatSession,
chatHistoryOpen,
createChatSession,
setCurrentChatSession,
archiveChatSession,
unarchiveChatSession,
deleteChatSession,
setChatHistoryOpen,
} = useAppStore();
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
if (!currentProject) {
return null;
}
// Filter sessions for current project
const projectSessions = chatSessions.filter(
(session) => session.projectId === currentProject.id
);
// Filter by search query and archived status
const filteredSessions = projectSessions.filter((session) => {
const matchesSearch = session.title
.toLowerCase()
.includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived
? session.archived
: !session.archived;
return matchesSearch && matchesArchivedStatus;
});
// Sort by most recently updated
const sortedSessions = filteredSessions.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const handleCreateNewChat = () => {
createChatSession();
};
const handleSelectSession = (session: any) => {
setCurrentChatSession(session);
};
const handleArchiveSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
archiveChatSession(sessionId);
};
const handleUnarchiveSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
unarchiveChatSession(sessionId);
};
const handleDeleteSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this chat session?")) {
deleteChatSession(sessionId);
}
};
return (
<div
className={cn(
"flex flex-col h-full bg-zinc-950/50 backdrop-blur-md border-r border-white/10 transition-all duration-200",
chatHistoryOpen ? "w-80" : "w-0 overflow-hidden"
)}
>
{chatHistoryOpen && (
<>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
<h2 className="font-semibold">Chat History</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setChatHistoryOpen(false)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
</div>
{/* New Chat Button */}
<div className="p-4 border-b">
<Button
onClick={handleCreateNewChat}
className="w-full justify-start gap-2"
variant="outline"
>
<Plus className="w-4 h-4" />
New Chat
</Button>
</div>
{/* Search */}
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search chats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
{/* Archive Toggle */}
<div className="px-4 py-2 border-b">
<Button
variant="ghost"
size="sm"
onClick={() => setShowArchived(!showArchived)}
className="w-full justify-start gap-2"
>
{showArchived ? (
<ArchiveRestore className="w-4 h-4" />
) : (
<Archive className="w-4 h-4" />
)}
{showArchived ? "Show Active" : "Show Archived"}
{showArchived && (
<Badge variant="outline" className="ml-auto">
{projectSessions.filter((s) => s.archived).length}
</Badge>
)}
</Button>
</div>
{/* Chat Sessions List */}
<div className="flex-1 overflow-y-auto">
{sortedSessions.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? (
<>No chats match your search</>
) : showArchived ? (
<>No archived chats</>
) : (
<>No active chats. Create your first chat to get started!</>
)}
</div>
) : (
<div className="p-2">
{sortedSessions.map((session) => (
<div
key={session.id}
className={cn(
"flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group",
currentChatSession?.id === session.id && "bg-accent"
)}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">
{session.title}
</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
<p className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{session.archived ? (
<DropdownMenuItem
onClick={(e) =>
handleUnarchiveSession(session.id, e)
}
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) =>
handleArchiveSession(session.id, e)
}
>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteSession(session.id, e)}
className="text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,300 @@
import { useEffect, useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
File,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
RefreshCw,
Code,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface FileTreeNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileTreeNode[];
isExpanded?: boolean;
}
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
});
};
export function CodeView() {
const { currentProject } = useAppStore();
const [fileTree, setFileTree] = useState<FileTreeNode[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
// Load directory tree
const loadTree = useCallback(async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const api = getElectronAPI();
const result = await api.readdir(currentProject.path);
if (result.success && result.entries) {
const entries = result.entries
.filter((e) => !shouldIgnore(e.name))
.sort((a, b) => {
// Directories first
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
})
.map((e) => ({
name: e.name,
path: `${currentProject.path}/${e.name}`,
isDirectory: e.isDirectory,
}));
setFileTree(entries);
}
} catch (error) {
console.error("Failed to load file tree:", error);
} finally {
setIsLoading(false);
}
}, [currentProject]);
useEffect(() => {
loadTree();
}, [loadTree]);
// Load subdirectory
const loadSubdirectory = async (path: string): Promise<FileTreeNode[]> => {
try {
const api = getElectronAPI();
const result = await api.readdir(path);
if (result.success && result.entries) {
return result.entries
.filter((e) => !shouldIgnore(e.name))
.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
})
.map((e) => ({
name: e.name,
path: `${path}/${e.name}`,
isDirectory: e.isDirectory,
}));
}
} catch (error) {
console.error("Failed to load subdirectory:", error);
}
return [];
};
// Load file content
const loadFileContent = async (path: string) => {
try {
const api = getElectronAPI();
const result = await api.readFile(path);
if (result.success && result.content) {
setFileContent(result.content);
setSelectedFile(path);
}
} catch (error) {
console.error("Failed to load file:", error);
}
};
// Toggle folder expansion
const toggleFolder = async (node: FileTreeNode) => {
const newExpanded = new Set(expandedFolders);
if (expandedFolders.has(node.path)) {
newExpanded.delete(node.path);
} else {
newExpanded.add(node.path);
// Load children if not already loaded
if (!node.children) {
const children = await loadSubdirectory(node.path);
// Update the tree with children
const updateTree = (nodes: FileTreeNode[]): FileTreeNode[] => {
return nodes.map((n) => {
if (n.path === node.path) {
return { ...n, children };
}
if (n.children) {
return { ...n, children: updateTree(n.children) };
}
return n;
});
};
setFileTree(updateTree(fileTree));
}
}
setExpandedFolders(newExpanded);
};
// Render file tree node
const renderNode = (node: FileTreeNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedFile === node.path;
return (
<div key={node.path}>
<div
className={cn(
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-muted"
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
if (node.isDirectory) {
toggleFolder(node);
} else {
loadFileContent(node.path);
}
}}
data-testid={`file-tree-item-${node.name}`}
>
{node.isDirectory ? (
<>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
) : (
<Folder className="w-4 h-4 text-primary shrink-0" />
)}
</>
) : (
<>
<span className="w-4" />
<File className="w-4 h-4 text-muted-foreground shrink-0" />
</>
)}
<span className="text-sm truncate">{node.name}</span>
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-no-project"
>
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-loading"
>
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="code-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
<div className="flex items-center gap-3">
<Code className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Code Explorer</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={loadTree}
data-testid="refresh-tree"
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
{/* Split View */}
<div className="flex-1 flex overflow-hidden">
{/* File Tree */}
<div className="w-64 border-r overflow-y-auto" data-testid="file-tree">
<div className="p-2">{fileTree.map((node) => renderNode(node))}</div>
</div>
{/* Code Preview */}
<div className="flex-1 overflow-hidden">
{selectedFile ? (
<div className="h-full flex flex-col">
<div className="px-4 py-2 border-b bg-muted/30">
<p className="text-sm font-mono text-muted-foreground truncate">
{selectedFile.replace(currentProject.path, "")}
</p>
</div>
<Card className="flex-1 m-4 overflow-hidden">
<CardContent className="p-0 h-full">
<pre className="p-4 h-full overflow-auto text-sm font-mono whitespace-pre-wrap">
<code data-testid="code-content">{fileContent}</code>
</pre>
</CardContent>
</Card>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">
Select a file to view its contents
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,858 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Card } from "@/components/ui/card";
import {
Plus,
RefreshCw,
FileText,
Image as ImageIcon,
Trash2,
Save,
Upload,
File,
X,
BookOpen,
EditIcon,
Eye,
Pencil,
} from "lucide-react";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Markdown } from "../ui/markdown";
interface ContextFile {
name: string;
type: "text" | "image";
content?: string;
path: string;
}
export function ContextView() {
const { currentProject } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [editedContent, setEditedContent] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameFileName, setRenameFileName] = useState("");
const [newFileName, setNewFileName] = useState("");
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
null
);
const [newFileContent, setNewFileContent] = useState("");
const [isDropHovering, setIsDropHovering] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
// Keyboard shortcuts for this view
const contextShortcuts: KeyboardShortcut[] = useMemo(
() => [
{
key: shortcuts.addContextFile,
action: () => setIsAddDialogOpen(true),
description: "Add new context file",
},
],
[shortcuts]
);
useKeyboardShortcuts(contextShortcuts);
// Get context directory path for user-added context files
const getContextPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/context`;
}, [currentProject]);
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
return ext === ".md" || ext === ".markdown";
};
// Determine if a file is an image based on extension
const isImageFile = (filename: string): boolean => {
const imageExtensions = [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
];
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
return imageExtensions.includes(ext);
};
// Load context files
const loadContextFiles = useCallback(async () => {
const contextPath = getContextPath();
if (!contextPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
// Ensure context directory exists
await api.mkdir(contextPath);
// Read directory contents
const result = await api.readdir(contextPath);
if (result.success && result.entries) {
const files: ContextFile[] = result.entries
.filter((entry) => entry.isFile)
.map((entry) => ({
name: entry.name,
type: isImageFile(entry.name) ? "image" : "text",
path: `${contextPath}/${entry.name}`,
}));
setContextFiles(files);
}
} catch (error) {
console.error("Failed to load context files:", error);
} finally {
setIsLoading(false);
}
}, [getContextPath]);
useEffect(() => {
loadContextFiles();
}, [loadContextFiles]);
// Load selected file content
const loadFileContent = useCallback(async (file: ContextFile) => {
try {
const api = getElectronAPI();
const result = await api.readFile(file.path);
if (result.success && result.content !== undefined) {
setEditedContent(result.content);
setSelectedFile({ ...file, content: result.content });
setHasChanges(false);
}
} catch (error) {
console.error("Failed to load file content:", error);
}
}, []);
// Select a file
const handleSelectFile = (file: ContextFile) => {
if (hasChanges) {
// Could add a confirmation dialog here
}
loadFileContent(file);
setIsPreviewMode(isMarkdownFile(file.name));
};
// Save current file
const saveFile = async () => {
if (!selectedFile) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(selectedFile.path, editedContent);
setSelectedFile({ ...selectedFile, content: editedContent });
setHasChanges(false);
} catch (error) {
console.error("Failed to save file:", error);
} finally {
setIsSaving(false);
}
};
// Handle content change
const handleContentChange = (value: string) => {
setEditedContent(value);
setHasChanges(true);
};
// Add new context file
const handleAddFile = async () => {
const contextPath = getContextPath();
if (!contextPath || !newFileName.trim()) return;
try {
const api = getElectronAPI();
let filename = newFileName.trim();
// Add default extension if not provided
if (newFileType === "text" && !filename.includes(".")) {
filename += ".md";
}
const filePath = `${contextPath}/${filename}`;
if (newFileType === "image" && uploadedImageData) {
// Write image data
await api.writeFile(filePath, uploadedImageData);
} else {
// Write text file with content (or empty if no content)
await api.writeFile(filePath, newFileContent);
}
// Only reload files on success
await loadContextFiles();
} catch (error) {
console.error("Failed to add file:", error);
// Optionally show error toast to user here
} finally {
// Close dialog and reset state
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
}
};
// Delete selected file
const handleDeleteFile = async () => {
if (!selectedFile) return;
try {
const api = getElectronAPI();
await api.deleteFile(selectedFile.path);
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setEditedContent("");
setHasChanges(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to delete file:", error);
}
};
// Rename selected file
const handleRenameFile = async () => {
const contextPath = getContextPath();
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
const newName = renameFileName.trim();
if (newName === selectedFile.name) {
setIsRenameDialogOpen(false);
return;
}
try {
const api = getElectronAPI();
const newPath = `${contextPath}/${newName}`;
// Check if file with new name already exists
const exists = await api.exists(newPath);
if (exists) {
console.error("A file with this name already exists");
return;
}
// Read current file content
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
console.error("Failed to read file for rename");
return;
}
// Write to new path
await api.writeFile(newPath, result.content);
// Delete old file
await api.deleteFile(selectedFile.path);
setIsRenameDialogOpen(false);
setRenameFileName("");
// Reload files and select the renamed file
await loadContextFiles();
// Update selected file with new name and path
const renamedFile: ContextFile = {
name: newName,
type: isImageFile(newName) ? "image" : "text",
path: newPath,
content: result.content,
};
setSelectedFile(renamedFile);
} catch (error) {
console.error("Failed to rename file:", error);
}
};
// Handle image upload
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target?.result as string;
setUploadedImageData(base64);
if (!newFileName) {
setNewFileName(file.name);
}
};
reader.readAsDataURL(file);
};
// Handle drag and drop for file upload
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const contextPath = getContextPath();
if (!contextPath) return;
const api = getElectronAPI();
for (const file of files) {
const reader = new FileReader();
reader.onload = async (event) => {
const content = event.target?.result as string;
const filePath = `${contextPath}/${file.name}`;
await api.writeFile(filePath, content);
await loadContextFiles();
};
if (isImageFile(file.name)) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
// Handle drag and drop for .txt and .md files in the add context dialog textarea
const handleTextAreaDrop = async (
e: React.DragEvent<HTMLTextAreaElement>
) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0]; // Only handle the first file
const fileName = file.name.toLowerCase();
// Only accept .txt and .md files
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
console.warn("Only .txt and .md files are supported for drag and drop");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setNewFileContent(content);
// Auto-fill filename if empty
if (!newFileName) {
setNewFileName(file.name);
}
};
reader.readAsText(file);
};
const handleTextAreaDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(true);
};
const handleTextAreaDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
};
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="context-view-no-project"
>
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="context-view-loading"
>
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="context-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<BookOpen className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Context Files</h1>
<p className="text-sm text-muted-foreground">
Add context files to include in AI prompts
</p>
</div>
</div>
<div className="flex gap-2">
<HotkeyButton
size="sm"
onClick={() => setIsAddDialogOpen(true)}
hotkey={shortcuts.addContextFile}
hotkeyActive={false}
data-testid="add-context-file"
>
<Plus className="w-4 h-4 mr-2" />
Add File
</HotkeyButton>
</div>
</div>
{/* Main content area with file list and editor */}
<div
className="flex-1 flex overflow-hidden"
onDrop={handleDrop}
onDragOver={handleDragOver}
data-testid="context-drop-zone"
>
{/* Left Panel - File List */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Context Files ({contextFiles.length})
</h2>
</div>
<div
className="flex-1 overflow-y-auto p-2"
data-testid="context-file-list"
>
{contextFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
No context files yet.
<br />
Drop files here or click Add File.
</p>
</div>
) : (
<div className="space-y-1">
{contextFiles.map((file) => (
<div
key={file.path}
className={cn(
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
selectedFile?.path === file.path
? "bg-primary/20 text-foreground border border-primary/30"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
<button
onClick={() => handleSelectFile(file)}
className="flex-1 flex items-center gap-2 text-left min-w-0"
data-testid={`context-file-${file.name}`}
>
{file.type === "image" ? (
<ImageIcon className="w-4 h-4 flex-shrink-0" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
)}
<span className="truncate text-sm">{file.name}</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setRenameFileName(file.name);
setSelectedFile(file);
setIsRenameDialogOpen(true);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
data-testid={`rename-context-file-${file.name}`}
>
<Pencil className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Right Panel - Editor/Preview */}
<div className="flex-1 flex flex-col overflow-hidden">
{selectedFile ? (
<>
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
{selectedFile.type === "image" ? (
<ImageIcon className="w-4 h-4 text-muted-foreground" />
) : (
<FileText className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">
{selectedFile.name}
</span>
</div>
<div className="flex gap-2">
{selectedFile.type === "text" &&
isMarkdownFile(selectedFile.name) && (
<Button
variant={"outline"}
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
>
{isPreviewMode ? (
<>
<EditIcon className="w-4 h-4 mr-2" />
Edit
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
</>
)}
</Button>
)}
{selectedFile.type === "text" && (
<Button
size="sm"
onClick={saveFile}
disabled={!hasChanges || isSaving}
data-testid="save-context-file"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-context-file"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-hidden p-4">
{selectedFile.type === "image" ? (
<div
className="h-full flex items-center justify-center bg-card rounded-lg"
data-testid="image-preview"
>
<img
src={editedContent}
alt={selectedFile.name}
className="max-w-full max-h-full object-contain"
/>
</div>
) : isPreviewMode ? (
<Card className="h-full overflow-auto p-4" data-testid="markdown-preview">
<Markdown>{editedContent}</Markdown>
</Card>
) : (
<Card className="h-full overflow-hidden">
<textarea
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Enter context content here..."
spellCheck={false}
data-testid="context-editor"
/>
</Card>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground-secondary">
Select a file to view or edit
</p>
<p className="text-muted-foreground text-sm mt-1">
Or drop files here to add them
</p>
</div>
</div>
)}
</div>
</div>
{/* Add File Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent
data-testid="add-context-dialog"
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
>
<DialogHeader>
<DialogTitle>Add Context File</DialogTitle>
<DialogDescription>
Add a new text or image file to the context.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Button
variant={newFileType === "text" ? "default" : "outline"}
size="sm"
onClick={() => setNewFileType("text")}
data-testid="add-text-type"
>
<FileText className="w-4 h-4 mr-2" />
Text
</Button>
<Button
variant={newFileType === "image" ? "default" : "outline"}
size="sm"
onClick={() => setNewFileType("image")}
data-testid="add-image-type"
>
<ImageIcon className="w-4 h-4 mr-2" />
Image
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="filename">File Name</Label>
<Input
id="filename"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={
newFileType === "text" ? "context.md" : "image.png"
}
data-testid="new-file-name"
/>
</div>
{newFileType === "text" && (
<div className="space-y-2">
<Label htmlFor="context-content">Context Content</Label>
<div
className={cn(
"relative rounded-lg transition-colors",
isDropHovering && "ring-2 ring-primary"
)}
>
<textarea
id="context-content"
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
onDrop={handleTextAreaDrop}
onDragOver={handleTextAreaDragOver}
onDragLeave={handleTextAreaDragLeave}
placeholder="Enter context content here or drag & drop a .txt or .md file..."
className={cn(
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
isDropHovering && "border-primary bg-primary/10"
)}
spellCheck={false}
data-testid="new-file-content"
/>
{isDropHovering && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
<div className="flex flex-col items-center text-primary">
<Upload className="w-8 h-8 mb-2" />
<span className="text-sm font-medium">
Drop .txt or .md file here
</span>
</div>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Drag & drop .txt or .md files to import their content
</p>
</div>
)}
{newFileType === "image" && (
<div className="space-y-2">
<Label>Upload Image</Label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
id="image-upload"
data-testid="image-upload-input"
/>
<label
htmlFor="image-upload"
className="cursor-pointer flex flex-col items-center"
>
{uploadedImageData ? (
<img
src={uploadedImageData}
alt="Preview"
className="max-w-32 max-h-32 object-contain mb-2"
/>
) : (
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
)}
<span className="text-sm text-muted-foreground">
{uploadedImageData
? "Click to change"
: "Click to upload"}
</span>
</label>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsAddDialogOpen(false);
setNewFileName("");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
}}
>
Cancel
</Button>
<HotkeyButton
onClick={handleAddFile}
disabled={
!newFileName.trim() ||
(newFileType === "image" && !uploadedImageData)
}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={isAddDialogOpen}
data-testid="confirm-add-file"
>
Add File
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent data-testid="delete-context-dialog">
<DialogHeader>
<DialogTitle>Delete Context File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This
action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteFile}
className="bg-red-600 hover:bg-red-700"
data-testid="confirm-delete-file"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Dialog */}
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent data-testid="rename-context-dialog">
<DialogHeader>
<DialogTitle>Rename Context File</DialogTitle>
<DialogDescription>
Enter a new name for "{selectedFile?.name}".
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<Label htmlFor="rename-filename">File Name</Label>
<Input
id="rename-filename"
value={renameFileName}
onChange={(e) => setRenameFileName(e.target.value)}
placeholder="Enter new filename"
data-testid="rename-file-input"
onKeyDown={(e) => {
if (e.key === "Enter" && renameFileName.trim()) {
handleRenameFile();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsRenameDialogOpen(false);
setRenameFileName("");
}}
>
Cancel
</Button>
<Button
onClick={handleRenameFile}
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
data-testid="confirm-rename-file"
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,638 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Bot,
Send,
User,
Loader2,
Sparkles,
FileText,
ArrowLeft,
CheckCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context";
import { toast } from "sonner";
import { useNavigate } from "@tanstack/react-router";
interface InterviewMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
}
interface InterviewState {
projectName: string;
projectDescription: string;
techStack: string[];
features: string[];
additionalNotes: string;
}
// Interview questions flow
const INTERVIEW_QUESTIONS = [
{
id: "project-description",
question: "What do you want to build?",
hint: "Describe your project idea in a few sentences",
field: "projectDescription" as const,
},
{
id: "tech-stack",
question: "What tech stack would you like to use?",
hint: "e.g., React, Next.js, Node.js, Python, etc.",
field: "techStack" as const,
},
{
id: "core-features",
question: "What are the core features you want to include?",
hint: "List the main functionalities your app should have",
field: "features" as const,
},
{
id: "additional",
question: "Any additional requirements or preferences?",
hint: "Design preferences, integrations, deployment needs, etc.",
field: "additionalNotes" as const,
},
];
export function InterviewView() {
const { addProject, setCurrentProject, setAppSpec } =
useAppStore();
const { openFileBrowser } = useFileBrowser();
const navigate = useNavigate();
const [input, setInput] = useState("");
const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [interviewData, setInterviewData] = useState<InterviewState>({
projectName: "",
projectDescription: "",
techStack: [],
features: [],
additionalNotes: "",
});
const [isGenerating, setIsGenerating] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [generatedSpec, setGeneratedSpec] = useState<string | null>(null);
const [projectPath, setProjectPath] = useState("");
const [projectName, setProjectName] = useState("");
const [showProjectSetup, setShowProjectSetup] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Initialize with first question
useEffect(() => {
if (messages.length === 0) {
const welcomeMessage: InterviewMessage = {
id: "welcome",
role: "assistant",
content: `Hello! I'm here to help you plan your new project. Let's go through a few questions to understand what you want to build.\n\n**${INTERVIEW_QUESTIONS[0].question}**\n\n_${INTERVIEW_QUESTIONS[0].hint}_`,
timestamp: new Date(),
};
setMessages([welcomeMessage]);
}
}, [messages.length]);
// Auto-scroll to bottom when messages change
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (messagesContainerRef.current) {
// Use a small delay to ensure DOM is updated
timeoutId = setTimeout(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
}
}, 100);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [messages]);
// Auto-focus input
useEffect(() => {
if (inputRef.current && !isComplete) {
inputRef.current.focus();
}
}, [currentQuestionIndex, isComplete]);
const handleSend = useCallback(() => {
if (!input.trim() || isGenerating || isComplete) return;
const userMessage: InterviewMessage = {
id: `user-${Date.now()}`,
role: "user",
content: input,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
// Update interview data based on current question
const currentQuestion = INTERVIEW_QUESTIONS[currentQuestionIndex];
if (currentQuestion) {
setInterviewData((prev) => {
const newData = { ...prev };
if (
currentQuestion.field === "techStack" ||
currentQuestion.field === "features"
) {
// Parse comma-separated values into array
newData[currentQuestion.field] = input
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else {
(newData as Record<string, string | string[]>)[
currentQuestion.field
] = input;
}
return newData;
});
}
setInput("");
// Move to next question or complete
const nextIndex = currentQuestionIndex + 1;
setTimeout(() => {
if (nextIndex < INTERVIEW_QUESTIONS.length) {
const nextQuestion = INTERVIEW_QUESTIONS[nextIndex];
const assistantMessage: InterviewMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: `Great! **${nextQuestion.question}**\n\n_${nextQuestion.hint}_`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
setCurrentQuestionIndex(nextIndex);
} else {
// All questions answered - generate spec
const summaryMessage: InterviewMessage = {
id: `assistant-summary-${Date.now()}`,
role: "assistant",
content:
"Perfect! I have all the information I need. Now let me generate your project specification...",
timestamp: new Date(),
};
setMessages((prev) => [...prev, summaryMessage]);
generateSpec({
...interviewData,
projectDescription:
currentQuestionIndex === 0
? input
: interviewData.projectDescription,
techStack:
currentQuestionIndex === 1
? input
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: interviewData.techStack,
features:
currentQuestionIndex === 2
? input
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: interviewData.features,
additionalNotes:
currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
});
}
}, 500);
}, [input, isGenerating, isComplete, currentQuestionIndex, interviewData]);
const generateSpec = useCallback(async (data: InterviewState) => {
setIsGenerating(true);
// Generate a draft app_spec.txt based on the interview responses
const spec = generateAppSpec(data);
// Simulate some processing time for better UX
await new Promise((resolve) => setTimeout(resolve, 1500));
setGeneratedSpec(spec);
setIsGenerating(false);
setIsComplete(true);
setShowProjectSetup(true);
const completionMessage: InterviewMessage = {
id: `assistant-complete-${Date.now()}`,
role: "assistant",
content: `I've generated a draft project specification based on our conversation!\n\nPlease provide a project name and choose where to save your project, then click "Create Project" to get started.`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, completionMessage]);
}, []);
const generateAppSpec = (data: InterviewState): string => {
const projectName = data.projectDescription
.split(" ")
.slice(0, 3)
.join("-")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "");
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
return `<project_specification>
<project_name>${projectName || "my-project"}</project_name>
<overview>
${data.projectDescription}
</overview>
<technology_stack>
${
data.techStack.length > 0
? data.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")
: "<!-- Define your tech stack -->"
}
</technology_stack>
<core_capabilities>
${
data.features.length > 0
? data.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")
: "<!-- List core features -->"
}
</core_capabilities>
<additional_requirements>
${data.additionalNotes || "None specified"}
</additional_requirements>
<development_guidelines>
<guideline>Write clean, production-quality code</guideline>
<guideline>Include proper error handling</guideline>
<guideline>Write comprehensive Playwright tests</guideline>
<guideline>Ensure all tests pass before marking features complete</guideline>
</development_guidelines>
</project_specification>`;
};
const handleSelectDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Directory",
description:
"Choose the parent directory where your new project will be created",
});
if (selectedPath) {
setProjectPath(selectedPath);
}
};
const handleCreateProject = async () => {
if (!projectName || !projectPath || !generatedSpec) return;
setIsGenerating(true);
try {
const api = getElectronAPI();
// Use platform-specific path separator
const pathSep =
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
// Create project directory
const mkdirResult = await api.mkdir(fullProjectPath);
if (!mkdirResult.success) {
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
});
setIsGenerating(false);
return;
}
// Write app_spec.txt with generated content
await api.writeFile(
`${fullProjectPath}/.automaker/app_spec.txt`,
generatedSpec
);
// Create initial feature in the features folder
const initialFeature: Feature = {
id: crypto.randomUUID(),
category: "Core",
description: "Initial project setup",
status: "backlog" as const,
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
skipTests: true,
};
if (!api.features) {
throw new Error("Features API not available");
}
await api.features.create(fullProjectPath, initialFeature);
const project = {
id: `project-${Date.now()}`,
name: projectName,
path: fullProjectPath,
lastOpened: new Date().toISOString(),
};
// Update app spec in store
setAppSpec(generatedSpec);
// Add and select the project
addProject(project);
setCurrentProject(project);
} catch (error) {
console.error("Failed to create project:", error);
setIsGenerating(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleGoBack = () => {
navigate({ to: "/" });
};
return (
<div
className="flex-1 flex flex-col content-bg min-h-0"
data-testid="interview-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={handleGoBack}
className="h-8 w-8 p-0"
data-testid="interview-back-button"
>
<ArrowLeft className="w-4 h-4" />
</Button>
<Sparkles className="w-5 h-5 text-primary" />
<div>
<h1 className="text-xl font-bold">New Project Interview</h1>
<p className="text-sm text-muted-foreground">
{isComplete
? "Specification generated!"
: `Question ${currentQuestionIndex + 1} of ${
INTERVIEW_QUESTIONS.length
}`}
</p>
</div>
</div>
{/* Progress indicator */}
<div className="flex items-center gap-2">
{INTERVIEW_QUESTIONS.map((_, index) => (
<div
key={index}
className={cn(
"w-2 h-2 rounded-full transition-colors",
index < currentQuestionIndex
? "bg-green-500"
: index === currentQuestionIndex
? "bg-primary"
: "bg-zinc-700"
)}
/>
))}
{isComplete && (
<CheckCircle className="w-4 h-4 text-green-500 ml-2" />
)}
</div>
</div>
{/* Messages */}
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 space-y-4"
data-testid="interview-messages"
>
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" && "flex-row-reverse"
)}
>
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
)}
>
{message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4" />
)}
</div>
<Card
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-transparent border border-primary text-foreground"
: "border border-primary/30 bg-card"
)}
>
<CardContent className="px-3 py-2">
{message.role === "assistant" ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
)}
<p
className={cn(
"text-xs mt-1",
message.role === "user"
? "text-muted-foreground"
: "text-primary/70"
)}
>
{message.timestamp.toLocaleTimeString()}
</p>
</CardContent>
</Card>
</div>
))}
{isGenerating && !showProjectSetup && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="w-4 h-4 text-primary" />
</div>
<Card className="border border-primary/30 bg-card">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm text-primary">
Generating specification...
</span>
</div>
</CardContent>
</Card>
</div>
)}
{/* Project Setup Form */}
{showProjectSetup && (
<div className="mt-6">
<Card
className="bg-zinc-900/50 border-white/10"
data-testid="project-setup-form"
>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold">Create Your Project</h3>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="project-name"
className="text-sm font-medium text-zinc-300"
>
Project Name
</label>
<Input
id="project-name"
placeholder="my-awesome-project"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
data-testid="interview-project-name-input"
/>
</div>
<div className="space-y-2">
<label
htmlFor="project-path"
className="text-sm font-medium text-zinc-300"
>
Parent Directory
</label>
<div className="flex gap-2">
<Input
id="project-path"
placeholder="/path/to/projects"
value={projectPath}
onChange={(e) => setProjectPath(e.target.value)}
className="flex-1 bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
data-testid="interview-project-path-input"
/>
<Button
variant="secondary"
onClick={handleSelectDirectory}
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
data-testid="interview-browse-directory"
>
Browse
</Button>
</div>
</div>
{/* Preview of generated spec */}
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">
Generated Specification Preview
</label>
<div
className="bg-zinc-950/50 border border-white/10 rounded-md p-3 max-h-48 overflow-y-auto"
data-testid="spec-preview"
>
<pre className="text-xs text-zinc-400 whitespace-pre-wrap font-mono">
{generatedSpec}
</pre>
</div>
</div>
<Button
onClick={handleCreateProject}
disabled={!projectName || !projectPath || isGenerating}
className="w-full bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
data-testid="interview-create-project"
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Create Project
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
{/* Input */}
{!isComplete && (
<div className="border-t p-4">
<div className="flex gap-2">
<Input
ref={inputRef}
placeholder="Type your answer..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isGenerating}
data-testid="interview-input"
className="flex-1"
/>
<Button
onClick={handleSend}
disabled={!input.trim() || isGenerating}
data-testid="interview-send"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,299 @@
import { useState, useMemo, useCallback } from "react";
import {
useAppStore,
AIProfile,
} from "@/store/app-store";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
SortableProfileCard,
ProfileForm,
ProfilesHeader,
} from "./profiles-view/components";
export function ProfilesView() {
const {
aiProfiles,
addAIProfile,
updateAIProfile,
removeAIProfile,
reorderAIProfiles,
resetAIProfiles,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
const [profileToDelete, setProfileToDelete] = useState<AIProfile | null>(null);
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
// Separate built-in and custom profiles
const builtInProfiles = useMemo(
() => aiProfiles.filter((p) => p.isBuiltIn),
[aiProfiles]
);
const customProfiles = useMemo(
() => aiProfiles.filter((p) => !p.isBuiltIn),
[aiProfiles]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderAIProfiles(oldIndex, newIndex);
}
}
},
[aiProfiles, reorderAIProfiles]
);
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
addAIProfile(profile);
setShowAddDialog(false);
toast.success("Profile created", {
description: `Created "${profile.name}" profile`,
});
};
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
if (editingProfile) {
updateAIProfile(editingProfile.id, profile);
setEditingProfile(null);
toast.success("Profile updated", {
description: `Updated "${profile.name}" profile`,
});
}
};
const confirmDeleteProfile = () => {
if (!profileToDelete) return;
removeAIProfile(profileToDelete.id);
toast.success("Profile deleted", {
description: `Deleted "${profileToDelete.name}" profile`,
});
setProfileToDelete(null);
};
const handleResetProfiles = () => {
resetAIProfiles();
toast.success("Profiles refreshed", {
description: "Default profiles have been updated to the latest version",
});
};
// Build keyboard shortcuts for profiles view
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// Add profile shortcut - when in profiles view
shortcutsList.push({
key: shortcuts.addProfile,
action: () => setShowAddDialog(true),
description: "Create new profile",
});
return shortcutsList;
}, [shortcuts]);
// Register keyboard shortcuts for profiles view
useKeyboardShortcuts(profilesShortcuts);
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="profiles-view"
>
{/* Header Section */}
<ProfilesHeader
onResetProfiles={handleResetProfiles}
onAddProfile={() => setShowAddDialog(true)}
addProfileHotkey={shortcuts.addProfile}
/>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-8">
{/* Custom Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Custom Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{customProfiles.length}
</span>
</div>
{customProfiles.length === 0 ? (
<div
className="group rounded-xl border border-dashed border-border p-8 text-center transition-all duration-300 hover:border-primary hover:bg-primary/5 cursor-pointer"
onClick={() => setShowAddDialog(true)}
>
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50 transition-all duration-300 group-hover:text-primary group-hover:opacity-100 group-hover:scale-110 group-hover:rotate-12" />
<p className="text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
No custom profiles yet. Create one to get started!
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={customProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{customProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onDelete={() => setProfileToDelete(profile)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Built-in Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Built-in Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{builtInProfiles.length}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
Pre-configured profiles for common use cases. These cannot be
edited or deleted.
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={builtInProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{builtInProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => {}}
onDelete={() => {}}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent data-testid="add-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
<DialogHeader className="shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>
Define a reusable model configuration preset.
</DialogDescription>
</DialogHeader>
<ProfileForm
profile={{}}
onSave={handleAddProfile}
onCancel={() => setShowAddDialog(false)}
isEditing={false}
hotkeyActive={showAddDialog}
/>
</DialogContent>
</Dialog>
{/* Edit Profile Dialog */}
<Dialog
open={!!editingProfile}
onOpenChange={() => setEditingProfile(null)}
>
<DialogContent data-testid="edit-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
<DialogHeader className="shrink-0">
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription>
</DialogHeader>
{editingProfile && (
<ProfileForm
profile={editingProfile}
onSave={handleUpdateProfile}
onCancel={() => setEditingProfile(null)}
isEditing={true}
hotkeyActive={!!editingProfile}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={!!profileToDelete}
onOpenChange={(open) => !open && setProfileToDelete(null)}
onConfirm={confirmDeleteProfile}
title="Delete Profile"
description={
profileToDelete
? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.`
: ""
}
confirmText="Delete Profile"
testId="delete-profile-confirm-dialog"
confirmTestId="confirm-delete-profile-button"
/>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { SortableProfileCard } from "./sortable-profile-card";
export { ProfileForm } from "./profile-form";
export { ProfilesHeader } from "./profiles-header";

View File

@@ -0,0 +1,203 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn, modelSupportsThinking } from "@/lib/utils";
import { DialogFooter } from "@/components/ui/dialog";
import { Brain } from "lucide-react";
import { toast } from "sonner";
import type { AIProfile, AgentModel, ThinkingLevel } from "@/store/app-store";
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from "../constants";
import { getProviderFromModel } from "../utils";
interface ProfileFormProps {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, "id">) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
}
export function ProfileForm({
profile,
onSave,
onCancel,
isEditing,
hotkeyActive,
}: ProfileFormProps) {
const [formData, setFormData] = useState({
name: profile.name || "",
description: profile.description || "",
model: profile.model || ("opus" as AgentModel),
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
icon: profile.icon || "Brain",
});
const provider = getProviderFromModel(formData.model);
const supportsThinking = modelSupportsThinking(formData.model);
const handleModelChange = (model: AgentModel) => {
setFormData({
...formData,
model,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error("Please enter a profile name");
return;
}
onSave({
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
provider,
isBuiltIn: false,
icon: formData.icon,
});
};
return (
<>
<div className="overflow-y-auto flex-1 min-h-0 space-y-4 pr-3 -mr-3 pl-1">
{/* Name */}
<div className="mt-2 space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Heavy Task, Quick Fix"
data-testid="profile-name-input"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="profile-description">Description</Label>
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
/>
</div>
{/* Icon Selection */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex gap-2 flex-wrap">
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
formData.icon === name
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-border"
)}
data-testid={`icon-select-${name}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Model
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-border"
)}
data-testid={`model-select-${id}`}
>
{label.replace("Claude ", "")}
</button>
))}
</div>
</div>
{/* Thinking Level */}
{supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === "ultrathink") {
toast.warning("Ultrathink uses extensive reasoning", {
description:
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.thinkingLevel === id
? "bg-amber-500 text-white border-amber-400"
: "bg-background hover:bg-accent border-border"
)}
data-testid={`thinking-select-${id}`}
>
{label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
)}
</div>
{/* Actions */}
<DialogFooter className="pt-4 border-t border-border mt-4 shrink-0">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? "Save Changes" : "Create Profile"}
</HotkeyButton>
</DialogFooter>
</>
);
}

View File

@@ -0,0 +1,58 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { UserCircle, Plus, RefreshCw } from "lucide-react";
interface ProfilesHeaderProps {
onResetProfiles: () => void;
onAddProfile: () => void;
addProfileHotkey: string;
}
export function ProfilesHeader({
onResetProfiles,
onAddProfile,
addProfileHotkey,
}: ProfilesHeaderProps) {
return (
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Profiles
</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={onResetProfiles}
data-testid="refresh-profiles-button"
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh Defaults
</Button>
<HotkeyButton
onClick={onAddProfile}
hotkey={addProfileHotkey}
hotkeyActive={false}
data-testid="add-profile-button"
>
<Plus className="w-4 h-4 mr-2" />
New Profile
</HotkeyButton>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { GripVertical, Lock, Pencil, Trash2, Brain } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { AIProfile } from "@/store/app-store";
import { PROFILE_ICONS } from "../constants";
interface SortableProfileCardProps {
profile: AIProfile;
onEdit: () => void;
onDelete: () => void;
}
export function SortableProfileCard({
profile,
onEdit,
onDelete,
}: SortableProfileCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: profile.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
isDragging && "shadow-lg",
profile.isBuiltIn
? "border-border/50"
: "border-border hover:border-primary/50 hover:shadow-sm"
)}
data-testid={`profile-card-${profile.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
data-testid={`profile-drag-handle-${profile.id}`}
onClick={(e) => e.stopPropagation()}
aria-label={`Reorder ${profile.name} profile`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
{/* Icon */}
<div
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10"
>
{IconComponent && (
<IconComponent className="w-5 h-5 text-primary" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{profile.name}</h3>
{profile.isBuiltIn && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{profile.description}
</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10"
>
{profile.model}
</span>
{profile.thinkingLevel !== "none" && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
</span>
)}
</div>
</div>
{/* Actions */}
{!profile.isBuiltIn && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-8 w-8 p-0"
data-testid={`edit-profile-${profile.id}`}
aria-label={`Edit ${profile.name} profile`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
data-testid={`delete-profile-${profile.id}`}
aria-label={`Delete ${profile.name} profile`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
} from "lucide-react";
import type { AgentModel, ThinkingLevel } from "@/store/app-store";
// Icon mapping for profiles
export const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
};
// Available icons for selection
export const ICON_OPTIONS = [
{ name: "Brain", icon: Brain },
{ name: "Zap", icon: Zap },
{ name: "Scale", icon: Scale },
{ name: "Cpu", icon: Cpu },
{ name: "Rocket", icon: Rocket },
{ name: "Sparkles", icon: Sparkles },
];
// Model options for the form
export const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
{ id: "haiku", label: "Claude Haiku" },
{ id: "sonnet", label: "Claude Sonnet" },
{ id: "opus", label: "Claude Opus" },
];
export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: "none", label: "None" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "ultrathink", label: "Ultrathink" },
];

View File

@@ -0,0 +1,8 @@
import type { AgentModel, ModelProvider } from "@/store/app-store";
// Helper to determine provider from model
export function getProviderFromModel(model: AgentModel): ModelProvider {
return "claude";
}

View File

@@ -0,0 +1,211 @@
import { useState, useEffect, useCallback } from "react";
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from "lucide-react";
import { getElectronAPI, RunningAgent } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useNavigate } from "@tanstack/react-router";
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const { setCurrentProject, projects } = useAppStore();
const navigate = useNavigate();
const fetchRunningAgents = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
setRunningAgents(result.runningAgents);
}
}
} catch (error) {
console.error("[RunningAgentsView] Error fetching running agents:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// Initial fetch
useEffect(() => {
fetchRunningAgents();
}, [fetchRunningAgents]);
// Auto-refresh every 2 seconds
useEffect(() => {
const interval = setInterval(() => {
fetchRunningAgents();
}, 2000);
return () => clearInterval(interval);
}, [fetchRunningAgents]);
// Subscribe to auto-mode events to update in real-time
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
// When a feature completes or errors, refresh the list
if (
event.type === "auto_mode_feature_complete" ||
event.type === "auto_mode_error"
) {
fetchRunningAgents();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgents]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchRunningAgents();
}, [fetchRunningAgents]);
const handleStopAgent = useCallback(async (featureId: string) => {
try {
const api = getElectronAPI();
if (api.autoMode) {
await api.autoMode.stopFeature(featureId);
// Refresh list after stopping
fetchRunningAgents();
}
} catch (error) {
console.error("[RunningAgentsView] Error stopping agent:", error);
}
}, [fetchRunningAgents]);
const handleNavigateToProject = useCallback((agent: RunningAgent) => {
// Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
setCurrentProject(project);
navigate({ to: "/board" });
}
}, [projects, setCurrentProject, navigate]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-500/10">
<Activity className="h-6 w-6 text-brand-500" />
</div>
<div>
<h1 className="text-2xl font-bold">Running Agents</h1>
<p className="text-sm text-muted-foreground">
{runningAgents.length === 0
? "No agents currently running"
: `${runningAgents.length} agent${runningAgents.length === 1 ? "" : "s"} running across all projects`}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")}
/>
Refresh
</Button>
</div>
{/* Content */}
{runningAgents.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Bot className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">No Running Agents</h2>
<p className="text-muted-foreground max-w-md">
Agents will appear here when they are actively working on features.
Start an agent from the Kanban board by dragging a feature to "In Progress".
</p>
</div>
) : (
<div className="flex-1 overflow-auto">
<div className="space-y-3">
{runningAgents.map((agent) => (
<div
key={`${agent.projectPath}-${agent.featureId}`}
className="flex items-center justify-between p-4 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-4 min-w-0">
{/* Status indicator */}
<div className="relative">
<Bot className="h-8 w-8 text-brand-500" />
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
</div>
{/* Agent info */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">
{agent.featureId}
</span>
{agent.isAutoMode && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
AUTO
</span>
)}
</div>
<button
onClick={() => handleNavigateToProject(agent)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Folder className="h-3.5 w-3.5" />
<span className="truncate">{agent.projectName}</span>
</button>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleNavigateToProject(agent)}
className="text-muted-foreground hover:text-foreground"
>
View Project
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleStopAgent(agent.featureId)}
>
<Square className="h-3.5 w-3.5 mr-1.5" />
Stop
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { useCliStatus, useSettingsView } from "./settings-view/hooks";
import { NAV_ITEMS } from "./settings-view/config/navigation";
import { SettingsHeader } from "./settings-view/components/settings-header";
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
import { AudioSection } from "./settings-view/audio/audio-section";
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
import type {
Project as SettingsProject,
Theme,
} from "./settings-view/shared/types";
import type { Project as ElectronProject } from "@/lib/electron";
export function SettingsView() {
const {
theme,
setTheme,
setProjectTheme,
defaultSkipTests,
setDefaultSkipTests,
enableDependencyBlocking,
setEnableDependencyBlocking,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
setShowProfilesOnly,
muteDoneSound,
setMuteDoneSound,
currentProject,
moveProjectToTrash,
defaultPlanningMode,
setDefaultPlanningMode,
defaultRequirePlanApproval,
setDefaultRequirePlanApproval,
} = useAppStore();
// Convert electron Project to settings-view Project type
const convertProject = (
project: ElectronProject | null
): SettingsProject | null => {
if (!project) return null;
return {
id: project.id,
name: project.name,
path: project.path,
theme: project.theme as Theme | undefined,
};
};
const settingsProject = convertProject(currentProject);
// Compute the effective theme for the current project
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
// Handler to set theme - always updates global theme (user's preference),
// and also sets per-project theme if a project is selected
const handleSetTheme = (newTheme: typeof theme) => {
// Always update global theme so user's preference persists across all projects
setTheme(newTheme);
// Also set per-project theme if a project is selected
if (currentProject) {
setProjectTheme(currentProject.id, newTheme);
}
};
// Use CLI status hook
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } =
useCliStatus();
// Use settings view navigation hook
const { activeView, navigateTo } = useSettingsView();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
case "claude":
return (
<ClaudeCliStatus
status={claudeCliStatus}
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
);
case "ai-enhancement":
return <AIEnhancementSection />;
case "appearance":
return (
<AppearanceSection
effectiveTheme={effectiveTheme}
currentProject={settingsProject}
onThemeChange={handleSetTheme}
/>
);
case "keyboard":
return (
<KeyboardShortcutsSection
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
/>
);
case "audio":
return (
<AudioSection
muteDoneSound={muteDoneSound}
onMuteDoneSoundChange={setMuteDoneSound}
/>
);
case "defaults":
return (
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
/>
);
case "danger":
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
);
default:
return <ApiKeysSection />;
}
};
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="settings-view"
>
{/* Header Section */}
<SettingsHeader />
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Side Navigation - No longer scrolls, just switches views */}
<SettingsNavigation
navItems={NAV_ITEMS}
activeSection={activeView}
currentProject={currentProject}
onNavigate={navigateTo}
/>
{/* Content Panel - Shows only the active section */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
</div>
</div>
{/* Keyboard Map Dialog */}
<KeyboardMapDialog
open={showKeyboardMapDialog}
onOpenChange={setShowKeyboardMapDialog}
/>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { Label } from "@/components/ui/label";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
export function AIEnhancementSection() {
const { enhancementModel, setEnhancementModel } = useAppStore();
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Sparkles className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">AI Enhancement</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Choose the model used when enhancing feature descriptions.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-4">
<Label className="text-foreground font-medium">
Enhancement Model
</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{CLAUDE_MODELS.map(({ id, label, description, badge }) => {
const isActive = enhancementModel === id;
return (
<button
key={id}
onClick={() => setEnhancementModel(id)}
className={cn(
"group flex flex-col items-start gap-2 px-4 py-4 rounded-xl text-left",
"transition-all duration-200 ease-out",
isActive
? [
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
"border-2 border-brand-500/40",
"text-foreground",
"shadow-md shadow-brand-500/10",
]
: [
"bg-accent/30 hover:bg-accent/50",
"border border-border/50 hover:border-border",
"text-muted-foreground hover:text-foreground",
"hover:shadow-sm",
],
"hover:scale-[1.02] active:scale-[0.98]"
)}
data-testid={`enhancement-model-${id}`}
>
<div className="flex items-center gap-2 w-full">
<span className={cn(
"font-medium text-sm",
isActive ? "text-foreground" : "group-hover:text-foreground"
)}>
{label}
</span>
{badge && (
<span className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
isActive
? "bg-brand-500/20 text-brand-500"
: "bg-accent text-muted-foreground"
)}>
{badge}
</span>
)}
</div>
<span className="text-xs text-muted-foreground/80">
{description}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { AIEnhancementSection } from "./ai-enhancement-section";

View File

@@ -0,0 +1,117 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from "lucide-react";
import type { ProviderConfig } from "@/config/api-providers";
interface ApiKeyFieldProps {
config: ProviderConfig;
}
export function ApiKeyField({ config }: ApiKeyFieldProps) {
const {
label,
inputId,
placeholder,
value,
setValue,
showValue,
setShowValue,
hasStoredKey,
inputTestId,
toggleTestId,
testButton,
result,
resultTestId,
resultMessageTestId,
descriptionPrefix,
descriptionLinkHref,
descriptionLinkText,
descriptionSuffix,
} = config;
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label htmlFor={inputId} className="text-foreground">
{label}
</Label>
{hasStoredKey && <CheckCircle2 className="w-4 h-4 text-brand-500" />}
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id={inputId}
type={showValue ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground"
data-testid={inputTestId}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
onClick={() => setShowValue(!showValue)}
data-testid={toggleTestId}
>
{showValue ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<Button
type="button"
variant="secondary"
onClick={testButton.onClick}
disabled={testButton.disabled}
className="bg-secondary hover:bg-accent text-secondary-foreground border border-border"
data-testid={testButton.testId}
>
{testButton.loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="w-4 h-4 mr-2" />
Test
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{descriptionPrefix}{" "}
<a
href={descriptionLinkHref}
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:text-brand-400 hover:underline"
>
{descriptionLinkText}
</a>
{descriptionSuffix}
</p>
{result && (
<div
className={`flex items-center gap-2 p-3 rounded-lg ${
result.success
? "bg-green-500/10 border border-green-500/20 text-green-400"
: "bg-red-500/10 border border-red-500/20 text-red-400"
}`}
data-testid={resultTestId}
>
{result.success ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
<span className="text-sm" data-testid={resultMessageTestId}>
{result.message}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { Button } from "@/components/ui/button";
import { Key, CheckCircle2, Settings, Trash2, Loader2 } from "lucide-react";
import { ApiKeyField } from "./api-key-field";
import { buildProviderConfigs } from "@/config/api-providers";
import { AuthenticationStatusDisplay } from "./authentication-status-display";
import { SecurityNotice } from "./security-notice";
import { useApiKeyManagement } from "./hooks/use-api-key-management";
import { cn } from "@/lib/utils";
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useNavigate } from "@tanstack/react-router";
export function ApiKeysSection() {
const { apiKeys, setApiKeys } = useAppStore();
const { claudeAuthStatus, setClaudeAuthStatus, setSetupComplete } = useSetupStore();
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
const navigate = useNavigate();
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
useApiKeyManagement();
const providerConfigs = buildProviderConfigs(providerConfigParams);
// Delete Anthropic API key
const deleteAnthropicKey = useCallback(async () => {
setIsDeletingAnthropicKey(true);
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error("Delete API not available");
return;
}
const result = await api.setup.deleteApiKey("anthropic");
if (result.success) {
setApiKeys({ ...apiKeys, anthropic: "" });
setClaudeAuthStatus({
authenticated: false,
method: "none",
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
toast.success("Anthropic API key deleted");
} else {
toast.error(result.error || "Failed to delete API key");
}
} catch (error) {
toast.error("Failed to delete API key");
} finally {
setIsDeletingAnthropicKey(false);
}
}, [apiKeys, setApiKeys, claudeAuthStatus, setClaudeAuthStatus]);
// Open setup wizard
const openSetupWizard = useCallback(() => {
setSetupComplete(false);
navigate({ to: "/setup" });
}, [setSetupComplete, navigate]);
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Key className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">API Keys</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure your AI provider API keys. Keys are stored locally in your browser.
</p>
</div>
<div className="p-6 space-y-6">
{/* API Key Fields */}
{providerConfigs.map((provider) => (
<ApiKeyField key={provider.key} config={provider} />
))}
{/* Authentication Status Display */}
<AuthenticationStatusDisplay
claudeAuthStatus={claudeAuthStatus}
apiKeyStatus={apiKeyStatus}
apiKeys={apiKeys}
/>
{/* Security Notice */}
<SecurityNotice />
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button
onClick={handleSave}
data-testid="save-settings"
className={cn(
"min-w-[140px] h-10",
"bg-gradient-to-r from-brand-500 to-brand-600",
"hover:from-brand-600 hover:to-brand-600",
"text-white font-medium border-0",
"shadow-md shadow-brand-500/20 hover:shadow-lg hover:shadow-brand-500/25",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.98]"
)}
>
{saved ? (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Saved!
</>
) : (
"Save API Keys"
)}
</Button>
<Button
onClick={openSetupWizard}
variant="outline"
className="h-10 border-border"
data-testid="run-setup-wizard"
>
<Settings className="w-4 h-4 mr-2" />
Run Setup Wizard
</Button>
{apiKeys.anthropic && (
<Button
onClick={deleteAnthropicKey}
disabled={isDeletingAnthropicKey}
variant="outline"
className="h-10 border-red-500/30 text-red-500 hover:bg-red-500/10 hover:border-red-500/50"
data-testid="delete-anthropic-key"
>
{isDeletingAnthropicKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
Delete Anthropic Key
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { Label } from "@/components/ui/label";
import {
CheckCircle2,
AlertCircle,
Info,
Terminal,
Sparkles,
} from "lucide-react";
import type { ClaudeAuthStatus } from "@/store/setup-store";
interface AuthenticationStatusDisplayProps {
claudeAuthStatus: ClaudeAuthStatus | null;
apiKeyStatus: {
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
} | null;
apiKeys: {
anthropic: string;
google: string;
};
}
export function AuthenticationStatusDisplay({
claudeAuthStatus,
apiKeyStatus,
apiKeys,
}: AuthenticationStatusDisplayProps) {
return (
<div className="space-y-4 pt-4 border-t border-border">
<div className="flex items-center gap-2 mb-3">
<Info className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-semibold">
Current Authentication Configuration
</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Claude Authentication Status */}
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Terminal className="w-4 h-4 text-brand-500" />
<span className="text-sm font-medium text-foreground">
Claude (Anthropic)
</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{claudeAuthStatus?.authenticated ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">
Authenticated
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{claudeAuthStatus.method === "oauth_token"
? "Using stored OAuth token (subscription)"
: claudeAuthStatus.method === "api_key_env"
? "Using ANTHROPIC_API_KEY"
: claudeAuthStatus.method === "api_key"
? "Using stored API key"
: claudeAuthStatus.method === "credentials_file"
? "Using credentials file"
: claudeAuthStatus.method === "cli_authenticated"
? "Using Claude CLI authentication"
: `Using ${
claudeAuthStatus.method || "detected"
} authentication`}
</span>
</div>
</>
) : apiKeyStatus?.hasAnthropicKey ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using environment variable (ANTHROPIC_API_KEY)</span>
</div>
) : apiKeys.anthropic ? (
<div className="flex items-center gap-2 text-blue-400">
<Info className="w-3 h-3 shrink-0" />
<span>Using manual API key from settings</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
<AlertCircle className="w-3 h-3 shrink-0" />
<span className="text-xs">Not configured</span>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { ProviderConfigParams } from "@/config/api-providers";
interface TestResult {
success: boolean;
message: string;
}
interface ApiKeyStatus {
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
}
/**
* Custom hook for managing API key state and operations
* Handles input values, visibility toggles, connection testing, and saving
*/
export function useApiKeyManagement() {
const { apiKeys, setApiKeys } = useAppStore();
// API key values
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google);
// Visibility toggles
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
const [showGoogleKey, setShowGoogleKey] = useState(false);
// Test connection states
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
null
);
// API key status from environment
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
// Save state
const [saved, setSaved] = useState(false);
// Sync local state with store
useEffect(() => {
setAnthropicKey(apiKeys.anthropic);
setGoogleKey(apiKeys.google);
}, [apiKeys]);
// Check API key status from environment on mount
useEffect(() => {
const checkApiKeyStatus = async () => {
const api = getElectronAPI();
if (api?.setup?.getApiKeys) {
try {
const status = await api.setup.getApiKeys();
if (status.success) {
setApiKeyStatus({
hasAnthropicKey: status.hasAnthropicKey,
hasGoogleKey: status.hasGoogleKey,
});
}
} catch (error) {
console.error("Failed to check API key status:", error);
}
}
};
checkApiKeyStatus();
}, []);
// Test Anthropic/Claude connection
const handleTestAnthropicConnection = async () => {
setTestingConnection(true);
setTestResult(null);
try {
const api = getElectronAPI();
const data = await api.setup.verifyClaudeAuth("api_key");
if (data.success && data.authenticated) {
setTestResult({
success: true,
message: "Connection successful! Claude responded.",
});
} else {
setTestResult({
success: false,
message: data.error || "Failed to connect to Claude API.",
});
}
} catch {
setTestResult({
success: false,
message: "Network error. Please check your connection.",
});
} finally {
setTestingConnection(false);
}
};
// Test Google/Gemini connection
// TODO: Add backend endpoint for Gemini API key verification
const handleTestGeminiConnection = async () => {
setTestingGeminiConnection(true);
setGeminiTestResult(null);
// Basic validation - check key format
if (!googleKey || googleKey.trim().length < 10) {
setGeminiTestResult({
success: false,
message: "Please enter a valid API key.",
});
setTestingGeminiConnection(false);
return;
}
// For now, just validate the key format (starts with expected prefix)
// Full verification requires a backend endpoint
setGeminiTestResult({
success: true,
message: "API key saved. Connection test not yet available.",
});
setTestingGeminiConnection(false);
};
// Save API keys
const handleSave = () => {
setApiKeys({
anthropic: anthropicKey,
google: googleKey,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
// Build provider config params for buildProviderConfigs
const providerConfigParams: ProviderConfigParams = {
apiKeys,
anthropic: {
value: anthropicKey,
setValue: setAnthropicKey,
show: showAnthropicKey,
setShow: setShowAnthropicKey,
testing: testingConnection,
onTest: handleTestAnthropicConnection,
result: testResult,
},
google: {
value: googleKey,
setValue: setGoogleKey,
show: showGoogleKey,
setShow: setShowGoogleKey,
testing: testingGeminiConnection,
onTest: handleTestGeminiConnection,
result: geminiTestResult,
},
};
return {
// Provider config params for buildProviderConfigs
providerConfigParams,
// API key status from environment
apiKeyStatus,
// Save handler and state
handleSave,
saved,
};
}

View File

@@ -0,0 +1,21 @@
import { AlertCircle } from "lucide-react";
interface SecurityNoticeProps {
title?: string;
message?: string;
}
export function SecurityNotice({
title = "Security Notice",
message = "API keys are stored in your browser's local storage. Never share your API keys or commit them to version control.",
}: SecurityNoticeProps) {
return (
<div className="flex items-start gap-3 p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium text-yellow-500">{title}</p>
<p className="text-yellow-500/80 text-xs mt-1">{message}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { Label } from "@/components/ui/label";
import { Palette } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { cn } from "@/lib/utils";
import type { Theme, Project } from "../shared/types";
interface AppearanceSectionProps {
effectiveTheme: Theme;
currentProject: Project | null;
onThemeChange: (theme: Theme) => void;
}
export function AppearanceSection({
effectiveTheme,
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Palette className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Appearance</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize the look and feel of your application.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-4">
<Label className="text-foreground font-medium">
Theme{" "}
<span className="text-muted-foreground font-normal">
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
</span>
</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{themeOptions.map(({ value, label, Icon, testId }) => {
const isActive = effectiveTheme === value;
return (
<button
key={value}
onClick={() => onThemeChange(value)}
className={cn(
"group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl",
"text-sm font-medium transition-all duration-200 ease-out",
isActive
? [
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
"border-2 border-brand-500/40",
"text-foreground",
"shadow-md shadow-brand-500/10",
]
: [
"bg-accent/30 hover:bg-accent/50",
"border border-border/50 hover:border-border",
"text-muted-foreground hover:text-foreground",
"hover:shadow-sm",
],
"hover:scale-[1.02] active:scale-[0.98]"
)}
data-testid={testId}
>
<Icon className={cn(
"w-4 h-4 transition-all duration-200",
isActive ? "text-brand-500" : "group-hover:text-brand-400"
)} />
<span>{label}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
interface AudioSectionProps {
muteDoneSound: boolean;
onMuteDoneSoundChange: (value: boolean) => void;
}
export function AudioSection({
muteDoneSound,
onMuteDoneSoundChange,
}: AudioSectionProps) {
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Volume2 className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Audio
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure audio and notification settings.
</p>
</div>
<div className="p-6 space-y-4">
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="mute-done-sound"
checked={muteDoneSound}
onCheckedChange={onMuteDoneSoundChange}
className="mt-1"
data-testid="mute-done-sound-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="mute-done-sound"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<VolumeX className="w-4 h-4 text-brand-500" />
Mute notification sound when agents complete
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, disables the &quot;ding&quot; sound that plays when
an agent completes a feature. The feature will still move to the
completed column, but without audio notification.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { Button } from "@/components/ui/button";
import {
Terminal,
CheckCircle2,
AlertCircle,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { CliStatus } from "../shared/types";
interface CliStatusProps {
status: CliStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
export function ClaudeCliStatus({
status,
isChecking,
onRefresh,
}: CliStatusProps) {
if (!status) return null;
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Claude Code CLI
</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-claude-cli"
title="Refresh Claude CLI detection"
className={cn(
"h-9 w-9 rounded-lg",
"hover:bg-accent/50 hover:scale-105",
"transition-all duration-200"
)}
>
<RefreshCw
className={cn("w-4 h-4", isChecking && "animate-spin")}
/>
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Claude Code CLI provides better performance for long-running tasks,
especially with ultrathink.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === "installed" ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">
Claude Code CLI Installed
</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">
{status.recommendation}
</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">
Claude Code CLI Not Detected
</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation ||
"Consider installing Claude Code CLI for optimal performance with ultrathink."}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">
Installation Commands:
</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">npm</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">macOS/Linux</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
{status.installCommands.windows && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">Windows (PowerShell)</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.windows}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { Folder, Trash2 } from "lucide-react";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Project } from "@/lib/electron";
interface DeleteProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: Project | null;
onConfirm: (projectId: string) => void;
}
export function DeleteProjectDialog({
open,
onOpenChange,
project,
onConfirm,
}: DeleteProjectDialogProps) {
const handleConfirm = () => {
if (project) {
onConfirm(project.id);
}
};
return (
<DeleteConfirmDialog
open={open}
onOpenChange={onOpenChange}
onConfirm={handleConfirm}
title="Delete Project"
description="Are you sure you want to move this project to Trash?"
confirmText="Move to Trash"
testId="delete-project-dialog"
confirmTestId="confirm-delete-project"
>
{project && (
<>
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{project.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{project.path}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground">
The folder will remain on disk until you permanently delete it from
Trash.
</p>
</>
)}
</DeleteConfirmDialog>
);
}

View File

@@ -0,0 +1,46 @@
import { Keyboard } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map";
interface KeyboardMapDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function KeyboardMapDialog({ open, onOpenChange }: KeyboardMapDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5 text-brand-500" />
Keyboard Shortcut Map
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Visual overview of all keyboard shortcuts. Keys in color are bound to
shortcuts. Click on any shortcut below to edit it.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-6 py-4 pl-3 pr-6 pb-6">
{/* Visual Keyboard Map */}
<KeyboardMap />
{/* Shortcut Reference - Editable */}
<div className="border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground mb-4">
All Shortcuts Reference (Click to Edit)
</h3>
<ShortcutReferencePanel editable />
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
import { Settings } from "lucide-react";
import { cn } from "@/lib/utils";
interface SettingsHeaderProps {
title?: string;
description?: string;
}
export function SettingsHeader({
title = "Settings",
description = "Configure your API keys and preferences",
}: SettingsHeaderProps) {
return (
<div className={cn(
"shrink-0",
"border-b border-border/50",
"bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl"
)}>
<div className="px-8 py-6">
<div className="flex items-center gap-4">
<div className={cn(
"w-12 h-12 rounded-2xl flex items-center justify-center",
"bg-gradient-to-br from-brand-500 to-brand-600",
"shadow-lg shadow-brand-500/25",
"ring-1 ring-white/10"
)}>
<Settings className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground/80 mt-0.5">{description}</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/electron";
import type { NavigationItem } from "../config/navigation";
import type { SettingsViewId } from "../hooks/use-settings-view";
interface SettingsNavigationProps {
navItems: NavigationItem[];
activeSection: SettingsViewId;
currentProject: Project | null;
onNavigate: (sectionId: SettingsViewId) => void;
}
export function SettingsNavigation({
navItems,
activeSection,
currentProject,
onNavigate,
}: SettingsNavigationProps) {
return (
<nav className={cn(
"hidden lg:block w-52 shrink-0",
"border-r border-border/50",
"bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl"
)}>
<div className="sticky top-0 p-4 space-y-1.5">
{navItems
.filter((item) => item.id !== "danger" || currentProject)
.map((item) => {
const Icon = item.icon;
const isActive = activeSection === item.id;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={cn(
"group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden",
isActive
? [
"bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5",
"text-foreground",
"border border-brand-500/25",
"shadow-sm shadow-brand-500/5",
]
: [
"text-muted-foreground hover:text-foreground",
"hover:bg-accent/50",
"border border-transparent hover:border-border/40",
],
"hover:scale-[1.01] active:scale-[0.98]"
)}
>
{/* Active indicator bar */}
{isActive && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full" />
)}
<Icon
className={cn(
"w-4 h-4 shrink-0 transition-all duration-200",
isActive
? "text-brand-500"
: "group-hover:text-brand-400 group-hover:scale-110"
)}
/>
<span className="truncate">{item.label}</span>
</button>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,30 @@
import type { LucideIcon } from "lucide-react";
import {
Key,
Terminal,
Palette,
Settings2,
Volume2,
FlaskConical,
Trash2,
Sparkles,
} from "lucide-react";
import type { SettingsViewId } from "../hooks/use-settings-view";
export interface NavigationItem {
id: SettingsViewId;
label: string;
icon: LucideIcon;
}
// Navigation items for the settings side panel
export const NAV_ITEMS: NavigationItem[] = [
{ id: "api-keys", label: "API Keys", icon: Key },
{ id: "claude", label: "Claude", icon: Terminal },
{ id: "ai-enhancement", label: "AI Enhancement", icon: Sparkles },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "audio", label: "Audio", icon: Volume2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
];

View File

@@ -0,0 +1,70 @@
import { Button } from "@/components/ui/button";
import { Trash2, Folder, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Project } from "../shared/types";
interface DangerZoneSectionProps {
project: Project | null;
onDeleteClick: () => void;
}
export function DangerZoneSection({
project,
onDeleteClick,
}: DangerZoneSectionProps) {
if (!project) return null;
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-destructive/30",
"bg-gradient-to-br from-destructive/5 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-destructive/5"
)}
>
<div className="p-6 border-b border-destructive/20 bg-gradient-to-r from-destructive/5 via-transparent to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-destructive/20 to-destructive/10 flex items-center justify-center border border-destructive/20">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Permanently remove this project from Automaker.
</p>
</div>
<div className="p-6">
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{project.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">
{project.path}
</p>
</div>
</div>
<Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
"shrink-0",
"shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.98]"
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,292 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
FlaskConical, Settings2, TestTube, GitBranch, AlertCircle,
Zap, ClipboardList, FileText, ScrollText, ShieldCheck
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
}
export function FeatureDefaultsSection({
showProfilesOnly,
defaultSkipTests,
enableDependencyBlocking,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
}: FeatureDefaultsSectionProps) {
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<FlaskConical className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Feature Defaults
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure default settings for new features.
</p>
</div>
<div className="p-6 space-y-5">
{/* Planning Mode Default */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className={cn(
"w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0",
defaultPlanningMode === 'skip' ? "bg-emerald-500/10" :
defaultPlanningMode === 'lite' ? "bg-blue-500/10" :
defaultPlanningMode === 'spec' ? "bg-purple-500/10" :
"bg-amber-500/10"
)}>
{defaultPlanningMode === 'skip' && <Zap className="w-5 h-5 text-emerald-500" />}
{defaultPlanningMode === 'lite' && <ClipboardList className="w-5 h-5 text-blue-500" />}
{defaultPlanningMode === 'spec' && <FileText className="w-5 h-5 text-purple-500" />}
{defaultPlanningMode === 'full' && <ScrollText className="w-5 h-5 text-amber-500" />}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">
Default Planning Mode
</Label>
<Select
value={defaultPlanningMode}
onValueChange={(v: string) => onDefaultPlanningModeChange(v as PlanningMode)}
>
<SelectTrigger
className="w-[160px] h-8"
data-testid="default-planning-mode-select"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
<div className="flex items-center gap-2">
<Zap className="h-3.5 w-3.5 text-emerald-500" />
<span>Skip</span>
<span className="text-[10px] text-muted-foreground">(Default)</span>
</div>
</SelectItem>
<SelectItem value="lite">
<div className="flex items-center gap-2">
<ClipboardList className="h-3.5 w-3.5 text-blue-500" />
<span>Lite Planning</span>
</div>
</SelectItem>
<SelectItem value="spec">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-purple-500" />
<span>Spec (Lite SDD)</span>
</div>
</SelectItem>
<SelectItem value="full">
<div className="flex items-center gap-2">
<ScrollText className="h-3.5 w-3.5 text-amber-500" />
<span>Full (SDD)</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{defaultPlanningMode === 'skip' && "Jump straight to implementation without upfront planning."}
{defaultPlanningMode === 'lite' && "Create a quick planning outline with tasks before building."}
{defaultPlanningMode === 'spec' && "Generate a specification with acceptance criteria for approval."}
{defaultPlanningMode === 'full' && "Create comprehensive spec with phased implementation plan."}
</p>
</div>
</div>
{/* Require Plan Approval Setting - only show when not skip */}
{defaultPlanningMode !== 'skip' && (
<>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-require-plan-approval"
checked={defaultRequirePlanApproval}
onCheckedChange={(checked) =>
onDefaultRequirePlanApprovalChange(checked === true)
}
className="mt-1"
data-testid="default-require-plan-approval-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-require-plan-approval"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<ShieldCheck className="w-4 h-4 text-brand-500" />
Require manual plan approval by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, the agent will pause after generating a plan and wait for you to
review, edit, and approve before starting implementation. You can also view the
plan from the feature card.
</p>
</div>
</div>
<div className="border-t border-border/30" />
</>
)}
{/* Separator */}
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
{/* Profiles Only Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="show-profiles-only"
checked={showProfilesOnly}
onCheckedChange={(checked) =>
onShowProfilesOnlyChange(checked === true)
}
className="mt-1"
data-testid="show-profiles-only-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-profiles-only"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Settings2 className="w-4 h-4 text-brand-500" />
Show profiles only by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, the Add Feature dialog will show only AI profiles
and hide advanced model tweaking options. This creates a cleaner, less
overwhelming UI.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Automated Testing Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-skip-tests"
checked={!defaultSkipTests}
onCheckedChange={(checked) =>
onDefaultSkipTestsChange(checked !== true)
}
className="mt-1"
data-testid="default-skip-tests-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-skip-tests"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<TestTube className="w-4 h-4 text-brand-500" />
Enable automated testing by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, new features will use TDD with automated tests. When disabled, features will
require manual verification.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Dependency Blocking Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="enable-dependency-blocking"
checked={enableDependencyBlocking}
onCheckedChange={(checked) =>
onEnableDependencyBlockingChange(checked === true)
}
className="mt-1"
data-testid="enable-dependency-blocking-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-dependency-blocking"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<AlertCircle className="w-4 h-4 text-brand-500" />
Enable Dependency Blocking
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, features with incomplete dependencies will show blocked badges
and warnings. Auto mode and backlog ordering always respect dependencies
regardless of this setting.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Isolation Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) =>
onUseWorktreesChange(checked === true)
}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/15 text-amber-500 border border-amber-500/20 font-medium">
experimental
</span>
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature. When disabled,
agents work directly in the main project directory.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { useCliStatus } from "./use-cli-status";
export { useSettingsView, type SettingsViewId } from "./use-settings-view";

View File

@@ -0,0 +1,106 @@
import { useState, useEffect, useCallback } from "react";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
interface CliStatusResult {
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}
/**
* Custom hook for managing Claude CLI status
* Handles checking CLI installation, authentication, and refresh functionality
*/
export function useCliStatus() {
const { setClaudeAuthStatus } = useSetupStore();
const [claudeCliStatus, setClaudeCliStatus] =
useState<CliStatusResult | null>(null);
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
// Check CLI status on mount
useEffect(() => {
const checkCliStatus = async () => {
const api = getElectronAPI();
// Check Claude CLI
if (api?.checkClaudeCli) {
try {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
} catch (error) {
console.error("Failed to check Claude CLI status:", error);
}
}
// Check Claude auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getClaudeStatus) {
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "cli_authenticated", "none"] as const;
type AuthMethod = typeof validMethods[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid: auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
console.error("Failed to check Claude auth status:", error);
}
}
};
checkCliStatus();
}, [setClaudeAuthStatus]);
// Refresh Claude CLI status
const handleRefreshClaudeCli = useCallback(async () => {
setIsCheckingClaudeCli(true);
try {
const api = getElectronAPI();
if (api?.checkClaudeCli) {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
}
} catch (error) {
console.error("Failed to refresh Claude CLI status:", error);
} finally {
setIsCheckingClaudeCli(false);
}
}, []);
return {
claudeCliStatus,
isCheckingClaudeCli,
handleRefreshClaudeCli,
};
}

View File

@@ -0,0 +1,30 @@
import { useState, useCallback } from "react";
export type SettingsViewId =
| "api-keys"
| "claude"
| "ai-enhancement"
| "appearance"
| "keyboard"
| "audio"
| "defaults"
| "danger";
interface UseSettingsViewOptions {
initialView?: SettingsViewId;
}
export function useSettingsView({
initialView = "api-keys",
}: UseSettingsViewOptions = {}) {
const [activeView, setActiveView] = useState<SettingsViewId>(initialView);
const navigateTo = useCallback((viewId: SettingsViewId) => {
setActiveView(viewId);
}, []);
return {
activeView,
navigateTo,
};
}

View File

@@ -0,0 +1,74 @@
import { Button } from "@/components/ui/button";
import { Settings2, Keyboard } from "lucide-react";
import { cn } from "@/lib/utils";
interface KeyboardShortcutsSectionProps {
onOpenKeyboardMap: () => void;
}
export function KeyboardShortcutsSection({
onOpenKeyboardMap,
}: KeyboardShortcutsSectionProps) {
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Settings2 className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Keyboard Shortcuts
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize keyboard shortcuts for navigation and actions using the
visual keyboard map.
</p>
</div>
<div className="p-6">
{/* Centered message directing to keyboard map */}
<div className="flex flex-col items-center justify-center py-12 text-center space-y-5">
<div className="relative">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-brand-500/10 to-brand-600/5 flex items-center justify-center border border-brand-500/20">
<Keyboard className="w-10 h-10 text-brand-500/60" />
</div>
<div className="absolute inset-0 bg-brand-500/10 blur-2xl rounded-full -z-10" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="text-lg font-semibold text-foreground">
Use the Visual Keyboard Map
</h3>
<p className="text-sm text-muted-foreground/80">
Click the button below to customize your keyboard shortcuts. The visual
interface shows all available keys and lets you easily edit shortcuts.
</p>
</div>
<Button
variant="default"
size="lg"
onClick={onOpenKeyboardMap}
className={cn(
"gap-2.5 mt-2 h-11 px-6",
"bg-gradient-to-r from-brand-500 to-brand-600",
"hover:from-brand-600 hover:to-brand-600",
"text-white font-medium border-0",
"shadow-md shadow-brand-500/20 hover:shadow-lg hover:shadow-brand-500/25",
"transition-all duration-200 ease-out",
"hover:scale-[1.02] active:scale-[0.98]"
)}
>
<Keyboard className="w-5 h-5" />
Open Keyboard Map
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
// Shared TypeScript types for settings view components
export interface CliStatus {
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}
export type Theme =
| "dark"
| "light"
| "retro"
| "dracula"
| "nord"
| "monokai"
| "tokyonight"
| "solarized"
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave"
| "red"
| "cream"
| "sunset"
| "gray";
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
export interface Project {
id: string;
name: string;
path: string;
theme?: Theme;
}
export interface ApiKeys {
anthropic: string;
google: string;
openai: string;
}

View File

@@ -0,0 +1,142 @@
import { useSetupStore } from "@/store/setup-store";
import { StepIndicator } from "./setup-view/components";
import {
WelcomeStep,
CompleteStep,
ClaudeSetupStep,
GitHubSetupStep,
} from "./setup-view/steps";
import { useNavigate } from "@tanstack/react-router";
// Main Setup View
export function SetupView() {
const {
currentStep,
setCurrentStep,
completeSetup,
setSkipClaudeSetup,
} = useSetupStore();
const navigate = useNavigate();
const steps = ["welcome", "claude", "github", "complete"] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude";
if (currentStep === "welcome") return "welcome";
if (currentStep === "github") return "github";
return "complete";
};
const currentIndex = steps.indexOf(getStepName());
const handleNext = (from: string) => {
console.log(
"[Setup Flow] handleNext called from:",
from,
"currentStep:",
currentStep
);
switch (from) {
case "welcome":
console.log("[Setup Flow] Moving to claude_detect step");
setCurrentStep("claude_detect");
break;
case "claude":
console.log("[Setup Flow] Moving to github step");
setCurrentStep("github");
break;
case "github":
console.log("[Setup Flow] Moving to complete step");
setCurrentStep("complete");
break;
}
};
const handleBack = (from: string) => {
console.log("[Setup Flow] handleBack called from:", from);
switch (from) {
case "claude":
setCurrentStep("welcome");
break;
case "github":
setCurrentStep("claude_detect");
break;
}
};
const handleSkipClaude = () => {
console.log("[Setup Flow] Skipping Claude setup");
setSkipClaudeSetup(true);
setCurrentStep("github");
};
const handleSkipGithub = () => {
console.log("[Setup Flow] Skipping GitHub setup");
setCurrentStep("complete");
};
const handleFinish = () => {
console.log("[Setup Flow] handleFinish called - completing setup");
completeSetup();
console.log("[Setup Flow] Setup completed, redirecting to welcome view");
navigate({ to: "/" });
};
return (
<div className="h-full flex flex-col content-bg" data-testid="setup-view">
{/* Header */}
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md titlebar-drag-region">
<div className="px-8 py-4">
<div className="flex items-center gap-3 titlebar-no-drag">
<img src="/logo.png" alt="Automaker" className="w-8 h-8" />
<span className="text-lg font-semibold text-foreground">
Automaker Setup
</span>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-8">
<div className="w-full max-w-2xl mx-auto">
<div className="mb-8">
<StepIndicator
currentStep={currentIndex}
totalSteps={steps.length}
/>
</div>
<div className="py-8">
{currentStep === "welcome" && (
<WelcomeStep onNext={() => handleNext("welcome")} />
)}
{(currentStep === "claude_detect" ||
currentStep === "claude_auth") && (
<ClaudeSetupStep
onNext={() => handleNext("claude")}
onBack={() => handleBack("claude")}
onSkip={handleSkipClaude}
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { ReactNode } from "react";
interface AuthMethodOption {
id: string;
icon: ReactNode;
title: string;
description: string;
badge: string;
badgeColor: string; // e.g., "brand-500", "green-500"
}
interface AuthMethodSelectorProps {
options: AuthMethodOption[];
onSelect: (methodId: string) => void;
}
// Map badge colors to complete Tailwind class names
const getBadgeClasses = (badgeColor: string) => {
const colorMap: Record<string, { border: string; bg: string; text: string }> = {
"brand-500": {
border: "hover:border-brand-500/50",
bg: "hover:bg-brand-500/5",
text: "text-brand-500",
},
"green-500": {
border: "hover:border-green-500/50",
bg: "hover:bg-green-500/5",
text: "text-green-500",
},
"blue-500": {
border: "hover:border-blue-500/50",
bg: "hover:bg-blue-500/5",
text: "text-blue-500",
},
"purple-500": {
border: "hover:border-purple-500/50",
bg: "hover:bg-purple-500/5",
text: "text-purple-500",
},
};
return colorMap[badgeColor] || {
border: "hover:border-brand-500/50",
bg: "hover:bg-brand-500/5",
text: "text-brand-500",
};
};
export function AuthMethodSelector({
options,
onSelect,
}: AuthMethodSelectorProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{options.map((option) => {
const badgeClasses = getBadgeClasses(option.badgeColor);
return (
<button
key={option.id}
onClick={() => onSelect(option.id)}
className={`p-4 rounded-lg border border-border ${badgeClasses.border} bg-card ${badgeClasses.bg} transition-all text-left`}
data-testid={`select-${option.id}-auth`}
>
<div className="flex items-start gap-3">
{option.icon}
<div>
<p className="font-medium text-foreground">{option.title}</p>
<p className="text-sm text-muted-foreground mt-1">
{option.description}
</p>
<p className={`text-xs ${badgeClasses.text} mt-2`}>
{option.badge}
</p>
</div>
</div>
</button>
);
})}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More