Merge main into kanban-scaling

Resolves merge conflicts while preserving:
- Kanban scaling improvements (window sizing, bounce prevention, debouncing)
- Main's sidebar refactoring into hooks
- Main's openInEditor functionality for VS Code integration

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
trueheads
2025-12-22 01:49:45 -06:00
599 changed files with 26666 additions and 24168 deletions

View File

@@ -1,16 +1,9 @@
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 { 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,
@@ -22,9 +15,9 @@ import {
File,
Pencil,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
interface ToolResult {
success: boolean;
@@ -45,20 +38,18 @@ export function AgentToolsView() {
const api = getElectronAPI();
// Read File Tool State
const [readFilePath, setReadFilePath] = useState("");
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 [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 [terminalCommand, setTerminalCommand] = useState('ls');
const [terminalResult, setTerminalResult] = useState<ToolResult | null>(null);
const [isRunningCommand, setIsRunningCommand] = useState(false);
@@ -85,7 +76,7 @@ export function AgentToolsView() {
} else {
setReadFileResult({
success: false,
error: result.error || "Failed to read file",
error: result.error || 'Failed to read file',
timestamp: new Date(),
});
console.log(`[Agent Tool] File read failed: ${result.error}`);
@@ -93,7 +84,7 @@ export function AgentToolsView() {
} catch (error) {
setReadFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -124,7 +115,7 @@ export function AgentToolsView() {
} else {
setWriteFileResult({
success: false,
error: result.error || "Failed to write file",
error: result.error || 'Failed to write file',
timestamp: new Date(),
});
console.log(`[Agent Tool] File write failed: ${result.error}`);
@@ -132,7 +123,7 @@ export function AgentToolsView() {
} catch (error) {
setWriteFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -154,13 +145,12 @@ export function AgentToolsView() {
// 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",
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}',
'cat package.json': '{\n "name": "demo-project",\n "version": "1.0.0"\n}',
};
// Simulate command execution delay
@@ -175,13 +165,11 @@ export function AgentToolsView() {
output: output,
timestamp: new Date(),
});
console.log(
`[Agent Tool] Command executed successfully: ${terminalCommand}`
);
console.log(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
} catch (error) {
setTerminalResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -191,26 +179,18 @@ export function AgentToolsView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="agent-tools-no-project"
>
<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>
<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"
>
<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" />
@@ -232,9 +212,7 @@ export function AgentToolsView() {
<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>
<CardDescription>Agent requests to read a file from the filesystem</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -270,10 +248,10 @@ export function AgentToolsView() {
{readFileResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
readFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="read-file-result"
>
@@ -284,13 +262,11 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{readFileResult.success ? "Success" : "Failed"}
{readFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{readFileResult.success
? readFileResult.output
: readFileResult.error}
{readFileResult.success ? readFileResult.output : readFileResult.error}
</pre>
</div>
)}
@@ -304,9 +280,7 @@ export function AgentToolsView() {
<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>
<CardDescription>Agent requests to write content to a file</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -332,11 +306,7 @@ export function AgentToolsView() {
</div>
<Button
onClick={handleWriteFile}
disabled={
isWritingFile ||
!writeFilePath.trim() ||
!writeFileContent.trim()
}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full"
data-testid="write-file-button"
>
@@ -357,10 +327,10 @@ export function AgentToolsView() {
{writeFileResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
writeFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="write-file-result"
>
@@ -371,13 +341,11 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{writeFileResult.success ? "Success" : "Failed"}
{writeFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{writeFileResult.success
? writeFileResult.output
: writeFileResult.error}
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
</pre>
</div>
)}
@@ -391,9 +359,7 @@ export function AgentToolsView() {
<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>
<CardDescription>Agent requests to execute a terminal command</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -429,10 +395,10 @@ export function AgentToolsView() {
{terminalResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
terminalResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="terminal-result"
>
@@ -443,15 +409,13 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{terminalResult.success ? "Success" : "Failed"}
{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}
{'\n'}
{terminalResult.success ? terminalResult.output : terminalResult.error}
</pre>
</div>
)}
@@ -463,15 +427,12 @@ export function AgentToolsView() {
<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>
<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.
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>

View File

@@ -1,14 +1,12 @@
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 { 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,
@@ -18,37 +16,52 @@ import {
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";
FileText,
} 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, TextFileAttachment } from '@/store/app-store';
import {
fileToBase64,
generateImageId,
generateFileId,
validateImageFile,
validateTextFile,
isTextFile,
isImageFile,
fileToText,
getTextFileMimeType,
formatFileSize,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
} 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";
} 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 { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState("");
const [input, setInput] = useState('');
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
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");
const [selectedModel, setSelectedModel] = useState<AgentModel>('sonnet');
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
@@ -72,7 +85,7 @@ export function AgentView() {
clearHistory,
error: agentError,
} = useElectronAgent({
sessionId: currentSessionId || "",
sessionId: currentSessionId || '',
workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => {
@@ -108,10 +121,7 @@ export function AgentView() {
const lastSessionId = getLastSelectedSession(currentProject.path);
if (lastSessionId) {
console.log(
"[AgentView] Restoring last selected session:",
lastSessionId
);
console.log('[AgentView] Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [currentProject?.path, getLastSelectedSession]);
@@ -122,17 +132,23 @@ export function AgentView() {
}, [currentProject?.path]);
const handleSend = useCallback(async () => {
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
if (
(!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) ||
isProcessing
)
return;
const messageContent = input;
const messageImages = selectedImages;
const messageTextFiles = selectedTextFiles;
setInput("");
setInput('');
setSelectedImages([]);
setSelectedTextFiles([]);
setShowImageDropZone(false);
await sendMessage(messageContent, messageImages);
}, [input, selectedImages, isProcessing, sendMessage]);
await sendMessage(messageContent, messageImages, messageTextFiles);
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
@@ -142,88 +158,99 @@ export function AgentView() {
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
// Process dropped files (images and text 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 newTextFiles: TextFileAttachment[] = [];
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;
}
// Check if it's a text file
if (isTextFile(file)) {
const validation = validateTextFile(file);
if (!validation.isValid) {
errors.push(validation.error!);
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
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
// Check if we've reached max files
if (newImages.length + selectedImages.length >= MAX_FILES) {
errors.push(`Maximum ${MAX_FILES} images allowed.`);
break;
try {
const content = await fileToText(file);
const textFileAttachment: TextFileAttachment = {
id: generateFileId(),
content,
mimeType: getTextFileMimeType(file.name),
filename: file.name,
size: file.size,
};
newTextFiles.push(textFileAttachment);
} catch {
errors.push(`${file.name}: Failed to read text file.`);
}
}
// Check if it's an image file
else if (isImageFile(file)) {
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
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.`);
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: generateImageId(),
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
} else {
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
console.warn('File upload errors:', errors);
}
if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]);
}
if (newTextFiles.length > 0) {
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
}
},
[isProcessing, selectedImages, fileToBase64]
[isProcessing, selectedImages, selectedTextFiles]
);
// Remove individual image
@@ -231,6 +258,11 @@ export function AgentView() {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
// Remove individual text file
const removeTextFile = useCallback((fileId: string) => {
setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId));
}, []);
// Drag and drop handlers for the input area
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
@@ -239,7 +271,7 @@ export function AgentView() {
if (isProcessing || !isConnected) return;
// Check if dragged items contain files
if (e.dataTransfer.types.includes("Files")) {
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
},
@@ -285,7 +317,7 @@ export function AgentView() {
if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
const dataTransfer = new DataTransfer();
@@ -309,9 +341,9 @@ export function AgentView() {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type.startsWith("image/")) {
if (file && file.type.startsWith('image/')) {
e.preventDefault(); // Prevent default paste of file path
files.push(file);
}
@@ -329,14 +361,14 @@ export function AgentView() {
);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClearChat = async () => {
if (!confirm("Are you sure you want to clear this conversation?")) return;
if (!confirm('Are you sure you want to clear this conversation?')) return;
await clearHistory();
};
@@ -347,14 +379,13 @@ export function AgentView() {
const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <=
threshold;
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
setIsUserAtBottom(isAtBottom);
}, []);
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const container = messagesContainerRef.current;
if (!container) return;
@@ -375,7 +406,7 @@ export function AgentView() {
if (isUserAtBottom && messages.length > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom("smooth");
scrollToBottom('smooth');
}, 100);
}
}, [messages, isUserAtBottom, scrollToBottom]);
@@ -385,7 +416,7 @@ export function AgentView() {
if (currentSessionId && messages.length > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom("auto");
scrollToBottom('auto');
setIsUserAtBottom(true);
}, 100);
}
@@ -414,7 +445,7 @@ export function AgentView() {
quickCreateSessionRef.current();
}
},
description: "Create new session",
description: 'Create new session',
});
}
@@ -434,9 +465,7 @@ export function AgentView() {
<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>
<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>
@@ -450,8 +479,8 @@ export function AgentView() {
messages.length === 0
? [
{
id: "welcome",
role: "assistant" as const,
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(),
@@ -460,10 +489,7 @@ export function AgentView() {
: messages;
return (
<div
className="flex-1 flex overflow-hidden bg-background"
data-testid="agent-view"
>
<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">
@@ -498,12 +524,10 @@ export function AgentView() {
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">
AI Agent
</h1>
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
{currentSessionId && !isConnected && " - Connecting..."}
{currentSessionId && !isConnected && ' - Connecting...'}
</p>
</div>
</div>
@@ -521,7 +545,10 @@ export function AgentView() {
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
@@ -530,17 +557,12 @@ export function AgentView() {
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn(
"cursor-pointer",
selectedModel === model.id && "bg-accent"
)}
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>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
@@ -554,9 +576,7 @@ export function AgentView() {
</div>
)}
{agentError && (
<span className="text-xs text-destructive font-medium">
{agentError}
</span>
<span className="text-xs text-destructive font-medium">{agentError}</span>
)}
{currentSessionId && messages.length > 0 && (
<Button
@@ -583,9 +603,7 @@ export function AgentView() {
<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>
<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>
@@ -595,7 +613,7 @@ export function AgentView() {
className="gap-2"
>
<PanelLeft className="w-4 h-4" />
{showSessionManager ? "View" : "Show"} Sessions
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div>
@@ -610,20 +628,20 @@ export function AgentView() {
<div
key={message.id}
className={cn(
"flex gap-4 max-w-4xl",
message.role === "user" ? "flex-row-reverse ml-auto" : ""
'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"
'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" ? (
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
@@ -633,76 +651,67 @@ export function AgentView() {
{/* 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"
'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" ? (
{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>
<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>
{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"
'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",
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
@@ -720,20 +729,18 @@ export function AgentView() {
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "0ms" }}
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "150ms" }}
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "300ms" }}
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-sm text-muted-foreground">
Thinking...
</span>
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
@@ -755,16 +762,19 @@ export function AgentView() {
/>
)}
{/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{selectedImages.length > 0 && !showImageDropZone && (
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{(selectedImages.length > 0 || selectedTextFiles.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
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
</p>
<button
onClick={() => setSelectedImages([])}
onClick={() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
>
@@ -772,6 +782,7 @@ export function AgentView() {
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
<div
key={image.id}
@@ -808,6 +819,35 @@ export function AgentView() {
)}
</div>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
<div
key={file.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"
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
</p>
<p className="text-[10px] text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeTextFile(file.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>
)}
@@ -815,8 +855,8 @@ export function AgentView() {
{/* 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"
'flex gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -827,9 +867,7 @@ export function AgentView() {
<Input
ref={inputRef}
placeholder={
isDragOver
? "Drop your images here..."
: "Describe what you want to build..."
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -838,16 +876,17 @@ export function AgentView() {
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"
'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 || selectedTextFiles.length > 0) &&
'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{selectedImages.length > 0 && !isDragOver && (
{(selectedImages.length > 0 || selectedTextFiles.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" : ""}
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
</div>
)}
{isDragOver && (
@@ -858,19 +897,19 @@ export function AgentView() {
)}
</div>
{/* Image Attachment Button */}
{/* File 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"
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30 text-primary'
)}
title="Attach images"
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
@@ -879,7 +918,9 @@ export function AgentView() {
<Button
onClick={handleSend}
disabled={
(!input.trim() && selectedImages.length === 0) ||
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
isProcessing ||
!isConnected
}
@@ -892,11 +933,9 @@ export function AgentView() {
{/* 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
Press{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send
</p>
</div>
)}
@@ -904,12 +943,3 @@ export function AgentView() {
</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];
}

View File

@@ -1,20 +1,8 @@
import { useCallback, useState } from "react";
import {
useAppStore,
FileTreeNode,
ProjectAnalysis,
Feature,
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useCallback, useState } from 'react';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Folder,
FolderOpen,
@@ -30,29 +18,29 @@ import {
CheckCircle,
AlertCircle,
ListChecks,
} from "lucide-react";
import { cn } from "@/lib/utils";
} from 'lucide-react';
import { cn } from '@/lib/utils';
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
".cache",
"coverage",
"__pycache__",
".pytest_cache",
".venv",
"venv",
".env",
'node_modules',
'.git',
'.next',
'dist',
'build',
'.DS_Store',
'*.log',
'.cache',
'coverage',
'__pycache__',
'.pytest_cache',
'.venv',
'venv',
'.env',
];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
if (pattern.startsWith('*')) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
@@ -60,8 +48,8 @@ const shouldIgnore = (name: string) => {
};
const getExtension = (filename: string): string => {
const parts = filename.split(".");
return parts.length > 1 ? parts.pop() || "" : "";
const parts = filename.split('.');
return parts.length > 1 ? parts.pop() || '' : '';
};
export function AnalysisView() {
@@ -74,9 +62,7 @@ export function AnalysisView() {
clearAnalysis,
} = useAppStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [isGeneratingSpec, setIsGeneratingSpec] = useState(false);
const [specGenerated, setSpecGenerated] = useState(false);
const [specError, setSpecError] = useState<string | null>(null);
@@ -123,7 +109,7 @@ export function AnalysisView() {
return nodes;
} catch (error) {
console.error("Failed to scan directory:", path, error);
console.error('Failed to scan directory:', path, error);
return [];
}
},
@@ -148,7 +134,7 @@ export function AnalysisView() {
if (item.extension) {
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
} else {
byExt["(no extension)"] = (byExt["(no extension)"] || 0) + 1;
byExt['(no extension)'] = (byExt['(no extension)'] || 0) + 1;
}
}
}
@@ -179,17 +165,11 @@ export function AnalysisView() {
setProjectAnalysis(analysis);
} catch (error) {
console.error("Analysis failed:", error);
console.error('Analysis failed:', error);
} finally {
setIsAnalyzing(false);
}
}, [
currentProject,
setIsAnalyzing,
clearAnalysis,
scanDirectory,
setProjectAnalysis,
]);
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
// Generate app_spec.txt from analysis
const generateSpec = useCallback(async () => {
@@ -204,7 +184,7 @@ export function AnalysisView() {
// Read key files to understand the project better
const fileContents: Record<string, string> = {};
const keyFiles = ["package.json", "README.md", "tsconfig.json"];
const keyFiles = ['package.json', 'README.md', 'tsconfig.json'];
// Collect file paths from analysis
const collectFilePaths = (
@@ -217,15 +197,13 @@ export function AnalysisView() {
if (!node.isDirectory) {
paths.push(node.path);
} else if (node.children && currentDepth < maxDepth) {
paths.push(
...collectFilePaths(node.children, maxDepth, currentDepth + 1)
);
paths.push(...collectFilePaths(node.children, maxDepth, currentDepth + 1));
}
}
return paths;
};
const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
collectFilePaths(projectAnalysis.fileTree);
// Try to read key configuration files
for (const keyFile of keyFiles) {
@@ -245,40 +223,34 @@ export function AnalysisView() {
const extensions = projectAnalysis.filesByExtension;
// Check package.json for dependencies
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"])
stack.push("React");
if (pkg.dependencies?.next) stack.push("Next.js");
if (pkg.dependencies?.vue) stack.push("Vue");
if (pkg.dependencies?.angular) stack.push("Angular");
if (pkg.dependencies?.express) stack.push("Express");
if (pkg.dependencies?.electron) stack.push("Electron");
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) stack.push('React');
if (pkg.dependencies?.next) stack.push('Next.js');
if (pkg.dependencies?.vue) stack.push('Vue');
if (pkg.dependencies?.angular) stack.push('Angular');
if (pkg.dependencies?.express) stack.push('Express');
if (pkg.dependencies?.electron) stack.push('Electron');
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript)
stack.push("TypeScript");
if (
pkg.devDependencies?.tailwindcss ||
pkg.dependencies?.tailwindcss
)
stack.push("Tailwind CSS");
stack.push('TypeScript');
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss)
stack.push('Tailwind CSS');
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
stack.push("Playwright");
if (pkg.devDependencies?.jest || pkg.dependencies?.jest)
stack.push("Jest");
stack.push('Playwright');
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) stack.push('Jest');
} catch {
// Ignore JSON parse errors
}
}
// Detect by file extensions
if (extensions["ts"] || extensions["tsx"]) stack.push("TypeScript");
if (extensions["py"]) stack.push("Python");
if (extensions["go"]) stack.push("Go");
if (extensions["rs"]) stack.push("Rust");
if (extensions["java"]) stack.push("Java");
if (extensions["css"] || extensions["scss"] || extensions["sass"])
stack.push("CSS/SCSS");
if (extensions['ts'] || extensions['tsx']) stack.push('TypeScript');
if (extensions['py']) stack.push('Python');
if (extensions['go']) stack.push('Go');
if (extensions['rs']) stack.push('Rust');
if (extensions['java']) stack.push('Java');
if (extensions['css'] || extensions['scss'] || extensions['sass']) stack.push('CSS/SCSS');
// Remove duplicates
return [...new Set(stack)];
@@ -286,9 +258,9 @@ export function AnalysisView() {
// Get project name from package.json or folder name
const getProjectName = () => {
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.name) return pkg.name;
} catch {
// Ignore JSON parse errors
@@ -300,30 +272,30 @@ export function AnalysisView() {
// Get project description from package.json or README
const getProjectDescription = () => {
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.description) return pkg.description;
} catch {
// Ignore JSON parse errors
}
}
if (fileContents["README.md"]) {
if (fileContents['README.md']) {
// Extract first paragraph from README
const lines = fileContents["README.md"].split("\n");
const lines = fileContents['README.md'].split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (
trimmed &&
!trimmed.startsWith("#") &&
!trimmed.startsWith("!") &&
!trimmed.startsWith('#') &&
!trimmed.startsWith('!') &&
trimmed.length > 20
) {
return trimmed.substring(0, 200);
}
}
}
return "A software project";
return 'A software project';
};
// Group files by directory for structure analysis
@@ -336,7 +308,7 @@ export function AnalysisView() {
for (const dir of topLevelDirs) {
structure.push(` <directory name="${dir}" />`);
}
return structure.join("\n");
return structure.join('\n');
};
const projectName = getProjectName();
@@ -356,20 +328,15 @@ export function AnalysisView() {
<languages>
${Object.entries(projectAnalysis.filesByExtension)
.filter(([ext]: [string, number]) =>
["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
ext
)
['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext)
)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 5)
.map(
([ext, count]: [string, number]) =>
` <language ext=".${ext}" count="${count}" />`
)
.join("\n")}
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
.join('\n')}
</languages>
<frameworks>
${techStack.map((tech) => ` <framework>${tech}</framework>`).join("\n")}
${techStack.map((tech) => ` <framework>${tech}</framework>`).join('\n')}
</frameworks>
</technology_stack>
@@ -387,11 +354,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
.slice(0, 10)
.map(
([ext, count]: [string, number]) =>
` <extension type="${
ext.startsWith("(") ? ext : "." + ext
}" count="${count}" />`
` <extension type="${ext.startsWith('(') ? ext : '.' + ext}" count="${count}" />`
)
.join("\n")}
.join('\n')}
</file_breakdown>
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
@@ -405,13 +370,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (writeResult.success) {
setSpecGenerated(true);
} else {
setSpecError(writeResult.error || "Failed to write spec file");
setSpecError(writeResult.error || 'Failed to write spec file');
}
} catch (error) {
console.error("Failed to generate spec:", error);
setSpecError(
error instanceof Error ? error.message : "Failed to generate spec"
);
console.error('Failed to generate spec:', error);
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
} finally {
setIsGeneratingSpec(false);
}
@@ -430,7 +393,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Read key files to understand the project
const fileContents: Record<string, string> = {};
const keyFiles = ["package.json", "README.md"];
const keyFiles = ['package.json', 'README.md'];
// Try to read key configuration files
for (const keyFile of keyFiles) {
@@ -481,21 +444,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for test directories and files
const hasTests =
topLevelDirs.includes("tests") ||
topLevelDirs.includes("test") ||
topLevelDirs.includes("__tests__") ||
allFilePaths.some(
(p) => p.includes(".spec.") || p.includes(".test.")
);
topLevelDirs.includes('tests') ||
topLevelDirs.includes('test') ||
topLevelDirs.includes('__tests__') ||
allFilePaths.some((p) => p.includes('.spec.') || p.includes('.test.'));
if (hasTests) {
detectedFeatures.push({
category: "Testing",
description: "Automated test suite",
category: 'Testing',
description: 'Automated test suite',
steps: [
"Step 1: Tests directory exists",
"Step 2: Test files are present",
"Step 3: Run test suite",
'Step 1: Tests directory exists',
'Step 2: Test files are present',
'Step 3: Run test suite',
],
passes: true,
});
@@ -503,50 +464,50 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for components directory (UI components)
const hasComponents =
topLevelDirs.includes("components") ||
allFilePaths.some((p) => p.toLowerCase().includes("/components/"));
topLevelDirs.includes('components') ||
allFilePaths.some((p) => p.toLowerCase().includes('/components/'));
if (hasComponents) {
detectedFeatures.push({
category: "UI/Design",
description: "Component-based UI architecture",
category: 'UI/Design',
description: 'Component-based UI architecture',
steps: [
"Step 1: Components directory exists",
"Step 2: UI components are defined",
"Step 3: Components are reusable",
'Step 1: Components directory exists',
'Step 2: UI components are defined',
'Step 3: Components are reusable',
],
passes: true,
});
}
// Check for src directory (organized source code)
if (topLevelDirs.includes("src")) {
if (topLevelDirs.includes('src')) {
detectedFeatures.push({
category: "Project Structure",
description: "Organized source code structure",
category: 'Project Structure',
description: 'Organized source code structure',
steps: [
"Step 1: Source directory exists",
"Step 2: Code is properly organized",
"Step 3: Follows best practices",
'Step 1: Source directory exists',
'Step 2: Code is properly organized',
'Step 3: Follows best practices',
],
passes: true,
});
}
// Check package.json for dependencies and detect features
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
// React/Next.js app detection
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"]) {
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) {
detectedFeatures.push({
category: "Frontend",
description: "React-based user interface",
category: 'Frontend',
description: 'React-based user interface',
steps: [
"Step 1: React is installed",
"Step 2: Components render correctly",
"Step 3: State management works",
'Step 1: React is installed',
'Step 2: Components render correctly',
'Step 3: State management works',
],
passes: true,
});
@@ -554,12 +515,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (pkg.dependencies?.next) {
detectedFeatures.push({
category: "Framework",
description: "Next.js framework integration",
category: 'Framework',
description: 'Next.js framework integration',
steps: [
"Step 1: Next.js is configured",
"Step 2: Pages/routes are defined",
"Step 3: Server-side rendering works",
'Step 1: Next.js is configured',
'Step 2: Pages/routes are defined',
'Step 3: Server-side rendering works',
],
passes: true,
});
@@ -569,33 +530,30 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (
pkg.devDependencies?.typescript ||
pkg.dependencies?.typescript ||
extensions["ts"] ||
extensions["tsx"]
extensions['ts'] ||
extensions['tsx']
) {
detectedFeatures.push({
category: "Developer Experience",
description: "TypeScript type safety",
category: 'Developer Experience',
description: 'TypeScript type safety',
steps: [
"Step 1: TypeScript is configured",
"Step 2: Type definitions exist",
"Step 3: Code compiles without errors",
'Step 1: TypeScript is configured',
'Step 2: Type definitions exist',
'Step 3: Code compiles without errors',
],
passes: true,
});
}
// Tailwind CSS
if (
pkg.devDependencies?.tailwindcss ||
pkg.dependencies?.tailwindcss
) {
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss) {
detectedFeatures.push({
category: "UI/Design",
description: "Tailwind CSS styling",
category: 'UI/Design',
description: 'Tailwind CSS styling',
steps: [
"Step 1: Tailwind is configured",
"Step 2: Styles are applied",
"Step 3: Responsive design works",
'Step 1: Tailwind is configured',
'Step 2: Styles are applied',
'Step 3: Responsive design works',
],
passes: true,
});
@@ -604,12 +562,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
// ESLint/Prettier (code quality)
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
detectedFeatures.push({
category: "Developer Experience",
description: "Code quality tools",
category: 'Developer Experience',
description: 'Code quality tools',
steps: [
"Step 1: Linter is configured",
"Step 2: Code passes lint checks",
"Step 3: Formatting is consistent",
'Step 1: Linter is configured',
'Step 2: Code passes lint checks',
'Step 3: Formatting is consistent',
],
passes: true,
});
@@ -618,29 +576,26 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Electron (desktop app)
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
detectedFeatures.push({
category: "Platform",
description: "Electron desktop application",
category: 'Platform',
description: 'Electron desktop application',
steps: [
"Step 1: Electron is configured",
"Step 2: Main process runs",
"Step 3: Renderer process loads",
'Step 1: Electron is configured',
'Step 2: Main process runs',
'Step 3: Renderer process loads',
],
passes: true,
});
}
// Playwright testing
if (
pkg.devDependencies?.playwright ||
pkg.devDependencies?.["@playwright/test"]
) {
if (pkg.devDependencies?.playwright || pkg.devDependencies?.['@playwright/test']) {
detectedFeatures.push({
category: "Testing",
description: "Playwright end-to-end testing",
category: 'Testing',
description: 'Playwright end-to-end testing',
steps: [
"Step 1: Playwright is configured",
"Step 2: E2E tests are defined",
"Step 3: Tests pass successfully",
'Step 1: Playwright is configured',
'Step 2: E2E tests are defined',
'Step 3: Tests pass successfully',
],
passes: true,
});
@@ -651,17 +606,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
// Check for documentation
if (
topLevelFiles.includes("readme.md") ||
topLevelDirs.includes("docs")
) {
if (topLevelFiles.includes('readme.md') || topLevelDirs.includes('docs')) {
detectedFeatures.push({
category: "Documentation",
description: "Project documentation",
category: 'Documentation',
description: 'Project documentation',
steps: [
"Step 1: README exists",
"Step 2: Documentation is comprehensive",
"Step 3: Setup instructions are clear",
'Step 1: README exists',
'Step 2: Documentation is comprehensive',
'Step 3: Setup instructions are clear',
],
passes: true,
});
@@ -669,18 +621,18 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for CI/CD configuration
const hasCICD =
topLevelDirs.includes(".github") ||
topLevelFiles.includes(".gitlab-ci.yml") ||
topLevelFiles.includes(".travis.yml");
topLevelDirs.includes('.github') ||
topLevelFiles.includes('.gitlab-ci.yml') ||
topLevelFiles.includes('.travis.yml');
if (hasCICD) {
detectedFeatures.push({
category: "DevOps",
description: "CI/CD pipeline configuration",
category: 'DevOps',
description: 'CI/CD pipeline configuration',
steps: [
"Step 1: CI config exists",
"Step 2: Pipeline runs on push",
"Step 3: Automated checks pass",
'Step 1: CI config exists',
'Step 2: Pipeline runs on push',
'Step 3: Automated checks pass',
],
passes: true,
});
@@ -688,20 +640,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for API routes (Next.js API or Express)
const hasAPIRoutes = allFilePaths.some(
(p) =>
p.includes("/api/") ||
p.includes("/routes/") ||
p.includes("/endpoints/")
(p) => p.includes('/api/') || p.includes('/routes/') || p.includes('/endpoints/')
);
if (hasAPIRoutes) {
detectedFeatures.push({
category: "Backend",
description: "API endpoints",
category: 'Backend',
description: 'API endpoints',
steps: [
"Step 1: API routes are defined",
"Step 2: Endpoints respond correctly",
"Step 3: Error handling is implemented",
'Step 1: API routes are defined',
'Step 2: Endpoints respond correctly',
'Step 3: Error handling is implemented',
],
passes: true,
});
@@ -710,37 +659,34 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for state management
const hasStateManagement = allFilePaths.some(
(p) =>
p.includes("/store/") ||
p.includes("/stores/") ||
p.includes("/redux/") ||
p.includes("/context/")
p.includes('/store/') ||
p.includes('/stores/') ||
p.includes('/redux/') ||
p.includes('/context/')
);
if (hasStateManagement) {
detectedFeatures.push({
category: "Architecture",
description: "State management system",
category: 'Architecture',
description: 'State management system',
steps: [
"Step 1: Store is configured",
"Step 2: State updates correctly",
"Step 3: Components access state",
'Step 1: Store is configured',
'Step 2: State updates correctly',
'Step 3: Components access state',
],
passes: true,
});
}
// Check for configuration files
if (
topLevelFiles.includes("tsconfig.json") ||
topLevelFiles.includes("package.json")
) {
if (topLevelFiles.includes('tsconfig.json') || topLevelFiles.includes('package.json')) {
detectedFeatures.push({
category: "Configuration",
description: "Project configuration files",
category: 'Configuration',
description: 'Project configuration files',
steps: [
"Step 1: Config files exist",
"Step 2: Configuration is valid",
"Step 3: Build process works",
'Step 1: Config files exist',
'Step 2: Configuration is valid',
'Step 3: Build process works',
],
passes: true,
});
@@ -752,12 +698,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
// If no features were detected, add a default feature
if (detectedFeatures.length === 0) {
detectedFeatures.push({
category: "Core",
description: "Basic project structure",
category: 'Core',
description: 'Basic project structure',
steps: [
"Step 1: Project directory exists",
"Step 2: Files are present",
"Step 3: Project can be loaded",
'Step 1: Project directory exists',
'Step 2: Files are present',
'Step 3: Project can be loaded',
],
passes: true,
});
@@ -765,7 +711,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Create each feature using the features API
if (!api.features) {
throw new Error("Features API not available");
throw new Error('Features API not available');
}
for (const detectedFeature of detectedFeatures) {
@@ -774,17 +720,15 @@ ${Object.entries(projectAnalysis.filesByExtension)
category: detectedFeature.category,
description: detectedFeature.description,
steps: detectedFeature.steps,
status: "backlog",
status: 'backlog',
});
}
setFeatureListGenerated(true);
} catch (error) {
console.error("Failed to generate feature list:", error);
console.error('Failed to generate feature list:', error);
setFeatureListError(
error instanceof Error
? error.message
: "Failed to generate feature list"
error instanceof Error ? error.message : 'Failed to generate feature list'
);
} finally {
setIsGeneratingFeatureList(false);
@@ -810,7 +754,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
<div key={node.path} data-testid={`analysis-node-${node.name}`}>
<div
className={cn(
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm"
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm'
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
@@ -840,17 +784,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
)}
<span className="truncate">{node.name}</span>
{node.extension && (
<span className="text-xs text-muted-foreground ml-auto">
.{node.extension}
</span>
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span>
)}
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child: FileTreeNode) =>
renderNode(child, depth + 1)
)}
</div>
<div>{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}</div>
)}
</div>
);
@@ -868,26 +806,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="analysis-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="analysis-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">
<Search className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Project Analysis</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
</p>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
</div>
</div>
<Button
onClick={runAnalysis}
disabled={isAnalyzing}
data-testid="analyze-project-button"
>
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
{isAnalyzing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -909,13 +838,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Click &quot;Analyze Project&quot; to scan your codebase and get
insights about its structure.
Click &quot;Analyze Project&quot; to scan your codebase and get insights about its
structure.
</p>
<Button
onClick={runAnalysis}
data-testid="analyze-project-button-empty"
>
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
<Search className="w-4 h-4 mr-2" />
Start Analysis
</Button>
@@ -936,27 +862,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
Statistics
</CardTitle>
<CardDescription>
Analyzed{" "}
{new Date(projectAnalysis.analyzedAt).toLocaleString()}
Analyzed {new Date(projectAnalysis.analyzedAt).toLocaleString()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Total Files
</span>
<span className="text-sm text-muted-foreground">Total Files</span>
<span className="font-medium" data-testid="total-files">
{projectAnalysis.totalFiles}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Total Directories
</span>
<span
className="font-medium"
data-testid="total-directories"
>
<span className="text-sm text-muted-foreground">Total Directories</span>
<span className="font-medium" data-testid="total-directories">
{projectAnalysis.totalDirectories}
</span>
</div>
@@ -973,15 +891,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<CardContent>
<div className="space-y-2">
{Object.entries(projectAnalysis.filesByExtension)
.sort(
(a: [string, number], b: [string, number]) =>
b[1] - a[1]
)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 15)
.map(([ext, count]: [string, number]) => (
<div key={ext} className="flex justify-between text-sm">
<span className="text-muted-foreground font-mono">
{ext.startsWith("(") ? ext : `.${ext}`}
{ext.startsWith('(') ? ext : `.${ext}`}
</span>
<span>{count}</span>
</div>
@@ -997,14 +912,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<FileText className="w-4 h-4" />
Generate Specification
</CardTitle>
<CardDescription>
Create app_spec.txt from analysis
</CardDescription>
<CardDescription>Create app_spec.txt from analysis</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Generate a project specification file based on the analyzed
codebase structure and detected technologies.
Generate a project specification file based on the analyzed codebase structure
and detected technologies.
</p>
<Button
onClick={generateSpec}
@@ -1052,15 +965,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<ListChecks className="w-4 h-4" />
Generate Feature List
</CardTitle>
<CardDescription>
Create features from analysis
</CardDescription>
<CardDescription>Create features from analysis</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Automatically detect and generate a feature list based on
the analyzed codebase structure, dependencies, and project
configuration.
Automatically detect and generate a feature list based on the analyzed codebase
structure, dependencies, and project configuration.
</p>
<Button
onClick={generateFeatureList}
@@ -1110,18 +1020,13 @@ ${Object.entries(projectAnalysis.filesByExtension)
File Tree
</CardTitle>
<CardDescription>
{projectAnalysis.totalFiles} files in{" "}
{projectAnalysis.totalDirectories} directories
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{' '}
directories
</CardDescription>
</CardHeader>
<CardContent
className="p-0 overflow-y-auto h-full"
data-testid="analysis-file-tree"
>
<CardContent className="p-0 overflow-y-auto h-full" data-testid="analysis-file-tree">
<div className="p-2">
{projectAnalysis.fileTree.map((node: FileTreeNode) =>
renderNode(node)
)}
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
</div>
</CardContent>
</Card>

View File

@@ -1,26 +1,26 @@
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import {
PointerSensor,
useSensor,
useSensors,
rectIntersection,
pointerWithin,
} from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { pathsEqual } from "@/lib/utils";
import { getBlockingDependencies } from "@automaker/dependency-resolver";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useWindowState } from "@/hooks/use-window-state";
} from '@dnd-kit/core';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { pathsEqual } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { RefreshCw } from 'lucide-react';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from "./board-view/board-header";
import { BoardSearchBar } from "./board-view/board-search-bar";
import { BoardControls } from "./board-view/board-controls";
import { KanbanBoard } from "./board-view/kanban-board";
import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import {
AddFeatureDialog,
AgentOutputModal,
@@ -31,15 +31,15 @@ import {
FeatureSuggestionsDialog,
FollowUpDialog,
PlanApprovalDialog,
} from "./board-view/dialogs";
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
import { WorktreePanel } from "./board-view/worktree-panel";
import type { PRInfo, WorktreeInfo } from "./board-view/worktree-panel/types";
import { COLUMNS } from "./board-view/constants";
} from './board-view/dialogs';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
import { COLUMNS } from './board-view/constants';
import {
useBoardFeatures,
useBoardDragDrop,
@@ -51,12 +51,10 @@ import {
useBoardPersistence,
useFollowUpState,
useSuggestionsState,
} from "./board-view/hooks";
} from './board-view/hooks';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<
ReturnType<typeof useAppStore.getState>["getWorktrees"]
> = [];
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
/** Delay before starting a newly created feature to allow state to settle */
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
@@ -98,26 +96,18 @@ export function BoardView() {
const [isMounted, setIsMounted] = useState(false);
const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
new Set()
);
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(new Set());
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] = useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] = useState(false);
const [showCompletedModal, setShowCompletedModal] = useState(false);
const [deleteCompletedFeature, setDeleteCompletedFeature] =
useState<Feature | null>(null);
const [deleteCompletedFeature, setDeleteCompletedFeature] = useState<Feature | null>(null);
// State for viewing plan in read-only mode
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
useState(false);
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
@@ -158,7 +148,7 @@ export function BoardView() {
closeSuggestionsDialog,
} = useSuggestionsState();
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
@@ -175,14 +165,11 @@ export function BoardView() {
return false;
}
const result = await api.autoMode.contextExists(
currentProject.path,
featureId
);
const result = await api.autoMode.contextExists(currentProject.path, featureId);
return result.success && result.exists === true;
} catch (error) {
console.error("[Board] Error checking context:", error);
console.error('[Board] Error checking context:', error);
return false;
}
},
@@ -228,9 +215,7 @@ export function BoardView() {
// Get unique categories from existing features AND persisted categories for autocomplete suggestions
const categorySuggestions = useMemo(() => {
const featureCategories = hookFeatures
.map((f) => f.category)
.filter(Boolean);
const featureCategories = hookFeatures.map((f) => f.category).filter(Boolean);
// Merge feature categories with persisted categories
const allCategories = [...featureCategories, ...persistedCategories];
return [...new Set(allCategories)].sort();
@@ -264,7 +249,7 @@ export function BoardView() {
setBranchSuggestions(localBranches);
}
} catch (error) {
console.error("[BoardView] Error fetching branches:", error);
console.error('[BoardView] Error fetching branches:', error);
setBranchSuggestions([]);
}
};
@@ -276,8 +261,8 @@ export function BoardView() {
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== "completed") {
const branch = feature.branchName ?? "main";
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
@@ -304,8 +289,9 @@ export function BoardView() {
}, []);
// Use persistence hook
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({
currentProject,
});
// Memoize the removed worktrees handler to prevent infinite loops
const handleRemovedWorktrees = useCallback(
@@ -332,15 +318,13 @@ export function BoardView() {
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === "in_progress";
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path)
: null;
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
@@ -359,9 +343,7 @@ export function BoardView() {
return worktrees.find((w) => w.isMain);
} else {
// Specific worktree selected - find it by path
return worktrees.find(
(w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
);
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
}
}, [worktrees, currentWorktreePath]);
@@ -371,7 +353,7 @@ export function BoardView() {
// Get the branch for the currently selected worktree (for defaulting new features)
// Use the branch from selectedWorktree, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Extract all action handlers into a hook
const {
@@ -422,9 +404,7 @@ export function BoardView() {
if (!currentProject) return;
// Check if worktree already exists in the store (by branch name)
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find(
(w) => w.branch === newWorktree.branch
);
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
@@ -435,17 +415,10 @@ export function BoardView() {
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
},
currentWorktreeBranch,
});
@@ -460,17 +433,17 @@ export function BoardView() {
// Create the feature
const featureData = {
category: "PR Review",
category: 'PR Review',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus" as const,
thinkingLevel: "none" as const,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
priority: 1, // High priority for PR feedback
planningMode: "skip" as const,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
@@ -483,7 +456,7 @@ export function BoardView() {
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === "backlog" &&
f.status === 'backlog' &&
f.description.includes(`PR #${prNumber}`)
);
@@ -502,17 +475,17 @@ export function BoardView() {
// Create the feature
const featureData = {
category: "Maintenance",
category: 'Maintenance',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus" as const,
thinkingLevel: "none" as const,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
priority: 1, // High priority for conflict resolution
planningMode: "skip" as const,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
@@ -524,8 +497,8 @@ export function BoardView() {
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === "backlog" &&
f.description.includes("Pull latest from origin/main")
f.status === 'backlog' &&
f.description.includes('Pull latest from origin/main')
);
if (newFeature) {
@@ -573,22 +546,21 @@ export function BoardView() {
if (!currentProject) return;
// Only process events for the current project
const eventProjectPath =
"projectPath" in event ? event.projectPath : undefined;
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
if (eventProjectPath && eventProjectPath !== currentProject.path) {
return;
}
switch (event.type) {
case "auto_mode_feature_start":
case 'auto_mode_feature_start':
// Feature is now confirmed running - remove from pending
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
case "auto_mode_feature_complete":
case "auto_mode_error":
case 'auto_mode_feature_complete':
case 'auto_mode_error':
// Feature completed or errored - remove from pending if still there
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
@@ -629,8 +601,7 @@ export function BoardView() {
// Count currently running tasks + pending features
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning =
runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
// No available slots, skip check
@@ -643,7 +614,7 @@ export function BoardView() {
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeatures = currentFeatures.filter((f) => {
if (f.status !== "backlog") return false;
if (f.status !== 'backlog') return false;
const featureBranch = f.branchName;
@@ -700,9 +671,8 @@ export function BoardView() {
// If feature has no branchName and primary worktree is selected, assign primary branch
if (currentWorktreePath === null && !feature.branchName) {
const primaryBranch =
(currentProject.path
? getPrimaryWorktreeBranch(currentProject.path)
: null) || "main";
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
'main';
await persistFeatureUpdate(feature.id, {
branchName: primaryBranch,
});
@@ -728,7 +698,7 @@ export function BoardView() {
// Check immediately, then every 3 seconds
checkAndStartFeatures();
const interval = setInterval(checkAndStartFeatures, 1000);
const interval = setInterval(checkAndStartFeatures, 3000);
return () => {
// Mark as inactive to prevent any pending async operations from continuing
@@ -788,9 +758,7 @@ export function BoardView() {
// Find feature for pending plan approval
const pendingApprovalFeature = useMemo(() => {
if (!pendingPlanApproval) return null;
return (
hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null
);
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null;
}, [pendingPlanApproval, hookFeatures]);
// Handle plan approval
@@ -803,7 +771,7 @@ export function BoardView() {
try {
const api = getElectronAPI();
if (!api?.autoMode?.approvePlan) {
throw new Error("Plan approval API not available");
throw new Error('Plan approval API not available');
}
const result = await api.autoMode.approvePlan(
@@ -819,7 +787,7 @@ export function BoardView() {
const currentFeature = hookFeatures.find((f) => f.id === featureId);
updateFeature(featureId, {
planSpec: {
status: "approved",
status: 'approved',
content: editedPlan || pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1,
approvedAt: new Date().toISOString(),
@@ -829,10 +797,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error("[Board] Failed to approve plan:", result.error);
console.error('[Board] Failed to approve plan:', result.error);
}
} catch (error) {
console.error("[Board] Error approving plan:", error);
console.error('[Board] Error approving plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -858,7 +826,7 @@ export function BoardView() {
try {
const api = getElectronAPI();
if (!api?.autoMode?.approvePlan) {
throw new Error("Plan approval API not available");
throw new Error('Plan approval API not available');
}
const result = await api.autoMode.approvePlan(
@@ -874,9 +842,9 @@ export function BoardView() {
// Get current feature to preserve version
const currentFeature = hookFeatures.find((f) => f.id === featureId);
updateFeature(featureId, {
status: "backlog",
status: 'backlog',
planSpec: {
status: "rejected",
status: 'rejected',
content: pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1,
reviewedByUser: true,
@@ -885,10 +853,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error("[Board] Failed to reject plan:", result.error);
console.error('[Board] Failed to reject plan:', result.error);
}
} catch (error) {
console.error("[Board] Error rejecting plan:", error);
console.error('[Board] Error rejecting plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -911,8 +879,8 @@ export function BoardView() {
// Determine the planning mode for approval (skip should never have a plan requiring approval)
const mode = feature.planningMode;
const approvalMode: "lite" | "spec" | "full" =
mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec";
const approvalMode: 'lite' | 'spec' | 'full' =
mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec';
// Re-open the approval dialog with the feature's plan data
setPendingPlanApproval({
@@ -927,10 +895,7 @@ export function BoardView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="board-view-no-project"
>
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
@@ -938,10 +903,7 @@ export function BoardView() {
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="board-view-loading"
>
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
@@ -970,7 +932,7 @@ export function BoardView() {
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: "Add new feature",
description: 'Add new feature',
}}
isMounted={isMounted}
/>
@@ -1125,8 +1087,8 @@ export function BoardView() {
<AgentOutputModal
open={showOutputModal}
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ""}
featureId={outputFeature?.id || ""}
featureDescription={outputFeature?.description || ''}
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
@@ -1135,7 +1097,7 @@ export function BoardView() {
<ArchiveAllVerifiedDialog
open={showArchiveAllVerifiedDialog}
onOpenChange={setShowArchiveAllVerifiedDialog}
verifiedCount={getColumnFeatures("verified").length}
verifiedCount={getColumnFeatures('verified').length}
onConfirm={async () => {
await handleArchiveAllVerified();
setShowArchiveAllVerifiedDialog(false);
@@ -1177,7 +1139,7 @@ export function BoardView() {
}
}}
feature={pendingApprovalFeature}
planContent={pendingPlanApproval?.planContent || ""}
planContent={pendingPlanApproval?.planContent || ''}
onApprove={handlePlanApprove}
onReject={handlePlanReject}
isLoading={isPlanApprovalLoading}
@@ -1212,17 +1174,10 @@ export function BoardView() {
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
// Now set the current worktree with both path and branch
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
// Trigger refresh to get full worktree details (hasChanges, etc.)
setWorktreeRefreshKey((k) => k + 1);
@@ -1237,9 +1192,7 @@ export function BoardView() {
worktree={selectedWorktreeForAction}
affectedFeatureCount={
selectedWorktreeForAction
? hookFeatures.filter(
(f) => f.branchName === selectedWorktreeForAction.branch
).length
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
onDeleted={(deletedWorktree, _deletedBranch) => {
@@ -1291,9 +1244,7 @@ export function BoardView() {
// Persist changes asynchronously and in parallel
Promise.all(
featuresToUpdate.map((feature) =>
persistFeatureUpdate(feature.id, { prUrl })
)
featuresToUpdate.map((feature) => persistFeatureUpdate(feature.id, { prUrl }))
).catch(console.error);
}
setWorktreeRefreshKey((k) => k + 1);

View File

@@ -1,16 +1,15 @@
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";
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;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
}
export function BoardControls({
@@ -57,7 +56,7 @@ export function BoardControls({
<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}
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
@@ -75,12 +74,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("minimal")}
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"
'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"
>
@@ -94,12 +93,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("standard")}
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"
'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"
>
@@ -113,12 +112,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("detailed")}
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"
'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"
>

View File

@@ -1,13 +1,11 @@
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, Bot } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { ClaudeUsagePopover } from "@/components/claude-usage-popover";
import { useAppStore } from "@/store/app-store";
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, Bot } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
interface BoardHeaderProps {
projectName: string;
@@ -36,7 +34,8 @@ export function BoardHeader({
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
return (
@@ -78,10 +77,7 @@ export function BoardHeader({
{/* 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"
>
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>
<Switch

View File

@@ -1,7 +1,6 @@
import { useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Search, X, Loader2 } from "lucide-react";
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search, X, Loader2 } from 'lucide-react';
interface BoardSearchBarProps {
searchQuery: string;
@@ -25,7 +24,7 @@ export function BoardSearchBar({
const handleKeyDown = (e: KeyboardEvent) => {
// Only focus if not typing in an input/textarea
if (
e.key === "/" &&
e.key === '/' &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
) {
@@ -34,8 +33,8 @@ export function BoardSearchBar({
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
@@ -53,7 +52,7 @@ export function BoardSearchBar({
/>
{searchQuery ? (
<button
onClick={() => onSearchChange("")}
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"
@@ -70,19 +69,18 @@ export function BoardSearchBar({
)}
</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>
)}
{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

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

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { Feature, ThinkingLevel, useAppStore } from "@/store/app-store";
import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
import {
AgentTaskInfo,
parseAgentContext,
formatModelName,
DEFAULT_MODEL,
} from "@/lib/agent-context-parser";
import { cn } from "@/lib/utils";
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import {
Cpu,
Brain,
@@ -17,21 +17,21 @@ import {
Circle,
Loader2,
Wrench,
} from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { SummaryDialog } from "./summary-dialog";
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
/**
* Formats thinking level for compact display
*/
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
if (!level || level === "none") return "";
if (!level || level === 'none') return '';
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
none: '',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
return labels[level];
}
@@ -53,7 +53,7 @@ export function AgentInfoPanel({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const showAgentInfo = kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
useEffect(() => {
const loadContext = async () => {
@@ -63,22 +63,18 @@ export function AgentInfoPanel({
return;
}
if (feature.status === "backlog") {
if (feature.status === 'backlog') {
setAgentInfo(null);
return;
}
try {
const api = getElectronAPI();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-undef
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
feature.id
);
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
@@ -94,68 +90,61 @@ export function AgentInfoPanel({
}
}
} catch {
// eslint-disable-next-line no-undef
console.debug("[KanbanCard] No context file for feature:", feature.id);
console.debug('[KanbanCard] No context file for feature:', feature.id);
}
};
loadContext();
if (isCurrentAutoTask) {
// eslint-disable-next-line no-undef
const interval = setInterval(loadContext, 3000);
return () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === "backlog") {
if (showAgentInfo && feature.status === 'backlog') {
return (
<div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
{feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatThinkingLevel(feature.thinkingLevel)}
{formatThinkingLevel(feature.thinkingLevel as ThinkingLevel)}
</span>
</div>
)}
) : null}
</div>
</div>
);
}
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== "backlog" && agentInfo) {
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
return (
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{agentInfo.currentPhase && (
<div
className={cn(
"px-1.5 py-0.5 rounded-md text-[10px] font-medium",
agentInfo.currentPhase === "planning" &&
"bg-[var(--status-info-bg)] text-[var(--status-info)]",
agentInfo.currentPhase === "action" &&
"bg-[var(--status-warning-bg)] text-[var(--status-warning)]",
agentInfo.currentPhase === "verification" &&
"bg-[var(--status-success-bg)] text-[var(--status-success)]"
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
agentInfo.currentPhase === 'planning' &&
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
agentInfo.currentPhase === 'action' &&
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
agentInfo.currentPhase === 'verification' &&
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
)}
>
{agentInfo.currentPhase}
@@ -169,31 +158,26 @@ export function AgentInfoPanel({
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === "completed").length}
/{agentInfo.todos.length} tasks
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 text-[10px]"
>
{todo.status === "completed" ? (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === "in_progress" ? (
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-muted-foreground/60 line-through",
todo.status === "in_progress" &&
"text-[var(--status-warning)]",
todo.status === "pending" && "text-muted-foreground/80"
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
@@ -210,8 +194,7 @@ export function AgentInfoPanel({
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === "waiting_approval" ||
feature.status === "verified") && (
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
@@ -238,27 +221,20 @@ export function AgentInfoPanel({
</p>
</div>
)}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
{!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{
agentInfo.todos.filter((t) => t.status === "completed")
.length
}{" "}
tasks done
</span>
)}
</div>
)}
)}
</div>
)}
</>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
Edit,
PlayCircle,
@@ -10,7 +10,7 @@ import {
Eye,
Wand2,
Archive,
} from "lucide-react";
} from 'lucide-react';
interface CardActionsProps {
feature: Feature;
@@ -52,7 +52,7 @@ export function CardActions({
{isCurrentAutoTask && (
<>
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
@@ -109,10 +109,10 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
@@ -191,7 +191,7 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{/* Logs button */}
{onViewOutput && (
@@ -229,7 +229,7 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
<>
{/* Refine prompt button */}
{onFollowUp && (
@@ -282,7 +282,7 @@ export function CardActions({
) : null}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<Button
variant="secondary"

View File

@@ -1,19 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { Feature, useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AlertCircle, Lock, Hand, Sparkles } from "lucide-react";
import { getBlockingDependencies } from "@automaker/dependency-resolver";
import { useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
interface CardBadgeProps {
children: React.ReactNode;
className?: string;
"data-testid"?: string;
'data-testid'?: string;
title?: string;
}
@@ -21,16 +16,11 @@ interface CardBadgeProps {
* Shared badge component matching the "Just Finished" badge style
* Used for priority badges and other card badges
*/
function CardBadge({
children,
className,
"data-testid": dataTestId,
title,
}: CardBadgeProps) {
function CardBadge({ children, className, 'data-testid': dataTestId, title }: CardBadgeProps) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
className
)}
data-testid={dataTestId}
@@ -50,7 +40,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== "backlog") {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
@@ -62,7 +52,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
(blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog");
feature.status === 'backlog');
if (!showStatusBadges) {
return null;
@@ -77,8 +67,8 @@ export function CardBadges({ feature }: CardBadgesProps) {
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]"
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
@@ -96,14 +86,14 @@ export function CardBadges({ feature }: CardBadgesProps) {
{blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog" && (
feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold",
"bg-orange-500/20 border-orange-500/50 text-orange-500"
'inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold',
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
@@ -112,10 +102,8 @@ export function CardBadges({ feature }: CardBadgesProps) {
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{" "}
{blockingDependencies.length === 1
? "dependency"
: "dependencies"}
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
@@ -123,7 +111,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(", ")}
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
@@ -141,11 +129,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
const [currentTime, setCurrentTime] = useState(() => Date.now());
const isJustFinished = useMemo(() => {
if (
!feature.justFinishedAt ||
feature.status !== "waiting_approval" ||
feature.error
) {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
return false;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
@@ -154,7 +138,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval') {
return;
}
@@ -179,7 +163,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
const showPriorityBadges =
feature.priority ||
(feature.skipTests && !feature.error && feature.status === "backlog") ||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
isJustFinished;
if (!showPriorityBadges) {
@@ -195,45 +179,39 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
<TooltipTrigger asChild>
<CardBadge
className={cn(
"bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5", // badge style from example
'bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5', // badge style from example
feature.priority === 1 &&
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]",
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
"bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]",
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
feature.priority === 3 &&
"bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]"
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
)}
data-testid={`priority-badge-${feature.id}`}
>
{feature.priority === 1 ? (
<span className="font-bold text-xs flex items-center gap-0.5">
H
</span>
<span className="font-bold text-xs flex items-center gap-0.5">H</span>
) : feature.priority === 2 ? (
<span className="font-bold text-xs flex items-center gap-0.5">
M
</span>
<span className="font-bold text-xs flex items-center gap-0.5">M</span>
) : (
<span className="font-bold text-xs flex items-center gap-0.5">
L
</span>
<span className="font-bold text-xs flex items-center gap-0.5">L</span>
)}
</CardBadge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
{feature.priority === 1
? "High Priority"
? 'High Priority'
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
? 'Medium Priority'
: 'Low Priority'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{feature.skipTests && !feature.error && feature.status === "backlog" && (
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from "lucide-react";
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
interface CardContentSectionsProps {
feature: Feature;
@@ -25,10 +25,10 @@ export function CardContentSections({
)}
{/* PR URL Display */}
{typeof feature.prUrl === "string" &&
{typeof feature.prUrl === 'string' &&
/^https?:\/\//i.test(feature.prUrl) &&
(() => {
const prNumber = feature.prUrl.split("/").pop();
const prNumber = feature.prUrl.split('/').pop();
return (
<div className="mb-2">
<a
@@ -43,7 +43,7 @@ export function CardContentSections({
>
<GitPullRequest className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[150px]">
{prNumber ? `Pull Request #${prNumber}` : "Pull Request"}
{prNumber ? `Pull Request #${prNumber}` : 'Pull Request'}
</span>
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
</a>
@@ -59,14 +59,12 @@ export function CardContentSections({
key={index}
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
>
{feature.status === "verified" ? (
{feature.status === 'verified' ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
{step}
</span>
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
@@ -79,4 +77,3 @@ export function CardContentSections({
</>
);
}

View File

@@ -1,18 +1,14 @@
import { useState } from "react";
import { Feature } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
import {
GripVertical,
Edit,
@@ -23,10 +19,10 @@ import {
ChevronDown,
ChevronUp,
Cpu,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { formatModelName, DEFAULT_MODEL } from "@/lib/agent-context-parser";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
} from 'lucide-react';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
interface CardHeaderProps {
feature: Feature;
@@ -100,9 +96,7 @@ export function CardHeaderSection({
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
@@ -111,7 +105,7 @@ export function CardHeaderSection({
)}
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === "backlog" && (
{!isCurrentAutoTask && feature.status === 'backlog' && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
@@ -128,8 +122,7 @@ export function CardHeaderSection({
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
(feature.status === "waiting_approval" ||
feature.status === "verified") && (
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
@@ -142,9 +135,7 @@ export function CardHeaderSection({
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Edit"
>
@@ -161,9 +152,7 @@ export function CardHeaderSection({
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Logs"
>
@@ -177,9 +166,7 @@ export function CardHeaderSection({
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Delete"
>
@@ -190,7 +177,7 @@ export function CardHeaderSection({
)}
{/* In progress header */}
{!isCurrentAutoTask && feature.status === "in_progress" && (
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
@@ -246,9 +233,7 @@ export function CardHeaderSection({
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
@@ -271,9 +256,7 @@ export function CardHeaderSection({
{feature.titleGenerating ? (
<div className="flex items-center gap-1.5 mb-1">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground italic">
Generating title...
</span>
<span className="text-xs text-muted-foreground italic">Generating title...</span>
</div>
) : feature.title ? (
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
@@ -282,13 +265,13 @@ export function CardHeaderSection({
) : null}
<CardDescription
className={cn(
"text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground",
!isDescriptionExpanded && "line-clamp-3"
'text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground',
!isDescriptionExpanded && 'line-clamp-3'
)}
>
{feature.description || feature.summary || feature.id}
</CardDescription>
{(feature.description || feature.summary || "").length > 100 && (
{(feature.description || feature.summary || '').length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -327,4 +310,3 @@ export function CardHeaderSection({
</CardHeader>
);
}

View File

@@ -0,0 +1,7 @@
export { AgentInfoPanel } from './agent-info-panel';
export { CardActions } from './card-actions';
export { CardBadges, PriorityBadges } from './card-badges';
export { CardContentSections } from './card-content-sections';
export { CardHeaderSection } from './card-header';
export { KanbanCard } from './kanban-card';
export { SummaryDialog } from './summary-dialog';

View File

@@ -1,14 +1,14 @@
import React, { memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Feature, useAppStore } from "@/store/app-store";
import { CardBadges, PriorityBadges } from "./card-badges";
import { CardHeaderSection } from "./card-header";
import { CardContentSections } from "./card-content-sections";
import { AgentInfoPanel } from "./agent-info-panel";
import { CardActions } from "./card-actions";
import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Feature, useAppStore } from '@/store/app-store';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';
interface KanbanCardProps {
feature: Feature;
@@ -63,23 +63,14 @@ export const KanbanCard = memo(function KanbanCard({
}: KanbanCardProps) {
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
const showSteps = kanbanCardDetailLevel === 'standard' || kanbanCardDetailLevel === 'detailed';
const isDraggable =
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
feature.status === "verified" ||
(feature.status === "in_progress" && !isCurrentAutoTask);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id,
disabled: !isDraggable,
});
@@ -92,10 +83,10 @@ export const KanbanCard = memo(function KanbanCard({
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = "0px";
(borderStyle as Record<string, string>).borderColor = "transparent";
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = "1px";
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
@@ -105,28 +96,22 @@ export const KanbanCard = memo(function KanbanCard({
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
"transition-all duration-200 ease-out",
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
"shadow-sm hover:shadow-md hover:shadow-black/10",
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
"hover:-translate-y-0.5",
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity === 100 &&
"border-border/50",
!isCurrentAutoTask &&
cardBorderEnabled &&
cardBorderOpacity !== 100 &&
"border",
!isDragging && "bg-transparent",
!glassmorphism && "backdrop-blur-[0px]!",
isDragging && "scale-105 shadow-xl shadow-black/20 rotate-1",
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
"border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg",
!isDraggable && "cursor-default"
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
onDoubleClick={onEdit}
@@ -137,8 +122,8 @@ export const KanbanCard = memo(function KanbanCard({
{!isDragging && (
<div
className={cn(
"absolute inset-0 rounded-xl bg-card -z-10",
glassmorphism && "backdrop-blur-sm"
'absolute inset-0 rounded-xl bg-card -z-10',
glassmorphism && 'backdrop-blur-sm'
)}
style={{ opacity: opacity / 100 }}
/>
@@ -149,9 +134,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Category row */}
<div className="px-3 pt-4">
<span className="text-[11px] text-muted-foreground/70 font-medium">
{feature.category}
</span>
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
</div>
{/* Priority and Manual Verification badges */}
@@ -169,11 +152,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
<CardContentSections
feature={feature}
useWorktrees={useWorktrees}
showSteps={showSteps}
/>
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
{/* Agent Info Panel */}
<AgentInfoPanel

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { AgentTaskInfo } from "@/lib/agent-context-parser";
import { Feature } from '@/store/app-store';
import { AgentTaskInfo } from '@/lib/agent-context-parser';
import {
Dialog,
DialogContent,
@@ -7,10 +7,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Markdown } from "@/components/ui/markdown";
import { Sparkles } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { Sparkles } from 'lucide-react';
interface SummaryDialogProps {
feature: Feature;
@@ -40,23 +40,17 @@ export function SummaryDialog({
</DialogTitle>
<DialogDescription
className="text-sm"
title={feature.description || feature.summary || ""}
title={feature.description || feature.summary || ''}
>
{(() => {
const displayText =
feature.description || feature.summary || "No description";
return displayText.length > 100
? `${displayText.slice(0, 100)}...`
: displayText;
const displayText = feature.description || feature.summary || 'No description';
return displayText.length > 100 ? `${displayText.slice(0, 100)}...` : displayText;
})()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
<Markdown>
{feature.summary ||
summary ||
agentInfo?.summary ||
"No summary available"}
{feature.summary || summary || agentInfo?.summary || 'No summary available'}
</Markdown>
</div>
<DialogFooter>
@@ -72,4 +66,3 @@ export function SummaryDialog({
</Dialog>
);
}

View File

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

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,18 +6,19 @@ import {
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
} 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
@@ -26,10 +26,10 @@ import {
FlaskConical,
Sparkles,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
useAppStore,
AgentModel,
@@ -37,7 +37,7 @@ import {
FeatureImage,
AIProfile,
PlanningMode,
} from "@/store/app-store";
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
@@ -46,14 +46,14 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "@tanstack/react-router";
} from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router';
interface AddFeatureDialogProps {
open: boolean;
@@ -65,6 +65,7 @@ interface AddFeatureDialogProps {
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
@@ -92,7 +93,7 @@ export function AddFeatureDialog({
branchSuggestions,
branchCardCounts,
defaultSkipTests,
defaultBranch = "main",
defaultBranch = 'main',
currentBranch,
isMaximized,
showProfilesOnly,
@@ -101,27 +102,29 @@ export function AddFeatureDialog({
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
title: "",
category: "",
description: "",
steps: [""],
title: '',
category: '',
description: '',
steps: [''],
images: [] as FeatureImage[],
imagePaths: [] as DescriptionImagePath[],
textFilePaths: [] as DescriptionTextFilePath[],
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "",
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 [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");
'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
@@ -144,10 +147,10 @@ export function AddFeatureDialog({
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
branchName: defaultBranch || "",
branchName: defaultBranch || '',
// Use default profile's model/thinkingLevel if set, else fallback to defaults
model: defaultProfile?.model ?? "opus",
thinkingLevel: defaultProfile?.thinkingLevel ?? "none",
model: defaultProfile?.model ?? 'opus',
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
}));
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
@@ -171,22 +174,20 @@ export function AddFeatureDialog({
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error("Please select a branch name");
toast.error('Please select a branch name');
return;
}
const category = newFeature.category || "Uncategorized";
const category = newFeature.category || 'Uncategorized';
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.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 || ""
: newFeature.branchName || "";
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
onAdd({
title: newFeature.title,
@@ -195,6 +196,7 @@ export function AddFeatureDialog({
steps: newFeature.steps.filter((s) => s.trim()),
images: newFeature.images,
imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths,
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
@@ -206,17 +208,18 @@ export function AddFeatureDialog({
// Reset form
setNewFeature({
title: "",
category: "",
description: "",
steps: [""],
title: '',
category: '',
description: '',
steps: [''],
images: [],
imagePaths: [],
textFilePaths: [],
skipTests: defaultSkipTests,
model: "opus",
model: 'opus',
priority: 2,
thinkingLevel: "none",
branchName: "",
thinkingLevel: 'none',
branchName: '',
});
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
@@ -251,13 +254,13 @@ export function AddFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
toast.success("Description enhanced!");
toast.success('Description enhanced!');
} else {
toast.error(result?.error || "Failed to enhance description");
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
console.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
@@ -267,16 +270,11 @@ export function AddFeatureDialog({
setNewFeature({
...newFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? newFeature.thinkingLevel
: "none",
thinkingLevel: modelSupportsThinking(model) ? newFeature.thinkingLevel : 'none',
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
setNewFeature({
...newFeature,
model,
@@ -306,14 +304,9 @@ export function AddFeatureDialog({
>
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>
Create a new feature card for the Kanban board.
</DialogDescription>
<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"
>
<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" />
@@ -330,10 +323,7 @@ export function AddFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
@@ -345,8 +335,10 @@ export function AddFeatureDialog({
}
}}
images={newFeature.imagePaths}
onImagesChange={(images) =>
setNewFeature({ ...newFeature, imagePaths: images })
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
textFiles={newFeature.textFilePaths}
onTextFilesChange={(textFiles) =>
setNewFeature({ ...newFeature, textFilePaths: textFiles })
}
placeholder="Describe the feature..."
previewMap={newFeaturePreviewMap}
@@ -360,47 +352,32 @@ export function AddFeatureDialog({
<Input
id="title"
value={newFeature.title}
onChange={(e) =>
setNewFeature({ ...newFeature, title: e.target.value })
}
onChange={(e) => setNewFeature({ ...newFeature, title: e.target.value })}
placeholder="Leave blank to auto-generate"
/>
</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"}
<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")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
@@ -422,9 +399,7 @@ export function AddFeatureDialog({
<Label htmlFor="category">Category (optional)</Label>
<CategoryAutocomplete
value={newFeature.category}
onChange={(value) =>
setNewFeature({ ...newFeature, category: value })
}
onChange={(value) => setNewFeature({ ...newFeature, category: value })}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="feature-category-input"
@@ -435,9 +410,7 @@ export function AddFeatureDialog({
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={newFeature.branchName}
onBranchNameChange={(value) =>
setNewFeature({ ...newFeature, branchName: value })
}
onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
@@ -448,25 +421,18 @@ export function AddFeatureDialog({
{/* Priority Selector */}
<PrioritySelector
selectedPriority={newFeature.priority}
onPrioritySelect={(priority) =>
setNewFeature({ ...newFeature, priority })
}
onPrioritySelect={(priority) => setNewFeature({ ...newFeature, priority })}
testIdPrefix="priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
<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-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>
@@ -478,7 +444,7 @@ export function AddFeatureDialog({
data-testid="show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showAdvancedOptions ? "Hide" : "Show"} Advanced
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</div>
)}
@@ -492,23 +458,19 @@ export function AddFeatureDialog({
showManageLink
onManageLinkClick={() => {
onOpenChange(false);
navigate({ to: "/profiles" });
navigate({ to: '/profiles' });
}}
/>
{/* Separator */}
{aiProfiles.length > 0 &&
(!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showAdvancedOptions) && (
<>
<ModelSelector
selectedModel={newFeature.model}
onModelSelect={handleModelSelect}
/>
<ModelSelector selectedModel={newFeature.model} onModelSelect={handleModelSelect} />
{newModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={newFeature.thinkingLevel}
@@ -522,10 +484,7 @@ export function AddFeatureDialog({
</TabsContent>
{/* Options Tab */}
<TabsContent
value="options"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
@@ -542,9 +501,7 @@ export function AddFeatureDialog({
{/* Testing Section */}
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) =>
setNewFeature({ ...newFeature, skipTests })
}
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
steps={newFeature.steps}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
@@ -556,12 +513,10 @@ export function AddFeatureDialog({
</Button>
<HotkeyButton
onClick={handleAdd}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
disabled={
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
}
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
Add Feature
</HotkeyButton>

View File

@@ -1,19 +1,18 @@
import { useEffect, useRef, useState } from "react";
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";
} 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;
@@ -26,7 +25,7 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = "parsed" | "raw" | "changes";
type ViewMode = 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -36,13 +35,13 @@ export function AgentOutputModal({
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>("");
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
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 projectPathRef = useRef<string>('');
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -75,22 +74,19 @@ export function AgentOutputModal({
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
const result = await api.features.getAgentOutput(currentProject.path, featureId);
if (result.success) {
setOutput(result.content || "");
setOutput(result.content || '');
} else {
setOutput("");
setOutput('');
}
} else {
setOutput("");
setOutput('');
}
} catch (error) {
console.error("Failed to load output:", error);
setOutput("");
console.error('Failed to load output:', error);
setOutput('');
} finally {
setIsLoading(false);
}
@@ -108,38 +104,32 @@ export function AgentOutputModal({
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only (skip events without featureId)
if ("featureId" in event && event.featureId !== featureId) {
if ('featureId' in event && event.featureId !== featureId) {
return;
}
let newContent = "";
let newContent = '';
switch (event.type) {
case "auto_mode_progress":
newContent = event.content || "";
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` : ""
}`;
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":
}
case 'auto_mode_phase': {
const phaseEmoji =
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
event.phase === 'planning' ? '📋' : event.phase === 'action' ? '⚡' : '✅';
newContent = `\n${phaseEmoji} ${event.message}\n`;
break;
case "auto_mode_error":
}
case 'auto_mode_error':
newContent = `\n❌ Error: ${event.error}\n`;
break;
case "auto_mode_ultrathink_preparation":
case 'auto_mode_ultrathink_preparation': {
// Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`;
@@ -169,66 +159,74 @@ export function AgentOutputModal({
newContent = prepContent;
break;
case "planning_started":
}
case 'planning_started': {
// Show when planning mode begins
if ("mode" in event && "message" in event) {
if ('mode' in event && 'message' in event) {
const modeLabel =
event.mode === "lite"
? "Lite"
: event.mode === "spec"
? "Spec"
: "Full";
event.mode === 'lite' ? 'Lite' : event.mode === 'spec' ? 'Spec' : 'Full';
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
}
break;
case "plan_approval_required":
}
case 'plan_approval_required':
// Show when plan requires approval
if ("planningMode" in event) {
if ('planningMode' in event) {
newContent = `\n⏸ Plan generated - waiting for your approval...\n`;
}
break;
case "plan_approved":
case 'plan_approved':
// Show when plan is manually approved
if ("hasEdits" in event) {
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":
case 'plan_auto_approved':
// Show when plan is auto-approved
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
break;
case "plan_revision_requested":
case 'plan_revision_requested': {
// Show when user requests plan revision
if ("planVersion" in event) {
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
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":
}
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" }>;
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":
}
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" }>;
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":
}
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" }>;
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 ? "✅" : "⚠️";
}
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)
@@ -239,6 +237,7 @@ export function AgentOutputModal({
}, 1500);
}
break;
}
}
if (newContent) {
@@ -267,20 +266,15 @@ export function AgentOutputModal({
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)
) {
if (!event.ctrlKey && !event.altKey && !event.metaKey && /^[0-9]$/.test(event.key)) {
event.preventDefault();
onNumberKeyPress(event.key);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, onNumberKeyPress]);
@@ -293,19 +287,18 @@ export function AgentOutputModal({
<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" />
)}
{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")}
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"
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
@@ -313,11 +306,11 @@ export function AgentOutputModal({
Logs
</button>
<button
onClick={() => setViewMode("changes")}
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"
viewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-changes"
>
@@ -325,11 +318,11 @@ export function AgentOutputModal({
Changes
</button>
<button
onClick={() => setViewMode("raw")}
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"
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
@@ -353,7 +346,7 @@ export function AgentOutputModal({
className="flex-shrink-0 mx-1"
/>
{viewMode === "changes" ? (
{viewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
@@ -386,19 +379,17 @@ export function AgentOutputModal({
<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" ? (
) : viewMode === 'parsed' ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
<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"}
? 'Auto-scrolling enabled'
: 'Scroll to bottom to enable auto-scroll'}
</div>
</>
)}

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Dialog,
@@ -7,9 +7,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Archive } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Archive } from 'lucide-react';
interface ArchiveAllVerifiedDialogProps {
open: boolean;
@@ -30,8 +30,8 @@ export function ArchiveAllVerifiedDialog({
<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.
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.
@@ -52,5 +52,3 @@ export function ArchiveAllVerifiedDialog({
</Dialog>
);
}

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
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";
} 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;
@@ -36,7 +35,7 @@ export function CommitWorktreeDialog({
worktree,
onCommitted,
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState("");
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -49,36 +48,36 @@ export function CommitWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.commit) {
setError("Worktree API not available");
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", {
toast.success('Changes committed', {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
});
onCommitted();
onOpenChange(false);
setMessage("");
setMessage('');
} else {
toast.info("No changes to commit", {
toast.info('No changes to commit', {
description: result.result.message,
});
}
} else {
setError(result.error || "Failed to commit changes");
setError(result.error || 'Failed to commit changes');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to commit");
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()) {
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
handleCommit();
}
};
@@ -94,15 +93,12 @@ export function CommitWorktreeDialog({
Commit Changes
</DialogTitle>
<DialogDescription>
Commit changes in the{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>{" "}
worktree.
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)
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
</DialogDescription>
@@ -132,17 +128,10 @@ export function CommitWorktreeDialog({
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={isLoading || !message.trim()}
>
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,11 +5,11 @@ import {
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";
} 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;
@@ -37,9 +36,9 @@ export function CompletedFeaturesModal({
<DialogTitle>Completed Features</DialogTitle>
<DialogDescription>
{completedFeatures.length === 0
? "No completed features yet."
? 'No completed features yet.'
: `${completedFeatures.length} completed feature${
completedFeatures.length > 1 ? "s" : ""
completedFeatures.length > 1 ? 's' : ''
}`}
</DialogDescription>
</DialogHeader>
@@ -62,7 +61,7 @@ export function CompletedFeaturesModal({
{feature.description || feature.summary || feature.id}
</CardTitle>
<CardDescription className="text-xs mt-1 truncate">
{feature.category || "Uncategorized"}
{feature.category || 'Uncategorized'}
</CardDescription>
</CardHeader>
<div className="p-3 pt-0 flex gap-2">

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
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";
} 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;
@@ -36,14 +35,14 @@ export function CreateBranchDialog({
worktree,
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState("");
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("");
setBranchName('');
setError(null);
}
}, [open]);
@@ -54,7 +53,7 @@ export function CreateBranchDialog({
// Basic validation
const invalidChars = /[\s~^:?*[\]\\]/;
if (invalidChars.test(branchName)) {
setError("Branch name contains invalid characters");
setError('Branch name contains invalid characters');
return;
}
@@ -64,7 +63,7 @@ export function CreateBranchDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error("Branch API not available");
toast.error('Branch API not available');
return;
}
@@ -75,11 +74,11 @@ export function CreateBranchDialog({
onCreated();
onOpenChange(false);
} else {
setError(result.error || "Failed to create branch");
setError(result.error || 'Failed to create branch');
}
} catch (err) {
console.error("Create branch failed:", err);
setError("Failed to create branch");
console.error('Create branch failed:', err);
setError('Failed to create branch');
} finally {
setIsCreating(false);
}
@@ -94,7 +93,10 @@ export function CreateBranchDialog({
Create New Branch
</DialogTitle>
<DialogDescription>
Create a new branch from <span className="font-mono text-foreground">{worktree?.branch || "current branch"}</span>
Create a new branch from{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
</DialogDescription>
</DialogHeader>
@@ -110,38 +112,29 @@ export function CreateBranchDialog({
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && branchName.trim() && !isCreating) {
if (e.key === 'Enter' && branchName.trim() && !isCreating) {
handleCreate();
}
}}
disabled={isCreating}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!branchName.trim() || isCreating}
>
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create Branch"
'Create Branch'
)}
</Button>
</DialogFooter>

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -7,15 +6,15 @@ import {
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";
} 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;
@@ -40,10 +39,10 @@ export function CreatePRDialog({
projectPath,
onCreated,
}: CreatePRDialogProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [baseBranch, setBaseBranch] = useState("main");
const [commitMessage, setCommitMessage] = useState("");
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);
@@ -57,10 +56,10 @@ export function CreatePRDialog({
useEffect(() => {
if (open) {
// Reset form fields
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setIsDraft(false);
setError(null);
// Also reset result states when opening for a new worktree
@@ -72,10 +71,10 @@ export function CreatePRDialog({
operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setIsDraft(false);
setError(null);
setPrUrl(null);
@@ -94,7 +93,7 @@ export function CreatePRDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.createPR) {
setError("Worktree API not available");
setError('Worktree API not available');
return;
}
const result = await api.worktree.createPR(worktree.path, {
@@ -114,19 +113,19 @@ export function CreatePRDialog({
// Show different message based on whether PR already existed
if (result.result.prAlreadyExisted) {
toast.success("Pull request found!", {
toast.success('Pull request found!', {
description: `PR already exists for ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
},
});
} else {
toast.success("Pull request created!", {
toast.success('Pull request created!', {
description: `PR created from ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
},
});
}
@@ -140,12 +139,12 @@ export function CreatePRDialog({
// 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) {
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", {
toast.success('Branch pushed', {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
@@ -159,11 +158,12 @@ export function CreatePRDialog({
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")) {
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.";
}
@@ -172,7 +172,7 @@ export function CreatePRDialog({
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed even though PR creation failed
operationCompletedRef.current = true;
toast.error("PR creation failed", {
toast.error('PR creation failed', {
description: errorMessage,
duration: 8000,
});
@@ -183,7 +183,7 @@ export function CreatePRDialog({
}
// Show success toast for push
toast.success("Branch pushed", {
toast.success('Branch pushed', {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
@@ -192,8 +192,9 @@ export function CreatePRDialog({
// 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.",
toast.info('PR not created', {
description:
'Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.',
duration: 8000,
});
}
@@ -202,10 +203,10 @@ export function CreatePRDialog({
onOpenChange(false);
}
} else {
setError(result.error || "Failed to create pull request");
setError(result.error || 'Failed to create pull request');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create PR");
setError(err instanceof Error ? err.message : 'Failed to create PR');
} finally {
setIsLoading(false);
}
@@ -235,10 +236,8 @@ export function CreatePRDialog({
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>
Push changes and create a pull request from{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
@@ -249,15 +248,10 @@ export function CreatePRDialog({
</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>
<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"
>
<Button onClick={() => window.open(prUrl, '_blank')} className="gap-2">
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
@@ -283,7 +277,7 @@ export function CreatePRDialog({
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, "_blank");
window.open(browserUrl, '_blank');
}
}}
className="gap-2 w-full"
@@ -292,11 +286,10 @@ export function CreatePRDialog({
<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>
<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
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}>
@@ -311,8 +304,7 @@ export function CreatePRDialog({
{worktree.hasChanges && (
<div className="grid gap-2">
<Label htmlFor="commit-message">
Commit Message{" "}
<span className="text-muted-foreground">(optional)</span>
Commit Message <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="commit-message"
@@ -322,8 +314,7 @@ export function CreatePRDialog({
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{worktree.changedFilesCount} uncommitted file(s) will be
committed
{worktree.changedFilesCount} uncommitted file(s) will be committed
</p>
</div>
)}
@@ -374,9 +365,7 @@ export function CreatePRDialog({
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
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";
} 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;
@@ -33,13 +32,13 @@ export function CreateWorktreeDialog({
projectPath,
onCreated,
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState("");
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");
setError('Branch name is required');
return;
}
@@ -47,7 +46,7 @@ export function CreateWorktreeDialog({
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError(
"Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes."
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
);
return;
}
@@ -58,35 +57,30 @@ export function CreateWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError("Worktree API not available");
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",
}
);
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("");
setBranchName('');
} else {
setError(result.error || "Failed to create worktree");
setError(result.error || 'Failed to create worktree');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create worktree");
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()) {
if (e.key === 'Enter' && !isLoading && branchName.trim()) {
handleCreate();
}
};
@@ -100,8 +94,8 @@ export function CreateWorktreeDialog({
Create New Worktree
</DialogTitle>
<DialogDescription>
Create a new git worktree with its own branch. This allows you to
work on multiple features in parallel.
Create a new git worktree with its own branch. This allows you to work on multiple
features in parallel.
</DialogDescription>
</DialogHeader>
@@ -140,17 +134,10 @@ export function CreateWorktreeDialog({
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={isLoading || !branchName.trim()}
>
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,9 +5,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
interface DeleteAllVerifiedDialogProps {
open: boolean;
@@ -29,8 +28,7 @@ export function DeleteAllVerifiedDialog({
<DialogHeader>
<DialogTitle>Delete All Verified Features</DialogTitle>
<DialogDescription>
Are you sure you want to delete all verified features? This action
cannot be undone.
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.
@@ -42,7 +40,11 @@ export function DeleteAllVerifiedDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-all-verified">
<Button
variant="destructive"
onClick={onConfirm}
data-testid="confirm-delete-all-verified"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete All
</Button>

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,10 +5,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { Feature } from "@/store/app-store";
} 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;
@@ -36,7 +35,7 @@ export function DeleteCompletedFeatureDialog({
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;
{(feature.description?.length ?? 0) > 100 ? '...' : ''}&quot;
</span>
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
@@ -44,11 +43,7 @@ export function DeleteCompletedFeatureDialog({
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={onClose}
data-testid="cancel-delete-completed-button"
>
<Button variant="ghost" onClick={onClose} data-testid="cancel-delete-completed-button">
Cancel
</Button>
<Button

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
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";
} 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;
@@ -51,14 +50,10 @@ export function DeleteWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.delete) {
toast.error("Worktree API not available");
toast.error('Worktree API not available');
return;
}
const result = await api.worktree.delete(
projectPath,
worktree.path,
deleteBranch
);
const result = await api.worktree.delete(projectPath, worktree.path, deleteBranch);
if (result.success) {
toast.success(`Worktree deleted`, {
@@ -70,13 +65,13 @@ export function DeleteWorktreeDialog({
onOpenChange(false);
setDeleteBranch(false);
} else {
toast.error("Failed to delete worktree", {
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",
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsLoading(false);
@@ -95,21 +90,18 @@ export function DeleteWorktreeDialog({
</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>
?
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.
{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>
)}
@@ -118,8 +110,8 @@ export function DeleteWorktreeDialog({
<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.
This worktree has {worktree.changedFilesCount} uncommitted change(s). These will
be lost if you proceed.
</span>
</div>
)}
@@ -133,26 +125,16 @@ export function DeleteWorktreeDialog({
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>
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}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,14 +1,8 @@
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";
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;
@@ -37,22 +31,20 @@ export function DependencyTreeDialog({
.filter((f): f is Feature => f !== undefined);
// Find features that depend on this one
const dependents = allFeatures.filter((f) =>
f.dependencies?.includes(feature.id)
);
const dependents = allFeatures.filter((f) => f.dependencies?.includes(feature.id));
setDependencyTree({ dependencies, dependents });
}, [feature, allFeatures]);
if (!feature) return null;
const getStatusIcon = (status: Feature["status"]) => {
const getStatusIcon = (status: Feature['status']) => {
switch (status) {
case "completed":
case "verified":
case 'completed':
case 'verified':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case "in_progress":
case "waiting_approval":
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" />;
@@ -64,10 +56,10 @@ export function DependencyTreeDialog({
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"
'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}
@@ -91,9 +83,7 @@ export function DependencyTreeDialog({
{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>
<p className="text-xs text-muted-foreground/70 mt-2">Category: {feature.category}</p>
</div>
{/* Dependencies (what this feature needs) */}
@@ -102,9 +92,7 @@ export function DependencyTreeDialog({
<h3 className="font-semibold text-sm">
Dependencies ({dependencyTree.dependencies.length})
</h3>
<span className="text-xs text-muted-foreground">
This feature requires:
</span>
<span className="text-xs text-muted-foreground">This feature requires:</span>
</div>
{dependencyTree.dependencies.length === 0 ? (
@@ -117,35 +105,33 @@ export function DependencyTreeDialog({
<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"
'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 && "..."}
{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="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"
'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, " ")}
{dep.status.replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -160,9 +146,7 @@ export function DependencyTreeDialog({
<h3 className="font-semibold text-sm">
Dependents ({dependencyTree.dependents.length})
</h3>
<span className="text-xs text-muted-foreground">
Features blocked by this:
</span>
<span className="text-xs text-muted-foreground">Features blocked by this:</span>
</div>
{dependencyTree.dependents.length === 0 ? (
@@ -172,34 +156,28 @@ export function DependencyTreeDialog({
) : (
<div className="space-y-2">
{dependencyTree.dependents.map((dependent) => (
<div
key={dependent.id}
className="border rounded-lg p-3 bg-muted/30"
>
<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 && "..."}
{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="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"
'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, " ")}
{dependent.status.replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -210,7 +188,7 @@ export function DependencyTreeDialog({
{/* Warning for incomplete dependencies */}
{dependencyTree.dependencies.some(
(d) => d.status !== "completed" && d.status !== "verified"
(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" />
@@ -219,8 +197,8 @@ export function DependencyTreeDialog({
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.
This feature has dependencies that aren't completed yet. Consider completing them
first for a smoother implementation.
</p>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,30 +6,30 @@ import {
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
} 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} 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";
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
Feature,
AgentModel,
@@ -38,7 +37,7 @@ import {
AIProfile,
useAppStore,
PlanningMode,
} from "@/store/app-store";
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
@@ -47,14 +46,14 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DependencyTreeDialog } from "./dependency-tree-dialog";
} from '@/components/ui/dropdown-menu';
import { DependencyTreeDialog } from './dependency-tree-dialog';
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -70,6 +69,7 @@ interface EditFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
@@ -104,16 +104,19 @@ export function EditFeatureDialog({
// If feature has no branchName, default to using current branch
return !feature?.branchName;
});
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
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");
'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);
const [requirePlanApproval, setRequirePlanApproval] = useState(
feature?.requirePlanApproval ?? false
);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
@@ -135,33 +138,31 @@ export function EditFeatureDialog({
if (!editingFeature) return;
// Validate branch selection when "other branch" is selected and branch selector is enabled
const isBranchSelectorEnabled = editingFeature.status === "backlog";
const isBranchSelectorEnabled = editingFeature.status === 'backlog';
if (
useWorktrees &&
isBranchSelectorEnabled &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
) {
toast.error("Please select a branch name");
toast.error('Please select a branch name');
return;
}
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none";
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 || "";
? currentBranch || ''
: editingFeature.branchName || '';
const updates = {
title: editingFeature.title ?? "",
title: editingFeature.title ?? '',
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
@@ -169,6 +170,7 @@ export function EditFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
textFilePaths: editingFeature.textFilePaths ?? [],
branchName: finalBranchName,
priority: editingFeature.priority ?? 2,
planningMode,
@@ -192,16 +194,11 @@ export function EditFeatureDialog({
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: "none",
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
@@ -224,16 +221,14 @@ export function EditFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!");
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
toast.success('Description enhanced!');
} else {
toast.error(result?.error || "Failed to enhance description");
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
console.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
@@ -267,10 +262,7 @@ export function EditFeatureDialog({
<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"
>
<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" />
@@ -287,10 +279,7 @@ export function EditFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
@@ -308,6 +297,13 @@ export function EditFeatureDialog({
imagePaths: images,
})
}
textFiles={editingFeature.textFilePaths ?? []}
onTextFilesChange={(textFiles) =>
setEditingFeature({
...editingFeature,
textFilePaths: textFiles,
})
}
placeholder="Describe the feature..."
previewMap={editFeaturePreviewMap}
onPreviewMapChange={setEditFeaturePreviewMap}
@@ -318,7 +314,7 @@ export function EditFeatureDialog({
<Label htmlFor="edit-title">Title (optional)</Label>
<Input
id="edit-title"
value={editingFeature.title ?? ""}
value={editingFeature.title ?? ''}
onChange={(e) =>
setEditingFeature({
...editingFeature,
@@ -332,38 +328,25 @@ export function EditFeatureDialog({
<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"}
<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")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
@@ -400,7 +383,7 @@ export function EditFeatureDialog({
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ""}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
@@ -410,7 +393,7 @@ export function EditFeatureDialog({
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== "backlog"}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
/>
)}
@@ -429,17 +412,12 @@ export function EditFeatureDialog({
</TabsContent>
{/* Model Tab */}
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
<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-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>
@@ -447,13 +425,11 @@ export function EditFeatureDialog({
<Button
variant="outline"
size="sm"
onClick={() =>
setShowEditAdvancedOptions(!showEditAdvancedOptions)
}
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
data-testid="edit-show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</div>
)}
@@ -461,29 +437,28 @@ export function EditFeatureDialog({
{/* Quick Select Profile Section */}
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={editingFeature.model ?? "opus"}
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
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" />
)}
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<>
<ModelSelector
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
onModelSelect={handleModelSelect}
testIdPrefix="edit-model-select"
/>
{editModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={editingFeature.thinkingLevel ?? "none"}
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
onLevelSelect={(level) =>
setEditingFeature({
...editingFeature,
@@ -515,13 +490,9 @@ export function EditFeatureDialog({
{/* Testing Section */}
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>
setEditingFeature({ ...editingFeature, skipTests })
}
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
steps={editingFeature.steps}
onStepsChange={(steps) =>
setEditingFeature({ ...editingFeature, steps })
}
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
testIdPrefix="edit"
/>
</TabsContent>
@@ -541,12 +512,12 @@ export function EditFeatureDialog({
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === "backlog" &&
editingFeature.status === 'backlog' &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -7,11 +6,11 @@ import {
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";
} 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,
@@ -22,10 +21,15 @@ import {
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";
} 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;
@@ -39,35 +43,38 @@ interface FeatureSuggestionsDialogProps {
}
// Configuration for each suggestion type
const suggestionTypeConfig: Record<SuggestionType, {
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}> = {
const suggestionTypeConfig: Record<
SuggestionType,
{
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}
> = {
features: {
label: "Feature Suggestions",
label: 'Feature Suggestions',
icon: Lightbulb,
description: "Discover missing features and improvements",
color: "text-yellow-500",
description: 'Discover missing features and improvements',
color: 'text-yellow-500',
},
refactoring: {
label: "Refactoring Suggestions",
label: 'Refactoring Suggestions',
icon: RefreshCw,
description: "Find code smells and refactoring opportunities",
color: "text-blue-500",
description: 'Find code smells and refactoring opportunities',
color: 'text-blue-500',
},
security: {
label: "Security Suggestions",
label: 'Security Suggestions',
icon: Shield,
description: "Identify security vulnerabilities and issues",
color: "text-red-500",
description: 'Identify security vulnerabilities and issues',
color: 'text-red-500',
},
performance: {
label: "Performance Suggestions",
label: 'Performance Suggestions',
icon: Zap,
description: "Discover performance bottlenecks and optimizations",
color: "text-green-500",
description: 'Discover performance bottlenecks and optimizations',
color: 'text-green-500',
},
};
@@ -112,23 +119,25 @@ export function FeatureSuggestionsDialog({
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";
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") {
} 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";
const typeLabel = currentSuggestionType
? suggestionTypeConfig[currentSuggestionType].label.toLowerCase()
: 'suggestions';
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info("No suggestions generated. Try again.");
toast.info('No suggestions generated. Try again.');
}
} else if (event.type === "suggestions_error") {
} else if (event.type === 'suggestions_error') {
setIsGenerating(false);
toast.error(`Error: ${event.error}`);
}
@@ -140,31 +149,34 @@ export function FeatureSuggestionsDialog({
}, [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;
}
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);
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");
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);
}
} catch (error) {
console.error("Failed to generate suggestions:", error);
toast.error("Failed to start generation");
setIsGenerating(false);
}
}, [projectPath, setIsGenerating, setSuggestions]);
},
[projectPath, setIsGenerating, setSuggestions]
);
// Stop generating
const handleStop = useCallback(async () => {
@@ -174,9 +186,9 @@ export function FeatureSuggestionsDialog({
try {
await api.suggestions.stop();
setIsGenerating(false);
toast.info("Generation stopped");
toast.info('Generation stopped');
} catch (error) {
console.error("Failed to stop generation:", error);
console.error('Failed to stop generation:', error);
}
}, [setIsGenerating]);
@@ -218,7 +230,7 @@ export function FeatureSuggestionsDialog({
// Import selected suggestions as features
const handleImport = useCallback(async () => {
if (selectedIds.size === 0) {
toast.warning("No suggestions selected");
toast.warning('No suggestions selected');
return;
}
@@ -226,9 +238,7 @@ export function FeatureSuggestionsDialog({
try {
const api = getElectronAPI();
const selectedSuggestions = suggestions.filter((s) =>
selectedIds.has(s.id)
);
const selectedSuggestions = suggestions.filter((s) => selectedIds.has(s.id));
// Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
@@ -236,7 +246,7 @@ export function FeatureSuggestionsDialog({
category: s.category,
description: s.description,
steps: s.steps,
status: "backlog" as const,
status: 'backlog' as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
}));
@@ -264,8 +274,8 @@ export function FeatureSuggestionsDialog({
onClose();
} catch (error) {
console.error("Failed to import features:", error);
toast.error("Failed to import features");
console.error('Failed to import features:', error);
toast.error('Failed to import features');
} finally {
setIsImporting(false);
}
@@ -315,7 +325,7 @@ export function FeatureSuggestionsDialog({
<DialogDescription>
{currentConfig
? currentConfig.description
: "Analyze your project to discover improvements. Choose a suggestion type below."}
: 'Analyze your project to discover improvements. Choose a suggestion type below.'}
</DialogDescription>
</DialogHeader>
@@ -323,32 +333,35 @@ export function FeatureSuggestionsDialog({
// 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:
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>
{(
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>
</Button>
);
}
)}
<div className="text-xs text-muted-foreground mt-1">{config.description}</div>
</div>
</Button>
);
})}
</div>
</div>
) : isGenerating ? (
@@ -370,7 +383,7 @@ export function FeatureSuggestionsDialog({
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("")}
{progress.join('')}
</div>
</div>
</div>
@@ -383,14 +396,10 @@ export function FeatureSuggestionsDialog({
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length
? "Deselect All"
: "Select All"}
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<span className="text-sm font-medium">{selectedIds.size} selected</span>
</div>
<div
@@ -406,8 +415,8 @@ export function FeatureSuggestionsDialog({
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
data-testid={`suggestion-${suggestion.id}`}
>
@@ -447,9 +456,7 @@ export function FeatureSuggestionsDialog({
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">
{suggestion.reasoning}
</p>
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
)}
{suggestion.steps.length > 0 && (
<div>
@@ -513,7 +520,7 @@ export function FeatureSuggestionsDialog({
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
@@ -522,7 +529,7 @@ export function FeatureSuggestionsDialog({
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? "s" : ""}
{selectedIds.size !== 1 ? 's' : ''}
</HotkeyButton>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,17 +6,17 @@ import {
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";
} 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";
} from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store';
interface FollowUpDialogProps {
open: boolean;
@@ -58,7 +57,7 @@ export function FollowUpDialog({
compact={!isMaximized}
data-testid="follow-up-dialog"
onKeyDown={(e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && prompt.trim()) {
e.preventDefault();
onSend();
}
@@ -71,7 +70,7 @@ export function FollowUpDialog({
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 100)}
{feature.description.length > 100 ? "..." : ""}
{feature.description.length > 100 ? '...' : ''}
</span>
)}
</DialogDescription>
@@ -90,8 +89,8 @@ export function FollowUpDialog({
/>
</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.
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>
@@ -106,7 +105,7 @@ export function FollowUpDialog({
<HotkeyButton
onClick={onSend}
disabled={!prompt.trim()}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-follow-up"
>

View File

@@ -1,9 +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";
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

@@ -1,6 +1,6 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -8,13 +8,13 @@ import {
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";
} 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;
@@ -40,7 +40,7 @@ export function PlanApprovalDialog({
const [isEditMode, setIsEditMode] = useState(false);
const [editedPlan, setEditedPlan] = useState(planContent);
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
const [rejectFeedback, setRejectFeedback] = useState("");
const [rejectFeedback, setRejectFeedback] = useState('');
// Reset state when dialog opens or plan content changes
useEffect(() => {
@@ -48,7 +48,7 @@ export function PlanApprovalDialog({
setEditedPlan(planContent);
setIsEditMode(false);
setShowRejectFeedback(false);
setRejectFeedback("");
setRejectFeedback('');
}
}, [open, planContent]);
@@ -68,7 +68,7 @@ export function PlanApprovalDialog({
const handleCancelReject = () => {
setShowRejectFeedback(false);
setRejectFeedback("");
setRejectFeedback('');
};
const handleClose = (open: boolean) => {
@@ -79,20 +79,17 @@ export function PlanApprovalDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="max-w-4xl"
data-testid="plan-approval-dialog"
>
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
<DialogHeader>
<DialogTitle>{viewOnly ? "View Plan" : "Review Plan"}</DialogTitle>
<DialogTitle>{viewOnly ? 'View Plan' : 'Review Plan'}</DialogTitle>
<DialogDescription>
{viewOnly
? "View the generated plan for this feature."
: "Review the generated plan before implementation begins."}
? '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 ? "..." : ""}
{feature.description.length > 150 ? '...' : ''}
</span>
)}
</DialogDescription>
@@ -103,7 +100,7 @@ export function PlanApprovalDialog({
{!viewOnly && (
<div className="flex items-center justify-between mb-3">
<Label className="text-sm text-muted-foreground">
{isEditMode ? "Edit Mode" : "View Mode"}
{isEditMode ? 'Edit Mode' : 'View Mode'}
</Label>
<Button
variant="outline"
@@ -138,7 +135,7 @@ export function PlanApprovalDialog({
/>
) : (
<div className="p-4 overflow-auto">
<Markdown>{editedPlan || "No plan content available."}</Markdown>
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
</div>
)}
</div>
@@ -169,33 +166,21 @@ export function PlanApprovalDialog({
</Button>
) : showRejectFeedback ? (
<>
<Button
variant="ghost"
onClick={handleCancelReject}
disabled={isLoading}
>
<Button variant="ghost" onClick={handleCancelReject} disabled={isLoading}>
Back
</Button>
<Button
variant="secondary"
onClick={handleReject}
disabled={isLoading}
>
<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"}
{rejectFeedback.trim() ? 'Revise Plan' : 'Cancel Feature'}
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={handleReject}
disabled={isLoading}
>
<Button variant="outline" onClick={handleReject} disabled={isLoading}>
<RefreshCw className="w-4 h-4 mr-2" />
Request Changes
</Button>

View File

@@ -1,10 +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";
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

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback } from 'react';
import {
Feature,
FeatureImage,
@@ -6,13 +6,13 @@ import {
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 "@automaker/dependency-resolver";
} 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 '@automaker/dependency-resolver';
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
@@ -20,10 +20,7 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<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;
@@ -113,14 +110,11 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success && result.worktree) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Auto-select the worktree when creating a feature for it
@@ -135,29 +129,27 @@ export function useBoardActions({
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
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",
console.error('[Board] Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
}
// Check if we need to generate a title
const needsTitleGeneration =
!featureData.title.trim() && featureData.description.trim();
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
const newFeatureData = {
...featureData,
title: featureData.title,
titleGenerating: needsTitleGeneration,
status: "backlog" as const,
status: 'backlog' as const,
branchName: finalBranchName,
};
const createdFeature = addFeature(newFeatureData);
@@ -187,7 +179,7 @@ export function useBoardActions({
}
})
.catch((error) => {
console.error("[Board] Error generating title:", error);
console.error('[Board] Error generating title:', error);
// Clear generating flag on error
const titleUpdates = { titleGenerating: false };
updateFeature(createdFeature.id, titleUpdates);
@@ -235,14 +227,11 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
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"
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Refresh worktree list in UI
@@ -252,16 +241,15 @@ export function useBoardActions({
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
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",
console.error('[Board] Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
}
@@ -300,15 +288,13 @@ export function useBoardActions({
if (isRunning) {
try {
await autoMode.stopFeature(featureId);
toast.success("Agent stopped", {
description: `Stopped and deleted: ${truncateDescription(
feature.description
)}`,
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.",
console.error('[Board] Error stopping feature before delete:', error);
toast.error('Failed to stop agent', {
description: 'The feature will still be deleted.',
});
}
}
@@ -321,17 +307,11 @@ export function useBoardActions({
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(
`[Board] Failed to delete image ${imagePathObj.path}:`,
error
);
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
}
}
} catch (error) {
console.error(
`[Board] Error deleting images for feature ${featureId}:`,
error
);
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
}
}
@@ -348,7 +328,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
@@ -362,15 +342,15 @@ export function useBoardActions({
if (result.success) {
console.log(
"[Board] Feature run started successfully, branch:",
feature.branchName || "default"
'[Board] Feature run started successfully, branch:',
feature.branchName || 'default'
);
} else {
console.error("[Board] Failed to run feature:", result.error);
console.error('[Board] Failed to run feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error running feature:", error);
console.error('[Board] Error running feature:', error);
await loadFeatures();
}
},
@@ -380,9 +360,9 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
async (feature: Feature) => {
if (!autoMode.canStartNewTask) {
toast.error("Concurrency limit reached", {
toast.error('Concurrency limit reached', {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? "s" : ""
autoMode.maxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
@@ -397,22 +377,22 @@ export function useBoardActions({
const dep = features.find((f) => f.id === depId);
return dep ? truncateDescription(dep.description, 40) : depId;
})
.join(", ");
.join(', ');
toast.warning("Starting feature with incomplete dependencies", {
toast.warning('Starting feature with incomplete dependencies', {
description: `This feature depends on: ${depDescriptions}`,
});
}
}
const updates = {
status: "in_progress" as const,
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...");
console.log('[Board] Feature moved to in_progress, starting agent...');
await handleRunFeature(feature);
return true;
},
@@ -433,23 +413,20 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
const result = await api.autoMode.verifyFeature(
currentProject.path,
feature.id
);
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
if (result.success) {
console.log("[Board] Feature verification started successfully");
console.log('[Board] Feature verification started successfully');
} else {
console.error("[Board] Failed to verify feature:", result.error);
console.error('[Board] Failed to verify feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error verifying feature:", error);
console.error('[Board] Error verifying feature:', error);
await loadFeatures();
}
},
@@ -463,7 +440,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
@@ -474,13 +451,13 @@ export function useBoardActions({
);
if (result.success) {
console.log("[Board] Feature resume started successfully");
console.log('[Board] Feature resume started successfully');
} else {
console.error("[Board] Failed to resume feature:", result.error);
console.error('[Board] Failed to resume feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error resuming feature:", error);
console.error('[Board] Error resuming feature:', error);
await loadFeatures();
}
},
@@ -489,15 +466,13 @@ export function useBoardActions({
const handleManualVerify = useCallback(
(feature: Feature) => {
moveFeature(feature.id, "verified");
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, {
status: "verified",
status: 'verified',
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${truncateDescription(
feature.description
)}`,
toast.success('Feature verified', {
description: `Marked as verified: ${truncateDescription(feature.description)}`,
});
},
[moveFeature, persistFeatureUpdate]
@@ -506,15 +481,13 @@ export function useBoardActions({
const handleMoveBackToInProgress = useCallback(
(feature: Feature) => {
const updates = {
status: "in_progress" as const,
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
)}`,
toast.info('Feature moved back', {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -523,16 +496,11 @@ export function useBoardActions({
const handleOpenFollowUp = useCallback(
(feature: Feature) => {
setFollowUpFeature(feature);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setShowFollowUpDialog(true);
},
[
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setShowFollowUpDialog,
]
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
);
const handleSendFollowUp = useCallback(async () => {
@@ -543,15 +511,15 @@ export function useBoardActions({
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.",
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,
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
justFinishedAt: undefined,
};
@@ -560,14 +528,12 @@ export function useBoardActions({
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(
featureDescription
)}`,
toast.success('Follow-up started', {
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
});
const imagePaths = followUpImagePaths.map((img) => img.path);
@@ -581,10 +547,9 @@ export function useBoardActions({
// 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",
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();
});
@@ -610,10 +575,9 @@ export function useBoardActions({
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.",
console.error('Commit feature API not available');
toast.error('Commit not available', {
description: 'This feature is not available in the current version.',
});
return;
}
@@ -626,38 +590,29 @@ export function useBoardActions({
);
if (result.success) {
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(
feature.description
)}`,
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",
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",
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,
]
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
);
const handleMergeFeature = useCallback(
@@ -667,37 +622,32 @@ export function useBoardActions({
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.",
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
);
const result = await api.worktree.mergeFeature(currentProject.path, feature.id);
if (result.success) {
await loadFeatures();
toast.success("Feature merged", {
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",
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",
console.error('[Board] Error merging feature:', error);
toast.error('Failed to merge feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
},
@@ -707,12 +657,12 @@ export function useBoardActions({
const handleCompleteFeature = useCallback(
(feature: Feature) => {
const updates = {
status: "completed" as const,
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature completed", {
toast.success('Feature completed', {
description: `Archived: ${truncateDescription(feature.description)}`,
});
},
@@ -722,15 +672,13 @@ export function useBoardActions({
const handleUnarchiveFeature = useCallback(
(feature: Feature) => {
const updates = {
status: "verified" as const,
status: 'verified' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${truncateDescription(
feature.description
)}`,
toast.success('Feature restored', {
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -746,7 +694,7 @@ export function useBoardActions({
const handleOutputModalNumberKeyPress = useCallback(
(key: string) => {
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
const index = key === '0' ? 9 : parseInt(key, 10) - 1;
const targetFeature = inProgressFeaturesForShortcuts[index];
if (!targetFeature) {
@@ -759,12 +707,7 @@ export function useBoardActions({
setOutputFeature(targetFeature);
}
},
[
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
);
const handleForceStopFeature = useCallback(
@@ -773,9 +716,9 @@ export function useBoardActions({
await autoMode.stopFeature(feature.id);
const targetStatus =
feature.skipTests && feature.status === "waiting_approval"
? "waiting_approval"
: "backlog";
feature.skipTests && feature.status === 'waiting_approval'
? 'waiting_approval'
: 'backlog';
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
@@ -783,21 +726,18 @@ export function useBoardActions({
await persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
toast.success('Agent stopped', {
description:
targetStatus === "waiting_approval"
targetStatus === 'waiting_approval'
? `Stopped commit - returned to waiting approval: ${truncateDescription(
feature.description
)}`
: `Stopped working on: ${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",
console.error('[Board] Error stopping feature:', error);
toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
},
@@ -807,25 +747,21 @@ export function useBoardActions({
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 primaryBranch = projectPath ? getPrimaryWorktreeBranch(projectPath) : null;
const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false;
if (f.status !== 'backlog') return false;
// Determine the feature's branch (default to primary branch if not set)
const featureBranch = f.branchName || primaryBranch || "main";
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))
(projectPath && isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
) {
return (
!f.branchName ||
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
!f.branchName || (projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
);
}
@@ -833,13 +769,11 @@ export function useBoardActions({
return featureBranch === currentWorktreeBranch;
});
const availableSlots =
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
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.",
toast.error('Concurrency limit reached', {
description: 'Wait for a task to complete or increase the concurrency limit.',
});
return;
}
@@ -847,12 +781,11 @@ export function useBoardActions({
if (backlogFeatures.length === 0) {
const isOnPrimaryBranch =
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info("Backlog empty", {
(projectPath && isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info('Backlog empty', {
description: !isOnPrimaryBranch
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
: 'No features in backlog to start.',
});
return;
}
@@ -882,9 +815,9 @@ export function useBoardActions({
});
if (!featureToStart) {
toast.info("No eligible features", {
toast.info('No eligible features', {
description:
"All backlog features have unmet dependencies. Complete their dependencies first.",
'All backlog features have unmet dependencies. Complete their dependencies first.',
});
return;
}
@@ -904,7 +837,7 @@ export function useBoardActions({
]);
const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
const verifiedFeatures = features.filter((f) => f.status === 'verified');
for (const feature of verifiedFeatures) {
const isRunning = runningAutoTasks.includes(feature.id);
@@ -912,30 +845,21 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error(
"[Board] Error stopping feature before archive:",
error
);
console.error('[Board] Error stopping feature before archive:', error);
}
}
// Archive the feature by setting status to completed
const updates = {
status: "completed" as const,
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
toast.success("All verified features archived", {
toast.success('All verified features archived', {
description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [
features,
runningAutoTasks,
autoMode,
updateFeature,
persistFeatureUpdate,
]);
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
return {
handleAddFeature,

View File

@@ -1,20 +1,17 @@
import { useMemo } from "react";
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
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
);
const boardBackgroundByProject = useAppStore((state) => state.boardBackgroundByProject);
// Get background settings for current project
const backgroundSettings = useMemo(() => {
return (
(currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings
);
}, [currentProject, boardBackgroundByProject]);
@@ -26,17 +23,15 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
return {
backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || "http://localhost:3008"
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}`
: ""
backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : ''
})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
} as React.CSSProperties;
}, [backgroundSettings, currentProject]);

View File

@@ -1,8 +1,8 @@
import { useMemo, useCallback } from "react";
import { Feature, useAppStore } from "@/store/app-store";
import { resolveDependencies, getBlockingDependencies } from "@automaker/dependency-resolver";
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
type ColumnId = Feature["status"];
type ColumnId = Feature['status'];
interface UseBoardColumnFeaturesProps {
features: Feature[];
@@ -87,7 +87,7 @@ export function useBoardColumnFeatures({
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === "backlog") {
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
@@ -136,7 +136,14 @@ export function useBoardColumnFeatures({
}
return map;
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
}, [
features,
runningAutoTasks,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
]);
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
@@ -147,7 +154,7 @@ export function useBoardColumnFeatures({
// Memoize completed features for the archive modal
const completedFeatures = useMemo(() => {
return features.filter((f) => f.status === "completed");
return features.filter((f) => f.status === 'completed');
}, [features]);
return {

View File

@@ -1,18 +1,15 @@
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";
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>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>;
}
@@ -63,12 +60,10 @@ export function useBoardDragDrop({
// - 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") {
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"
);
console.log('[Board] Cannot drag feature - currently running');
return;
}
}
@@ -94,9 +89,9 @@ export function useBoardDragDrop({
// Handle different drag scenarios
// Note: Worktrees are created server-side at execution time based on feature.branchName
if (draggedFeature.status === "backlog") {
if (draggedFeature.status === 'backlog') {
// From backlog
if (targetStatus === "in_progress") {
if (targetStatus === 'in_progress') {
// Use helper function to handle concurrency check and start implementation
// Server will derive workDir from feature.branchName
await handleStartImplementation(draggedFeature);
@@ -104,122 +99,110 @@ export function useBoardDragDrop({
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
}
} else if (draggedFeature.status === "waiting_approval") {
} 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");
if (targetStatus === 'verified') {
moveFeature(featureId, 'verified');
// Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, {
status: "verified",
status: 'verified',
justFinishedAt: undefined,
});
toast.success("Feature verified", {
toast.success('Feature verified', {
description: `Manually verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} else if (targetStatus === 'backlog') {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
moveFeature(featureId, 'backlog');
// Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
status: 'backlog',
justFinishedAt: undefined,
});
toast.info("Feature moved to backlog", {
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
} else if (draggedFeature.status === "in_progress") {
} else if (draggedFeature.status === 'in_progress') {
// Handle in_progress features being moved
if (targetStatus === "backlog") {
if (targetStatus === 'backlog') {
// Allow moving in_progress cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved 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 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (
targetStatus === "verified" &&
draggedFeature.skipTests
) {
} 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", {
moveFeature(featureId, 'verified');
persistFeatureUpdate(featureId, { status: 'verified' });
toast.success('Feature verified', {
description: `Marked as verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 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"
) {
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", {
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 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} 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", {
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 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
} else if (draggedFeature.status === "verified") {
} else if (draggedFeature.status === 'verified') {
// Handle verified TDD (non-skipTests) features being moved back
if (targetStatus === "waiting_approval") {
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", {
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 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} else if (targetStatus === 'backlog') {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved 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 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
}
},
[
features,
runningAutoTasks,
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
]
[features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation]
);
return {

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { useEffect } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null;
@@ -43,11 +43,11 @@ export function useBoardEffects({
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event) => {
if (event.type === "suggestions_complete" && event.suggestions) {
if (event.type === 'suggestions_complete' && event.suggestions) {
setSuggestionsCount(event.suggestions.length);
setFeatureSuggestions(event.suggestions);
setIsGeneratingSuggestions(false);
} else if (event.type === "suggestions_error") {
} else if (event.type === 'suggestions_error') {
setIsGeneratingSuggestions(false);
}
});
@@ -64,9 +64,9 @@ export function useBoardEffects({
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
"[BoardView] Spec regeneration event:",
'[BoardView] Spec regeneration event:',
event.type,
"for project:",
'for project:',
event.projectPath
);
@@ -74,9 +74,9 @@ export function useBoardEffects({
return;
}
if (event.type === "spec_regeneration_complete") {
if (event.type === 'spec_regeneration_complete') {
setSpecCreatingForProject(null);
} else if (event.type === "spec_regeneration_error") {
} else if (event.type === 'spec_regeneration_error') {
setSpecCreatingForProject(null);
}
});
@@ -101,10 +101,7 @@ export function useBoardEffects({
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log(
"[Board] Syncing running tasks from backend:",
status.runningFeatures
);
console.log('[Board] Syncing running tasks from backend:', status.runningFeatures);
clearRunningTasks(projectId);
@@ -114,7 +111,7 @@ export function useBoardEffects({
}
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);
console.error('[Board] Failed to sync running tasks:', error);
}
};
@@ -126,9 +123,7 @@ export function useBoardEffects({
const checkAllContexts = async () => {
const featuresWithPotentialContext = features.filter(
(f) =>
f.status === "in_progress" ||
f.status === "waiting_approval" ||
f.status === "verified"
f.status === 'in_progress' || f.status === 'waiting_approval' || f.status === 'verified'
);
const contextChecks = await Promise.all(
featuresWithPotentialContext.map(async (f) => ({

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
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;
@@ -24,8 +24,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch =
previousPath !== null && currentPath !== previousPath;
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
@@ -33,9 +32,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// 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}`
);
console.log(`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
@@ -51,7 +48,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
// Keep cached features if API is unavailable
return;
}
@@ -59,17 +56,15 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
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",
})
);
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);
@@ -78,7 +73,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} else if (!result.success && result.error) {
console.error("[BoardView] API returned error:", 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) {
@@ -88,7 +83,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Otherwise keep cached features
}
} catch (error) {
console.error("Failed to load features:", 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) {
@@ -108,9 +103,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
const result = await api.readFile(
`${currentProject.path}/.automaker/categories.json`
);
const result = await api.readFile(`${currentProject.path}/.automaker/categories.json`);
if (result.success && result.content) {
const parsed = JSON.parse(result.content);
@@ -122,7 +115,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} catch (error) {
console.error("Failed to load categories:", error);
console.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
@@ -154,7 +147,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(categories);
}
} catch (error) {
console.error("Failed to save category:", error);
console.error('Failed to save category:', error);
}
},
[currentProject, persistedCategories]
@@ -168,13 +161,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const unsubscribe = api.specRegeneration.onEvent((event) => {
// Refresh the kanban board when spec regeneration completes for the current project
if (
event.type === "spec_regeneration_complete" &&
event.type === 'spec_regeneration_complete' &&
currentProject &&
event.projectPath === currentProject.path
) {
console.log(
"[BoardView] Spec regeneration complete, refreshing features"
);
console.log('[BoardView] Spec regeneration complete, refreshing features');
loadFeatures();
}
});
@@ -195,32 +186,26 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
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;
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === "auto_mode_feature_complete") {
if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
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));
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") {
} 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...");
console.log('[Board] Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === "auto_mode_error") {
} 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
);
console.log('[Board] Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
if (event.featureId) {
@@ -231,20 +216,20 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Check for authentication errors and show a more helpful message
const isAuthError =
event.errorType === "authentication" ||
event.errorType === 'authentication' ||
(event.error &&
(event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")));
(event.error.includes('Authentication failed') ||
event.error.includes('Invalid API key')));
if (isAuthError) {
toast.error("Authentication Failed", {
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",
toast.error('Agent encountered an error', {
description: event.error || 'Check the logs for details',
});
}
}

View File

@@ -1,10 +1,10 @@
import { useMemo, useRef, useEffect } from "react";
import { useMemo, useRef, useEffect } from 'react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { Feature } from "@/store/app-store";
} from '@/hooks/use-keyboard-shortcuts';
import { Feature } from '@/store/app-store';
interface UseBoardKeyboardShortcutsProps {
features: Feature[];
@@ -27,7 +27,7 @@ export function useBoardKeyboardShortcuts({
const inProgressFeaturesForShortcuts = useMemo(() => {
return features.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === "in_progress";
return isRunning || f.status === 'in_progress';
});
}, [features, runningAutoTasks]);
@@ -45,19 +45,19 @@ export function useBoardKeyboardShortcuts({
{
key: shortcuts.addFeature,
action: onAddFeature,
description: "Add new feature",
description: 'Add new feature',
},
{
key: shortcuts.startNext,
action: () => startNextFeaturesRef.current(),
description: "Start next features from backlog",
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);
const key = index === 9 ? '0' : String(index + 1);
shortcutsList.push({
key,
action: () => {

View File

@@ -1,15 +1,13 @@
import { useCallback } from "react";
import { Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
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) {
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
const { updateFeature } = useAppStore();
// Persist feature update to API (replaces saveFeatures)
@@ -20,20 +18,16 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
return;
}
const result = await api.features.update(
currentProject.path,
featureId,
updates
);
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);
console.error('Failed to persist feature update:', error);
}
},
[currentProject, updateFeature]
@@ -47,7 +41,7 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
return;
}
@@ -56,7 +50,7 @@ export function useBoardPersistence({
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature creation:", error);
console.error('Failed to persist feature creation:', error);
}
},
[currentProject, updateFeature]
@@ -70,13 +64,13 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
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);
console.error('Failed to persist feature deletion:', error);
}
},
[currentProject]

View File

@@ -1,32 +1,35 @@
import { useState, useCallback } from "react";
import { Feature } from "@/store/app-store";
import { useState, useCallback } from 'react';
import { Feature } from '@/store/app-store';
import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} 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 [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
}, []);
const handleFollowUpDialogChange = useCallback((open: boolean) => {
if (!open) {
resetFollowUpState();
} else {
setShowFollowUpDialog(open);
}
}, [resetFollowUpState]);
const handleFollowUpDialogChange = useCallback(
(open: boolean) => {
if (!open) {
resetFollowUpState();
} else {
setShowFollowUpDialog(open);
}
},
[resetFollowUpState]
);
return {
// State

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import type { FeatureSuggestion } from "@/lib/electron";
import { useState, useCallback } from 'react';
import type { FeatureSuggestion } from '@/lib/electron';
export function useSuggestionsState() {
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);

View File

@@ -3,7 +3,6 @@ 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';

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { Label } from "@/components/ui/label";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
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;
@@ -25,7 +25,7 @@ export function BranchSelector({
branchCardCounts,
currentBranch,
disabled = false,
testIdPrefix = "branch",
testIdPrefix = 'branch',
}: BranchSelectorProps) {
// Validate: if "other branch" is selected, branch name is required
const isBranchRequired = !useCurrentBranch;
@@ -35,32 +35,22 @@ export function BranchSelector({
<div className="space-y-2">
<Label id={`${testIdPrefix}-label`}>Target Branch</Label>
<RadioGroup
value={useCurrentBranch ? "current" : "other"}
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
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"
>
<Label htmlFor={`${testIdPrefix}-current`} className="font-normal cursor-pointer">
Use current selected branch
{currentBranch && (
<span className="text-muted-foreground ml-1">
({currentBranch})
</span>
)}
{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"
>
<Label htmlFor={`${testIdPrefix}-other`} className="font-normal cursor-pointer">
Other branch
</Label>
</div>
@@ -91,11 +81,10 @@ export function BranchSelector({
) : (
<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."}
? '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

@@ -1,8 +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";
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

@@ -1,66 +1,50 @@
import type { AgentModel, ThinkingLevel } from "@/store/app-store";
import {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
} from "lucide-react";
import type { 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";
provider: 'claude';
};
export const CLAUDE_MODELS: ModelOption[] = [
{
id: "haiku",
label: "Claude Haiku",
description: "Fast and efficient for simple tasks.",
badge: "Speed",
provider: "claude",
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: '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",
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_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",
none: 'None',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
// Profile icon mapping
export const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,

View File

@@ -1,9 +1,8 @@
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";
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;
@@ -14,7 +13,7 @@ interface ModelSelectorProps {
export function ModelSelector({
selectedModel,
onModelSelect,
testIdPrefix = "model-select",
testIdPrefix = 'model-select',
}: ModelSelectorProps) {
return (
<div className="space-y-3">
@@ -30,7 +29,7 @@ export function ModelSelector({
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
const shortName = option.label.replace("Claude ", "");
const shortName = option.label.replace('Claude ', '');
return (
<button
key={option.id}
@@ -38,10 +37,10 @@ export function ModelSelector({
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",
'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"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>

View File

@@ -1,20 +1,27 @@
"use client";
'use client';
import { useState } from "react";
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";
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";
export type { ParsedTask, PlanSpec } from '@/store/app-store';
interface PlanningModeSelectorProps {
mode: PlanningMode;
@@ -90,7 +97,7 @@ export function PlanningModeSelector({
compact = false,
}: PlanningModeSelectorProps) {
const [showPreview, setShowPreview] = useState(false);
const selectedMode = modes.find(m => m.value === mode);
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;
@@ -100,11 +107,13 @@ export function PlanningModeSelector({
{/* 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
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>
@@ -117,12 +126,7 @@ export function PlanningModeSelector({
{/* 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"
>
<Button variant="ghost" size="sm" onClick={onViewSpec} className="h-7 px-2">
<Eye className="h-3.5 w-3.5 mr-1" />
View
</Button>
@@ -131,12 +135,7 @@ export function PlanningModeSelector({
</div>
{/* Mode Selection Cards */}
<div
className={cn(
"grid gap-2",
compact ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-4"
)}
>
<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;
@@ -147,37 +146,45 @@ export function PlanningModeSelector({
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",
'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"
? 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
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"
)}>
<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"
)}>
<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>
)}
@@ -213,14 +220,16 @@ export function PlanningModeSelector({
{/* 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={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">
@@ -228,7 +237,9 @@ export function PlanningModeSelector({
{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>
<span className="text-sm text-muted-foreground">
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
</span>
</>
) : planSpec?.status === 'approved' ? (
<>
@@ -238,7 +249,9 @@ export function PlanningModeSelector({
) : 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>
<span className="text-sm text-amber-500 font-medium">
Spec Ready for Review
</span>
</>
) : (
<>
@@ -293,12 +306,7 @@ export function PlanningModeSelector({
{/* 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"
>
<Button variant="outline" size="sm" onClick={onRejectSpec} className="flex-1">
Request Changes
</Button>
<Button
@@ -315,12 +323,7 @@ export function PlanningModeSelector({
{/* 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"
>
<Button variant="ghost" size="sm" onClick={onGenerateSpec} className="h-7">
<RefreshCw className="h-3.5 w-3.5 mr-1" />
Regenerate
</Button>
@@ -334,7 +337,7 @@ export function PlanningModeSelector({
{!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 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>
)}

View File

@@ -1,6 +1,5 @@
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
interface PrioritySelectorProps {
selectedPriority: number;
@@ -11,7 +10,7 @@ interface PrioritySelectorProps {
export function PrioritySelector({
selectedPriority,
onPrioritySelect,
testIdPrefix = "priority",
testIdPrefix = 'priority',
}: PrioritySelectorProps) {
return (
<div className="space-y-2">
@@ -21,10 +20,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'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"
? '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`}
>
@@ -34,10 +33,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'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"
? '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`}
>
@@ -47,10 +46,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'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"
? '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`}
>

View File

@@ -1,9 +1,8 @@
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";
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[];
@@ -20,7 +19,7 @@ export function ProfileQuickSelect({
selectedModel,
selectedThinkingLevel,
onSelect,
testIdPrefix = "profile-quick-select",
testIdPrefix = 'profile-quick-select',
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
@@ -41,36 +40,30 @@ export function ProfileQuickSelect({
</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 IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isSelected =
selectedModel === profile.model &&
selectedThinkingLevel === profile.thinkingLevel;
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",
'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"
? '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" />
)}
{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}`}
{profile.thinkingLevel !== 'none' && ` + ${profile.thinkingLevel}`}
</p>
</div>
</button>
@@ -81,8 +74,8 @@ export function ProfileQuickSelect({
Or customize below.
{showManageLink && onManageLinkClick && (
<>
{" "}
Manage profiles in{" "}
{' '}
Manage profiles in{' '}
<button
type="button"
onClick={onManageLinkClick}

View File

@@ -1,9 +1,8 @@
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";
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;
@@ -18,9 +17,9 @@ export function TestingTabContent({
onSkipTestsChange,
steps,
onStepsChange,
testIdPrefix = "",
testIdPrefix = '',
}: TestingTabContentProps) {
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests";
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : 'skip-tests';
const handleStepChange = (index: number, value: string) => {
const newSteps = [...steps];
@@ -29,7 +28,7 @@ export function TestingTabContent({
};
const handleAddStep = () => {
onStepsChange([...steps, ""]);
onStepsChange([...steps, '']);
};
return (
@@ -39,7 +38,7 @@ export function TestingTabContent({
id={checkboxId}
checked={!skipTests}
onCheckedChange={(checked) => onSkipTestsChange(checked !== true)}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}skip-tests-checkbox`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}skip-tests-checkbox`}
/>
<div className="flex items-center gap-2">
<Label htmlFor={checkboxId} className="text-sm cursor-pointer">
@@ -49,8 +48,8 @@ export function TestingTabContent({
</div>
</div>
<p className="text-xs text-muted-foreground">
When enabled, this feature will use automated TDD. When disabled, it
will require manual verification.
When enabled, this feature will use automated TDD. When disabled, it will require manual
verification.
</p>
{/* Verification Steps - Only shown when skipTests is enabled */}
@@ -66,14 +65,14 @@ export function TestingTabContent({
value={step}
placeholder={`Verification step ${index + 1}`}
onChange={(e) => handleStepChange(index, e.target.value)}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}feature-step-${index}${testIdPrefix ? '' : '-input'}`}
/>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddStep}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}add-step-button`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}add-step-button`}
>
<Plus className="w-4 h-4 mr-2" />
Add Verification Step

View File

@@ -1,9 +1,8 @@
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";
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;
@@ -14,7 +13,7 @@ interface ThinkingLevelSelectorProps {
export function ThinkingLevelSelector({
selectedLevel,
onLevelSelect,
testIdPrefix = "thinking-level",
testIdPrefix = 'thinking-level',
}: ThinkingLevelSelectorProps) {
return (
<div className="space-y-2 pt-2 border-t border-border">
@@ -29,10 +28,10 @@ export function ThinkingLevelSelector({
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]",
'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"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${level}`}
>

View File

@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
@@ -8,16 +7,10 @@ import {
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";
} 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;
@@ -49,12 +42,12 @@ export function BranchSwitchDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
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"
'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"
>
@@ -88,7 +81,7 @@ export function BranchSwitchDropdown({
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (
<DropdownMenuItem disabled className="text-xs">
{branchFilter ? "No matching branches" : "No branches found"}
{branchFilter ? 'No matching branches' : 'No branches found'}
</DropdownMenuItem>
) : (
filteredBranches.map((branch) => (
@@ -109,10 +102,7 @@ export function BranchSwitchDropdown({
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<DropdownMenuItem onClick={() => onCreateBranch(worktree)} className="text-xs">
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>

View File

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

View File

@@ -1,5 +1,4 @@
import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -7,7 +6,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
import {
Trash2,
MoreHorizontal,
@@ -21,9 +20,9 @@ import {
Globe,
MessageSquare,
GitMerge,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo } from '../types';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
@@ -81,12 +80,12 @@ export function WorktreeActionsDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
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"
'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" />
@@ -99,10 +98,7 @@ export function WorktreeActionsDropdown({
<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"
>
<DropdownMenuItem onClick={() => onOpenDevServerUrl(worktree)} className="text-xs">
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
@@ -122,26 +118,15 @@ export function WorktreeActionsDropdown({
disabled={isStartingDevServer}
className="text-xs"
>
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
<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"}
<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
@@ -153,10 +138,8 @@ export function WorktreeActionsDropdown({
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
<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
@@ -173,10 +156,7 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree)}
className="text-xs"
>
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
@@ -199,7 +179,7 @@ export function WorktreeActionsDropdown({
<>
<DropdownMenuItem
onClick={() => {
window.open(worktree.pr!.url, "_blank");
window.open(worktree.pr!.url, '_blank');
}}
className="text-xs"
>
@@ -218,8 +198,8 @@ export function WorktreeActionsDropdown({
title: worktree.pr!.title,
url: worktree.pr!.url,
state: worktree.pr!.state,
author: "", // Will be fetched
body: "", // Will be fetched
author: '', // Will be fetched
body: '', // Will be fetched
comments: [],
reviewComments: [],
};

View File

@@ -1,16 +1,10 @@
import { Button } from "@/components/ui/button";
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
interface WorktreeTabProps {
worktree: WorktreeInfo;
@@ -91,49 +85,48 @@ export function WorktreeTab({
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? "open";
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
const prStateClasses = (() => {
// When selected (active tab), use high contrast solid background (paper-like)
if (isSelected) {
return "bg-background text-foreground border-transparent shadow-sm";
return 'bg-background text-foreground border-transparent shadow-sm';
}
// When not selected, use the colored variants
switch (prState) {
case "open":
case "reopened":
return "bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25";
case "draft":
return "bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25";
case "merged":
return "bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25";
case "closed":
return "bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25";
case 'open':
case 'reopened':
return 'bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25';
case 'draft':
return 'bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25';
case 'merged':
return 'bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25';
case 'closed':
return 'bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25';
default:
return "bg-muted text-muted-foreground border-border/60 hover:bg-muted/80";
return 'bg-muted text-muted-foreground border-border/60 hover:bg-muted/80';
}
})();
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`;
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ''}`;
// Helper to get status icon color for the selected state
const getStatusColorClass = () => {
if (!isSelected) return "";
if (!isSelected) return '';
switch (prState) {
case "open":
case "reopened":
return "text-emerald-600 dark:text-emerald-500";
case "draft":
return "text-amber-600 dark:text-amber-500";
case "merged":
return "text-purple-600 dark:text-purple-500";
case "closed":
return "text-rose-600 dark:text-rose-500";
case 'open':
case 'reopened':
return 'text-emerald-600 dark:text-emerald-500';
case 'draft':
return 'text-amber-600 dark:text-amber-500';
case 'merged':
return 'text-purple-600 dark:text-purple-500';
case 'closed':
return 'text-rose-600 dark:text-rose-500';
default:
return "text-muted-foreground";
return 'text-muted-foreground';
}
};
@@ -142,9 +135,9 @@ export function WorktreeTab({
role="button"
tabIndex={0}
className={cn(
"ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
"cursor-pointer hover:opacity-80 active:opacity-70",
'ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background',
'cursor-pointer hover:opacity-80 active:opacity-70',
prStateClasses
)}
title={`${prLabel} - Click to open`}
@@ -152,25 +145,25 @@ export function WorktreeTab({
onClick={(e) => {
e.stopPropagation(); // Prevent triggering worktree selection
if (worktree.pr?.url) {
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
window.open(worktree.pr.url, '_blank', 'noopener,noreferrer');
}
}}
onKeyDown={(e) => {
// Prevent event from bubbling to parent button
e.stopPropagation();
if (e.key === "Enter" || e.key === " ") {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (worktree.pr?.url) {
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
window.open(worktree.pr.url, '_blank', 'noopener,noreferrer');
}
}
}}
>
<GitPullRequest className={cn("w-3 h-3", getStatusColorClass())} aria-hidden="true" />
<span aria-hidden="true" className={isSelected ? "text-foreground font-semibold" : ""}>
<GitPullRequest className={cn('w-3 h-3', getStatusColorClass())} aria-hidden="true" />
<span aria-hidden="true" className={isSelected ? 'text-foreground font-semibold' : ''}>
PR #{worktree.pr.number}
</span>
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
<span className={cn('capitalize', getStatusColorClass())} aria-hidden="true">
{prState}
</span>
</span>
@@ -182,12 +175,12 @@ export function WorktreeTab({
{worktree.isMain ? (
<>
<Button
variant={isSelected ? "default" : "outline"}
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"
'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}
@@ -196,9 +189,7 @@ export function WorktreeTab({
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw 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">
@@ -209,18 +200,23 @@ export function WorktreeTab({
<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"
)}>
<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 ?? "!"}
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -243,26 +239,24 @@ export function WorktreeTab({
</>
) : (
<Button
variant={isSelected ? "default" : "outline"}
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"
'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"
: '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" />
)}
{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">
@@ -273,18 +267,23 @@ export function WorktreeTab({
<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"
)}>
<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 ?? "!"}
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -295,13 +294,13 @@ export function WorktreeTab({
{isDevServerRunning && (
<Button
variant={isSelected ? "default" : "outline"}
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"
'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})`}

View File

@@ -1,6 +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";
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

@@ -1,21 +1,20 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import type { BranchInfo } from "../types";
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 [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");
console.warn('List branches API not available');
return;
}
const result = await api.worktree.listBranches(worktreePath);
@@ -25,14 +24,14 @@ export function useBranches() {
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error("Failed to fetch branches:", error);
console.error('Failed to fetch branches:', error);
} finally {
setIsLoadingBranches(false);
}
}, []);
const resetBranchFilter = useCallback(() => {
setBranchFilter("");
setBranchFilter('');
}, []);
const filteredBranches = branches.filter((b) =>

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useState, useEffect, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
export function useDefaultEditor() {
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const [defaultEditorName, setDefaultEditorName] = useState<string>('Editor');
const fetchDefaultEditor = useCallback(async () => {
try {
@@ -16,7 +15,7 @@ export function useDefaultEditor() {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error("Failed to fetch default editor:", error);
console.error('Failed to fetch default editor:', error);
}
}, []);

View File

@@ -1,9 +1,8 @@
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";
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;
@@ -11,9 +10,7 @@ interface UseDevServersOptions {
export function useDevServers({ projectPath }: UseDevServersOptions) {
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
new Map()
);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
const fetchDevServers = useCallback(async () => {
try {
@@ -30,7 +27,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
console.error('Failed to fetch dev servers:', error);
}
}, []);
@@ -54,7 +51,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
toast.error('Start dev server API not available');
return;
}
@@ -73,11 +70,11 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
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");
console.error('Start dev server failed:', error);
toast.error('Failed to start dev server');
} finally {
setIsStartingDevServer(false);
}
@@ -90,7 +87,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
toast.error('Stop dev server API not available');
return;
}
@@ -103,13 +100,13 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
toast.success(result.result?.message || 'Dev server stopped');
} else {
toast.error(result.error || "Failed to stop dev server");
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");
console.error('Stop dev server failed:', error);
toast.error('Failed to stop dev server');
}
},
[projectPath]
@@ -120,7 +117,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
window.open(serverInfo.url, '_blank');
}
},
[projectPath, runningDevServers]

View File

@@ -1,16 +1,12 @@
import { useCallback } from "react";
import type { WorktreeInfo, FeatureInfo } from "../types";
import { useCallback } from 'react';
import type { WorktreeInfo, FeatureInfo } from '../types';
interface UseRunningFeaturesOptions {
runningFeatureIds: string[];
features: FeatureInfo[];
}
export function useRunningFeatures({
runningFeatureIds,
features,
}: UseRunningFeaturesOptions) {
export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFeaturesOptions) {
const hasRunningFeatures = useCallback(
(worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;

View File

@@ -1,18 +1,14 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
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) {
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
@@ -25,7 +21,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error("Switch branch API not available");
toast.error('Switch branch API not available');
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
@@ -33,11 +29,11 @@ export function useWorktreeActions({
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
toast.error(result.error || 'Failed to switch branch');
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
console.error('Switch branch failed:', error);
toast.error('Failed to switch branch');
} finally {
setIsSwitching(false);
}
@@ -52,7 +48,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error("Pull API not available");
toast.error('Pull API not available');
return;
}
const result = await api.worktree.pull(worktree.path);
@@ -60,11 +56,11 @@ export function useWorktreeActions({
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to pull latest changes");
toast.error(result.error || 'Failed to pull latest changes');
}
} catch (error) {
console.error("Pull failed:", error);
toast.error("Failed to pull latest changes");
console.error('Pull failed:', error);
toast.error('Failed to pull latest changes');
} finally {
setIsPulling(false);
}
@@ -79,7 +75,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error("Push API not available");
toast.error('Push API not available');
return;
}
const result = await api.worktree.push(worktree.path);
@@ -88,11 +84,11 @@ export function useWorktreeActions({
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to push changes");
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {
console.error("Push failed:", error);
toast.error("Failed to push changes");
console.error('Push failed:', error);
toast.error('Failed to push changes');
} finally {
setIsPushing(false);
}
@@ -104,7 +100,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
console.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path);
@@ -114,7 +110,7 @@ export function useWorktreeActions({
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
console.error('Open in editor failed:', error);
}
}, []);

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import type { WorktreeInfo } from "../types";
import { useState, useEffect, useCallback, useRef } 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;
@@ -11,7 +10,11 @@ interface UseWorktreesOptions {
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
}
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
export function useWorktrees({
projectPath,
refreshTrigger = 0,
onRemovedWorktrees,
}: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
@@ -20,34 +23,37 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async (options?: { silent?: boolean }) => {
if (!projectPath) return;
const silent = options?.silent ?? false;
if (!silent) {
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 {
const fetchWorktrees = useCallback(
async (options?: { silent?: boolean }) => {
if (!projectPath) return;
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(false);
setIsLoading(true);
}
}
}, [projectPath, setWorktreesInStore]);
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 {
if (!silent) {
setIsLoading(false);
}
}
},
[projectPath, setWorktreesInStore]
);
useEffect(() => {
fetchWorktrees();
@@ -77,15 +83,16 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
if (worktrees.length > 0) {
const current = currentWorktreeRef.current;
const currentPath = current?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
const currentWorktreeExists =
currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (current == 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";
const mainBranch = mainWorktree?.branch || 'main';
setCurrentWorktree(projectPath, null, mainBranch);
}
}
@@ -93,11 +100,7 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
},
[projectPath, setCurrentWorktree]
);

View File

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

View File

@@ -1,15 +1,9 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
GitBranch,
Plus,
RefreshCw,
PanelLeftOpen,
PanelLeftClose,
} from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import { getItem, setItem } from "@/lib/storage";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { getItem, setItem } from '@/lib/storage';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
useDevServers,
@@ -17,10 +11,10 @@ import {
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from "./hooks";
import { WorktreeTab } from "./components";
} from './hooks';
import { WorktreeTab } from './components';
const WORKTREE_PANEL_COLLAPSED_KEY = "worktree-panel-collapsed";
const WORKTREE_PANEL_COLLAPSED_KEY = 'worktree-panel-collapsed';
export function WorktreePanel({
projectPath,
@@ -93,7 +87,7 @@ export function WorktreePanel({
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === "true";
return saved === 'true';
});
useEffect(() => {
@@ -102,12 +96,13 @@ export function WorktreePanel({
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
// Periodic interval check (1 second) to detect branch changes on disk
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchWorktrees({ silent: true });
}, 1000);
}, 5000);
return () => {
if (intervalRef.current) {
@@ -130,26 +125,22 @@ export function WorktreePanel({
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
};
const handleBranchDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleActionsDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -169,12 +160,10 @@ export function WorktreePanel({
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Branch:</span>
<span className="text-sm font-mono font-medium">
{selectedWorktree?.branch ?? "main"}
</span>
<span className="text-sm font-mono font-medium">{selectedWorktree?.branch ?? 'main'}</span>
{selectedWorktree?.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30">
{selectedWorktree.changedFilesCount ?? "!"}
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
</div>
@@ -222,12 +211,8 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
mainWorktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
mainWorktree
)}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
@@ -280,12 +265,8 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
worktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
worktree
)}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
@@ -320,20 +301,14 @@ export function WorktreePanel({
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
) {
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")}
/>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</div>
</>

View File

@@ -1,8 +1,7 @@
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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,
@@ -12,16 +11,16 @@ import {
Search,
ChevronLeft,
ArchiveRestore,
} from "lucide-react";
import { cn } from "@/lib/utils";
} 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";
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
export function ChatHistory() {
const {
@@ -37,7 +36,7 @@ export function ChatHistory() {
setChatHistoryOpen,
} = useAppStore();
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState('');
const [showArchived, setShowArchived] = useState(false);
if (!currentProject) {
@@ -45,18 +44,12 @@ export function ChatHistory() {
}
// Filter sessions for current project
const projectSessions = chatSessions.filter(
(session) => session.projectId === currentProject.id
);
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;
const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
return matchesSearch && matchesArchivedStatus;
});
@@ -85,7 +78,7 @@ export function ChatHistory() {
const handleDeleteSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this chat session?")) {
if (confirm('Are you sure you want to delete this chat session?')) {
deleteChatSession(sessionId);
}
};
@@ -93,8 +86,8 @@ export function ChatHistory() {
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"
'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 && (
@@ -105,11 +98,7 @@ export function ChatHistory() {
<MessageSquare className="w-5 h-5" />
<h2 className="font-semibold">Chat History</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setChatHistoryOpen(false)}
>
<Button variant="ghost" size="sm" onClick={() => setChatHistoryOpen(false)}>
<ChevronLeft className="w-4 h-4" />
</Button>
</div>
@@ -152,7 +141,7 @@ export function ChatHistory() {
) : (
<Archive className="w-4 h-4" />
)}
{showArchived ? "Show Active" : "Show Archived"}
{showArchived ? 'Show Active' : 'Show Archived'}
{showArchived && (
<Badge variant="outline" className="ml-auto">
{projectSessions.filter((s) => s.archived).length}
@@ -179,15 +168,13 @@ export function ChatHistory() {
<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"
'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>
<h3 className="font-medium text-sm truncate">{session.title}</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
@@ -199,30 +186,20 @@ export function ChatHistory() {
<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"
>
<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)
}
onClick={(e) => handleUnarchiveSession(session.id, e)}
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) =>
handleArchiveSession(session.id, e)
}
>
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>

View File

@@ -1,19 +1,10 @@
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";
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;
@@ -23,19 +14,11 @@ interface FileTreeNode {
isExpanded?: boolean;
}
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
];
const IGNORE_PATTERNS = ['node_modules', '.git', '.next', 'dist', 'build', '.DS_Store', '*.log'];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
if (pattern.startsWith('*')) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
@@ -46,11 +29,9 @@ export function CodeView() {
const { currentProject } = useAppStore();
const [fileTree, setFileTree] = useState<FileTreeNode[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [fileContent, setFileContent] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Load directory tree
const loadTree = useCallback(async () => {
@@ -79,7 +60,7 @@ export function CodeView() {
setFileTree(entries);
}
} catch (error) {
console.error("Failed to load file tree:", error);
console.error('Failed to load file tree:', error);
} finally {
setIsLoading(false);
}
@@ -110,7 +91,7 @@ export function CodeView() {
}));
}
} catch (error) {
console.error("Failed to load subdirectory:", error);
console.error('Failed to load subdirectory:', error);
}
return [];
};
@@ -126,7 +107,7 @@ export function CodeView() {
setSelectedFile(path);
}
} catch (error) {
console.error("Failed to load file:", error);
console.error('Failed to load file:', error);
}
};
@@ -170,8 +151,8 @@ export function CodeView() {
<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"
'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={() => {
@@ -205,9 +186,7 @@ export function CodeView() {
<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>{node.children.map((child) => renderNode(child, depth + 1))}</div>
)}
</div>
);
@@ -215,10 +194,7 @@ export function CodeView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-no-project"
>
<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>
);
@@ -226,37 +202,24 @@ export function CodeView() {
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-loading"
>
<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"
>
<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>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={loadTree}
data-testid="refresh-tree"
>
<Button variant="outline" size="sm" onClick={loadTree} data-testid="refresh-tree">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
@@ -275,7 +238,7 @@ export function CodeView() {
<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, "")}
{selectedFile.replace(currentProject.path, '')}
</p>
</div>
<Card className="flex-1 m-4 overflow-hidden">
@@ -288,9 +251,7 @@ export function CodeView() {
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">
Select a file to view its contents
</p>
<p className="text-muted-foreground">Select a file to view its contents</p>
</div>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,20 @@
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";
import {
getDefaultWorkspaceDirectory,
saveLastProjectDirectory,
} from "@/lib/workspace-config";
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';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
interface InterviewMessage {
id: string;
role: "user" | "assistant";
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
@@ -42,28 +30,28 @@ interface InterviewState {
// 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: '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: '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: '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,
id: 'additional',
question: 'Any additional requirements or preferences?',
hint: 'Design preferences, integrations, deployment needs, etc.',
field: 'additionalNotes' as const,
},
];
@@ -71,21 +59,21 @@ export function InterviewView() {
const { addProject, setCurrentProject, setAppSpec } = useAppStore();
const { openFileBrowser } = useFileBrowser();
const navigate = useNavigate();
const [input, setInput] = useState("");
const [input, setInput] = useState('');
const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [interviewData, setInterviewData] = useState<InterviewState>({
projectName: "",
projectDescription: "",
projectName: '',
projectDescription: '',
techStack: [],
features: [],
additionalNotes: "",
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 [projectPath, setProjectPath] = useState('');
const [projectName, setProjectName] = useState('');
const [showProjectSetup, setShowProjectSetup] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -109,7 +97,7 @@ export function InterviewView() {
setProjectPath(defaultDir);
}
} catch (error) {
console.error("Failed to load default workspace directory:", error);
console.error('Failed to load default workspace directory:', error);
}
};
@@ -124,8 +112,8 @@ export function InterviewView() {
useEffect(() => {
if (messages.length === 0) {
const welcomeMessage: InterviewMessage = {
id: "welcome",
role: "assistant",
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(),
};
@@ -142,7 +130,7 @@ export function InterviewView() {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
behavior: 'smooth',
});
}
}, 100);
@@ -166,7 +154,7 @@ export function InterviewView() {
const userMessage: InterviewMessage = {
id: `user-${Date.now()}`,
role: "user",
role: 'user',
content: input,
timestamp: new Date(),
};
@@ -178,25 +166,20 @@ export function InterviewView() {
if (currentQuestion) {
setInterviewData((prev) => {
const newData = { ...prev };
if (
currentQuestion.field === "techStack" ||
currentQuestion.field === "features"
) {
if (currentQuestion.field === 'techStack' || currentQuestion.field === 'features') {
// Parse comma-separated values into array
newData[currentQuestion.field] = input
.split(",")
.split(',')
.map((s) => s.trim())
.filter(Boolean);
} else {
(newData as Record<string, string | string[]>)[
currentQuestion.field
] = input;
(newData as Record<string, string | string[]>)[currentQuestion.field] = input;
}
return newData;
});
}
setInput("");
setInput('');
// Move to next question or complete
const nextIndex = currentQuestionIndex + 1;
@@ -206,7 +189,7 @@ export function InterviewView() {
const nextQuestion = INTERVIEW_QUESTIONS[nextIndex];
const assistantMessage: InterviewMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
role: 'assistant',
content: `Great! **${nextQuestion.question}**\n\n_${nextQuestion.hint}_`,
timestamp: new Date(),
};
@@ -216,34 +199,30 @@ export function InterviewView() {
// All questions answered - generate spec
const summaryMessage: InterviewMessage = {
id: `assistant-summary-${Date.now()}`,
role: "assistant",
role: 'assistant',
content:
"Perfect! I have all the information I need. Now let me generate your project specification...",
'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,
projectDescription: currentQuestionIndex === 0 ? input : interviewData.projectDescription,
techStack:
currentQuestionIndex === 1
? input
.split(",")
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: interviewData.techStack,
features:
currentQuestionIndex === 2
? input
.split(",")
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: interviewData.features,
additionalNotes:
currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
additionalNotes: currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
});
}
}, 500);
@@ -265,7 +244,7 @@ export function InterviewView() {
const completionMessage: InterviewMessage = {
id: `assistant-complete-${Date.now()}`,
role: "assistant",
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(),
};
@@ -274,15 +253,15 @@ export function InterviewView() {
const generateAppSpec = (data: InterviewState): string => {
const projectName = data.projectDescription
.split(" ")
.split(' ')
.slice(0, 3)
.join("-")
.join('-')
.toLowerCase()
.replace(/[^a-z0-9-]/g, "");
.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>
<project_name>${projectName || 'my-project'}</project_name>
<overview>
${data.projectDescription}
@@ -291,25 +270,21 @@ export function InterviewView() {
<technology_stack>
${
data.techStack.length > 0
? data.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")
: "<!-- Define your tech stack -->"
? 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 -->"
? data.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')
: '<!-- List core features -->'
}
</core_capabilities>
<additional_requirements>
${data.additionalNotes || "None specified"}
${data.additionalNotes || 'None specified'}
</additional_requirements>
<development_guidelines>
@@ -323,9 +298,8 @@ export function InterviewView() {
const handleSelectDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Directory",
description:
"Choose the parent directory where your new project will be created",
title: 'Select Base Directory',
description: 'Choose the parent directory where your new project will be created',
initialPath: projectPath || undefined,
});
@@ -345,45 +319,42 @@ export function InterviewView() {
const api = getElectronAPI();
// Use platform-specific path separator
const pathSep =
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
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",
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
);
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,
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",
'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");
throw new Error('Features API not available');
}
await api.features.create(fullProjectPath, initialFeature);
@@ -401,27 +372,24 @@ export function InterviewView() {
addProject(project);
setCurrentProject(project);
} catch (error) {
console.error("Failed to create project:", error);
console.error('Failed to create project:', error);
setIsGenerating(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleGoBack = () => {
navigate({ to: "/" });
navigate({ to: '/' });
};
return (
<div
className="flex-1 flex flex-col content-bg min-h-0"
data-testid="interview-view"
>
<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">
@@ -439,10 +407,8 @@ export function InterviewView() {
<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
}`}
? 'Specification generated!'
: `Question ${currentQuestionIndex + 1} of ${INTERVIEW_QUESTIONS.length}`}
</p>
</div>
</div>
@@ -453,18 +419,16 @@ export function InterviewView() {
<div
key={index}
className={cn(
"w-2 h-2 rounded-full transition-colors",
'w-2 h-2 rounded-full transition-colors',
index < currentQuestionIndex
? "bg-green-500"
? 'bg-green-500'
: index === currentQuestionIndex
? "bg-primary"
: "bg-zinc-700"
? 'bg-primary'
: 'bg-zinc-700'
)}
/>
))}
{isComplete && (
<CheckCircle className="w-4 h-4 text-green-500 ml-2" />
)}
{isComplete && <CheckCircle className="w-4 h-4 text-green-500 ml-2" />}
</div>
</div>
@@ -477,18 +441,15 @@ export function InterviewView() {
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" && "flex-row-reverse"
)}
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"
'w-8 h-8 rounded-full flex items-center justify-center shrink-0',
message.role === 'assistant' ? 'bg-primary/10' : 'bg-muted'
)}
>
{message.role === "assistant" ? (
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4" />
@@ -496,28 +457,24 @@ export function InterviewView() {
</div>
<Card
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-transparent border border-primary text-foreground"
: "border border-primary/30 bg-card"
'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" ? (
{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="text-sm whitespace-pre-wrap">{message.content}</p>
)}
<p
className={cn(
"text-xs mt-1",
message.role === "user"
? "text-muted-foreground"
: "text-primary/70"
'text-xs mt-1',
message.role === 'user' ? 'text-muted-foreground' : 'text-primary/70'
)}
>
{message.timestamp.toLocaleTimeString()}
@@ -536,9 +493,7 @@ export function InterviewView() {
<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>
<span className="text-sm text-primary">Generating specification...</span>
</div>
</CardContent>
</Card>
@@ -548,10 +503,7 @@ export function InterviewView() {
{/* Project Setup Form */}
{showProjectSetup && (
<div className="mt-6">
<Card
className="bg-zinc-900/50 border-white/10"
data-testid="project-setup-form"
>
<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" />
@@ -560,10 +512,7 @@ export function InterviewView() {
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="project-name"
className="text-sm font-medium text-zinc-300"
>
<label htmlFor="project-name" className="text-sm font-medium text-zinc-300">
Project Name
</label>
<Input
@@ -577,10 +526,7 @@ export function InterviewView() {
</div>
<div className="space-y-2">
<label
htmlFor="project-path"
className="text-sm font-medium text-zinc-300"
>
<label htmlFor="project-path" className="text-sm font-medium text-zinc-300">
Parent Directory
</label>
<div className="flex gap-2">

View File

@@ -1,24 +1,20 @@
import { useState, useMemo, useCallback } from "react";
import {
useAppStore,
AIProfile,
} from "@/store/app-store";
import { useState, useMemo, useCallback } from 'react';
import { useAppStore, AIProfile } from '@/store/app-store';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
} 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";
} from '@/components/ui/dialog';
import { Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import {
DndContext,
DragEndEvent,
@@ -26,16 +22,9 @@ import {
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
SortableProfileCard,
ProfileForm,
ProfilesHeader,
} from "./profiles-view/components";
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProfileCard, ProfileForm, ProfilesHeader } from './profiles-view/components';
export function ProfilesView() {
const {
@@ -62,14 +51,8 @@ export function ProfilesView() {
);
// 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 builtInProfiles = useMemo(() => aiProfiles.filter((p) => p.isBuiltIn), [aiProfiles]);
const customProfiles = useMemo(() => aiProfiles.filter((p) => !p.isBuiltIn), [aiProfiles]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
@@ -87,19 +70,19 @@ export function ProfilesView() {
[aiProfiles, reorderAIProfiles]
);
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
const handleAddProfile = (profile: Omit<AIProfile, 'id'>) => {
addAIProfile(profile);
setShowAddDialog(false);
toast.success("Profile created", {
toast.success('Profile created', {
description: `Created "${profile.name}" profile`,
});
};
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
const handleUpdateProfile = (profile: Omit<AIProfile, 'id'>) => {
if (editingProfile) {
updateAIProfile(editingProfile.id, profile);
setEditingProfile(null);
toast.success("Profile updated", {
toast.success('Profile updated', {
description: `Updated "${profile.name}" profile`,
});
}
@@ -109,7 +92,7 @@ export function ProfilesView() {
if (!profileToDelete) return;
removeAIProfile(profileToDelete.id);
toast.success("Profile deleted", {
toast.success('Profile deleted', {
description: `Deleted "${profileToDelete.name}" profile`,
});
setProfileToDelete(null);
@@ -117,8 +100,8 @@ export function ProfilesView() {
const handleResetProfiles = () => {
resetAIProfiles();
toast.success("Profiles refreshed", {
description: "Default profiles have been updated to the latest version",
toast.success('Profiles refreshed', {
description: 'Default profiles have been updated to the latest version',
});
};
@@ -130,7 +113,7 @@ export function ProfilesView() {
shortcutsList.push({
key: shortcuts.addProfile,
action: () => setShowAddDialog(true),
description: "Create new profile",
description: 'Create new profile',
});
return shortcutsList;
@@ -140,10 +123,7 @@ export function ProfilesView() {
useKeyboardShortcuts(profilesShortcuts);
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="profiles-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="profiles-view">
{/* Header Section */}
<ProfilesHeader
onResetProfiles={handleResetProfiles}
@@ -157,9 +137,7 @@ export function ProfilesView() {
{/* Custom Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Custom Profiles
</h2>
<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>
@@ -202,16 +180,13 @@ export function ProfilesView() {
{/* 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>
<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.
Pre-configured profiles for common use cases. These cannot be edited or deleted.
</p>
<DndContext
sensors={sensors}
@@ -240,12 +215,13 @@ export function ProfilesView() {
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent data-testid="add-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
<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>
<DialogDescription>Define a reusable model configuration preset.</DialogDescription>
</DialogHeader>
<ProfileForm
profile={{}}
@@ -258,11 +234,11 @@ export function ProfilesView() {
</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)]">
<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>
@@ -288,7 +264,7 @@ export function ProfilesView() {
description={
profileToDelete
? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.`
: ""
: ''
}
confirmText="Delete Profile"
testId="delete-profile-confirm-dialog"

View File

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

View File

@@ -1,21 +1,20 @@
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";
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;
onSave: (profile: Omit<AIProfile, 'id'>) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
@@ -29,11 +28,11 @@ export function ProfileForm({
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",
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);
@@ -48,7 +47,7 @@ export function ProfileForm({
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error("Please enter a profile name");
toast.error('Please enter a profile name');
return;
}
@@ -56,7 +55,7 @@ export function ProfileForm({
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none',
provider,
isBuiltIn: false,
icon: formData.icon,
@@ -84,9 +83,7 @@ export function ProfileForm({
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
@@ -103,10 +100,10 @@ export function ProfileForm({
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
'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"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`icon-select-${name}`}
>
@@ -129,14 +126,14 @@ export function ProfileForm({
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",
'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"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`model-select-${id}`}
>
{label.replace("Claude ", "")}
{label.replace('Claude ', '')}
</button>
))}
</div>
@@ -156,19 +153,19 @@ export function ProfileForm({
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === "ultrathink") {
toast.warning("Ultrathink uses extensive reasoning", {
if (id === 'ultrathink') {
toast.warning('Ultrathink uses extensive reasoning', {
description:
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
'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",
'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"
? 'bg-amber-500 text-white border-amber-400'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`thinking-select-${id}`}
>
@@ -190,14 +187,13 @@ export function ProfileForm({
</Button>
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? "Save Changes" : "Create Profile"}
{isEditing ? 'Save Changes' : 'Create Profile'}
</HotkeyButton>
</DialogFooter>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { UserCircle, Plus, RefreshCw } from "lucide-react";
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { UserCircle, Plus, RefreshCw } from 'lucide-react';
interface ProfilesHeaderProps {
onResetProfiles: () => void;
@@ -22,9 +22,7 @@ export function ProfilesHeader({
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Profiles
</h1>
<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>
@@ -55,4 +53,3 @@ export function ProfilesHeader({
</div>
);
}

View File

@@ -1,10 +1,10 @@
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";
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;
@@ -12,19 +12,10 @@ interface SortableProfileCardProps {
onDelete: () => void;
}
export function SortableProfileCard({
profile,
onEdit,
onDelete,
}: SortableProfileCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: profile.id });
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),
@@ -39,11 +30,11 @@ export function SortableProfileCard({
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",
'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"
? 'border-border/50'
: 'border-border hover:border-primary/50 hover:shadow-sm'
)}
data-testid={`profile-card-${profile.id}`}
>
@@ -60,12 +51,8 @@ export function SortableProfileCard({
</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 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 */}
@@ -79,16 +66,12 @@ export function SortableProfileCard({
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{profile.description}
</p>
<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"
>
<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" && (
{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>
@@ -124,4 +107,3 @@ export function SortableProfileCard({
</div>
);
}

View File

@@ -1,18 +1,8 @@
import {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
} from "lucide-react";
import type { AgentModel, ThinkingLevel } from "@/store/app-store";
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 }>
> = {
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,
@@ -23,27 +13,25 @@ export const PROFILE_ICONS: Record<
// 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 },
{ 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" },
{ 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" },
{ id: 'none', label: 'None' },
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'ultrathink', label: 'Ultrathink' },
];

View File

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

View File

@@ -1,11 +1,10 @@
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";
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[]>([]);
@@ -24,7 +23,7 @@ export function RunningAgentsView() {
}
}
} catch (error) {
console.error("[RunningAgentsView] Error fetching running agents:", error);
console.error('[RunningAgentsView] Error fetching running agents:', error);
} finally {
setLoading(false);
setRefreshing(false);
@@ -52,10 +51,7 @@ export function RunningAgentsView() {
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"
) {
if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
fetchRunningAgents();
}
});
@@ -70,27 +66,33 @@ export function RunningAgentsView() {
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();
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);
}
} catch (error) {
console.error("[RunningAgentsView] Error stopping agent:", error);
}
}, [fetchRunningAgents]);
},
[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]);
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 (
@@ -112,20 +114,13 @@ export function RunningAgentsView() {
<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`}
? '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")}
/>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
Refresh
</Button>
</div>
@@ -138,8 +133,8 @@ export function RunningAgentsView() {
</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".
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>
) : (
@@ -163,9 +158,7 @@ export function RunningAgentsView() {
{/* Agent info */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">
{agent.featureId}
</span>
<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

View File

@@ -1,27 +1,24 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
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 { ClaudeUsageSection } from "./settings-view/api-keys/claude-usage-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";
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 { ClaudeUsageSection } from './settings-view/api-keys/claude-usage-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 { TerminalSection } from './settings-view/terminal/terminal-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 {
@@ -52,13 +49,12 @@ export function SettingsView() {
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
// Convert electron Project to settings-view Project type
const convertProject = (
project: ElectronProject | null
): SettingsProject | null => {
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
if (!project) return null;
return {
id: project.id,
@@ -85,8 +81,7 @@ export function SettingsView() {
};
// Use CLI status hook
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } =
useCliStatus();
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } = useCliStatus();
// Use settings view navigation hook
const { activeView, navigateTo } = useSettingsView();
@@ -97,7 +92,7 @@ export function SettingsView() {
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
case "claude":
case 'claude':
return (
<div className="space-y-6">
<ClaudeCliStatus
@@ -108,9 +103,9 @@ export function SettingsView() {
{showUsageTracking && <ClaudeUsageSection />}
</div>
);
case "ai-enhancement":
case 'ai-enhancement':
return <AIEnhancementSection />;
case "appearance":
case 'appearance':
return (
<AppearanceSection
effectiveTheme={effectiveTheme}
@@ -118,20 +113,17 @@ export function SettingsView() {
onThemeChange={handleSetTheme}
/>
);
case "keyboard":
case 'terminal':
return <TerminalSection />;
case 'keyboard':
return (
<KeyboardShortcutsSection
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
/>
<KeyboardShortcutsSection onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)} />
);
case "audio":
case 'audio':
return (
<AudioSection
muteDoneSound={muteDoneSound}
onMuteDoneSoundChange={setMuteDoneSound}
/>
<AudioSection muteDoneSound={muteDoneSound} onMuteDoneSoundChange={setMuteDoneSound} />
);
case "defaults":
case 'defaults':
return (
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
@@ -151,7 +143,7 @@ export function SettingsView() {
onDefaultAIProfileIdChange={setDefaultAIProfileId}
/>
);
case "danger":
case 'danger':
return (
<DangerZoneSection
project={settingsProject}
@@ -164,10 +156,7 @@ export function SettingsView() {
};
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="settings-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
{/* Header Section */}
<SettingsHeader />
@@ -188,10 +177,7 @@ export function SettingsView() {
</div>
{/* Keyboard Map Dialog */}
<KeyboardMapDialog
open={showKeyboardMapDialog}
onOpenChange={setShowKeyboardMapDialog}
/>
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog

View File

@@ -1,8 +1,8 @@
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";
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();
@@ -10,10 +10,10 @@ export function AIEnhancementSection() {
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"
'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">
@@ -29,9 +29,7 @@ export function AIEnhancementSection() {
</div>
<div className="p-6 space-y-4">
<div className="space-y-4">
<Label className="text-foreground font-medium">
Enhancement Model
</Label>
<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;
@@ -40,46 +38,48 @@ export function AIEnhancementSection() {
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",
'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-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",
'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]"
'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"
)}>
<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"
)}>
<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>
<span className="text-xs text-muted-foreground/80">{description}</span>
</button>
);
})}

View File

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

View File

@@ -1,8 +1,8 @@
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";
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;
@@ -42,7 +42,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
<div className="relative flex-1">
<Input
id={inputId}
type={showValue ? "text" : "password"}
type={showValue ? 'text' : 'password'}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
@@ -82,7 +82,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
</Button>
</div>
<p className="text-xs text-muted-foreground">
{descriptionPrefix}{" "}
{descriptionPrefix}{' '}
<a
href={descriptionLinkHref}
target="_blank"
@@ -97,8 +97,8 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
<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"
? '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}
>

View File

@@ -1,17 +1,17 @@
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";
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();
@@ -19,8 +19,7 @@ export function ApiKeysSection() {
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
const navigate = useNavigate();
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
useApiKeyManagement();
const { providerConfigParams, apiKeyStatus, handleSave, saved } = useApiKeyManagement();
const providerConfigs = buildProviderConfigs(providerConfigParams);
@@ -30,24 +29,24 @@ export function ApiKeysSection() {
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error("Delete API not available");
toast.error('Delete API not available');
return;
}
const result = await api.setup.deleteApiKey("anthropic");
const result = await api.setup.deleteApiKey('anthropic');
if (result.success) {
setApiKeys({ ...apiKeys, anthropic: "" });
setApiKeys({ ...apiKeys, anthropic: '' });
setClaudeAuthStatus({
authenticated: false,
method: "none",
method: 'none',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
toast.success("Anthropic API key deleted");
toast.success('Anthropic API key deleted');
} else {
toast.error(result.error || "Failed to delete API key");
toast.error(result.error || 'Failed to delete API key');
}
} catch (error) {
toast.error("Failed to delete API key");
toast.error('Failed to delete API key');
} finally {
setIsDeletingAnthropicKey(false);
}
@@ -56,16 +55,16 @@ export function ApiKeysSection() {
// Open setup wizard
const openSetupWizard = useCallback(() => {
setSetupComplete(false);
navigate({ to: "/setup" });
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"
'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">
@@ -101,13 +100,13 @@ export function ApiKeysSection() {
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]"
'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 ? (
@@ -116,7 +115,7 @@ export function ApiKeysSection() {
Saved!
</>
) : (
"Save API Keys"
'Save API Keys'
)}
</Button>

View File

@@ -1,12 +1,6 @@
import { Label } from "@/components/ui/label";
import {
CheckCircle2,
AlertCircle,
Info,
Terminal,
Sparkles,
} from "lucide-react";
import type { ClaudeAuthStatus } from "@/store/setup-store";
import { Label } from '@/components/ui/label';
import { CheckCircle2, AlertCircle, Info, Terminal } from 'lucide-react';
import type { ClaudeAuthStatus } from '@/store/setup-store';
interface AuthenticationStatusDisplayProps {
claudeAuthStatus: ClaudeAuthStatus | null;
@@ -39,35 +33,29 @@ export function AuthenticationStatusDisplay({
<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>
<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>
<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`}
{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>
</>

View File

@@ -1,13 +1,13 @@
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
export function ClaudeUsageSection() {
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"
'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">
@@ -15,7 +15,9 @@ export function ClaudeUsageSection() {
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<div className="w-5 h-5 rounded-full bg-green-500/50" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Claude Usage Tracking</h2>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Claude Usage Tracking
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Track your Claude Code usage limits. Uses the Claude CLI for data.
@@ -27,7 +29,10 @@ export function ClaudeUsageSection() {
<p>Usage tracking requires Claude Code CLI to be installed and authenticated:</p>
<ol className="list-decimal list-inside space-y-1 ml-1">
<li>Install Claude Code CLI if not already installed</li>
<li>Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to authenticate</li>
<li>
Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to
authenticate
</li>
<li>Usage data will be fetched automatically every ~minute</li>
</ol>
</div>

View File

@@ -0,0 +1 @@
export { useApiKeyManagement } from './use-api-key-management';

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { ProviderConfigParams } from "@/config/api-providers";
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;
@@ -32,9 +32,7 @@ export function useApiKeyManagement() {
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
null
);
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(null);
// API key status from environment
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
@@ -62,7 +60,7 @@ export function useApiKeyManagement() {
});
}
} catch (error) {
console.error("Failed to check API key status:", error);
console.error('Failed to check API key status:', error);
}
}
};
@@ -76,23 +74,23 @@ export function useApiKeyManagement() {
try {
const api = getElectronAPI();
const data = await api.setup.verifyClaudeAuth("api_key");
const data = await api.setup.verifyClaudeAuth('api_key');
if (data.success && data.authenticated) {
setTestResult({
success: true,
message: "Connection successful! Claude responded.",
message: 'Connection successful! Claude responded.',
});
} else {
setTestResult({
success: false,
message: data.error || "Failed to connect to Claude API.",
message: data.error || 'Failed to connect to Claude API.',
});
}
} catch {
setTestResult({
success: false,
message: "Network error. Please check your connection.",
message: 'Network error. Please check your connection.',
});
} finally {
setTestingConnection(false);
@@ -109,7 +107,7 @@ export function useApiKeyManagement() {
if (!googleKey || googleKey.trim().length < 10) {
setGeminiTestResult({
success: false,
message: "Please enter a valid API key.",
message: 'Please enter a valid API key.',
});
setTestingGeminiConnection(false);
return;
@@ -119,7 +117,7 @@ export function useApiKeyManagement() {
// Full verification requires a backend endpoint
setGeminiTestResult({
success: true,
message: "API key saved. Connection test not yet available.",
message: 'API key saved. Connection test not yet available.',
});
setTestingGeminiConnection(false);
};

View File

@@ -0,0 +1,4 @@
export { ApiKeyField } from './api-key-field';
export { ApiKeysSection } from './api-keys-section';
export { AuthenticationStatusDisplay } from './authentication-status-display';
export { SecurityNotice } from './security-notice';

View File

@@ -1,4 +1,4 @@
import { AlertCircle } from "lucide-react";
import { AlertCircle } from 'lucide-react';
interface SecurityNoticeProps {
title?: string;
@@ -6,7 +6,7 @@ interface SecurityNoticeProps {
}
export function SecurityNotice({
title = "Security Notice",
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 (

View File

@@ -0,0 +1 @@
export { AppearanceSection } from './appearance-section';

View File

@@ -1,24 +1,21 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
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) {
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"
'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">
@@ -26,9 +23,7 @@ export function AudioSection({
<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>
<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.
@@ -52,9 +47,9 @@ export function AudioSection({
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.
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>

View File

@@ -0,0 +1 @@
export { AudioSection } from './audio-section';

View File

@@ -1,12 +1,7 @@
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";
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;
@@ -14,20 +9,16 @@ interface CliStatusProps {
onRefresh: () => void;
}
export function ClaudeCliStatus({
status,
isChecking,
onRefresh,
}: CliStatusProps) {
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"
'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">
@@ -48,32 +39,28 @@ export function ClaudeCliStatus({
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"
'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")}
/>
<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.
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" ? (
{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>
<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>
@@ -94,9 +81,7 @@ export function ClaudeCliStatus({
</div>
</div>
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">
{status.recommendation}
</p>
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
@@ -106,24 +91,22 @@ export function ClaudeCliStatus({
<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-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."}
'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>
<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>
<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>
@@ -131,7 +114,9 @@ export function ClaudeCliStatus({
)}
{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>
<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>
@@ -139,7 +124,9 @@ export function ClaudeCliStatus({
)}
{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>
<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>

View File

@@ -0,0 +1 @@
export { ClaudeCliStatus } from './claude-cli-status';

View File

@@ -1,6 +1,6 @@
import { Folder, Trash2 } from "lucide-react";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Project } from "@/lib/electron";
import { Folder } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import type { Project } from '@/lib/electron';
interface DeleteProjectDialogProps {
open: boolean;
@@ -39,18 +39,13 @@ export function DeleteProjectDialog({
<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>
<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.
The folder will remain on disk until you permanently delete it from Trash.
</p>
</>
)}

View File

@@ -0,0 +1,4 @@
export { DeleteProjectDialog } from './delete-project-dialog';
export { KeyboardMapDialog } from './keyboard-map-dialog';
export { SettingsHeader } from './settings-header';
export { SettingsNavigation } from './settings-navigation';

View File

@@ -1,12 +1,12 @@
import { Keyboard } from "lucide-react";
import { Keyboard } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map";
} from '@/components/ui/dialog';
import { KeyboardMap, ShortcutReferencePanel } from '@/components/ui/keyboard-map';
interface KeyboardMapDialogProps {
open: boolean;
@@ -23,8 +23,8 @@ export function KeyboardMapDialog({ open, onOpenChange }: KeyboardMapDialogProps
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.
Visual overview of all keyboard shortcuts. Keys in color are bound to shortcuts. Click
on any shortcut below to edit it.
</DialogDescription>
</DialogHeader>

View File

@@ -1,5 +1,5 @@
import { Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import { Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SettingsHeaderProps {
title?: string;
@@ -7,23 +7,27 @@ interface SettingsHeaderProps {
}
export function SettingsHeader({
title = "Settings",
description = "Configure your API keys and preferences",
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={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"
)}>
<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>

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