mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
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:
@@ -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's developer console to see detailed agent
|
||||
tool logs.
|
||||
Open your browser'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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 "Analyze Project" to scan your codebase and get
|
||||
insights about its structure.
|
||||
Click "Analyze Project" 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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
"{feature.description?.slice(0, 100)}
|
||||
{(feature.description?.length ?? 0) > 100 ? "..." : ""}"
|
||||
{(feature.description?.length ?? 0) > 100 ? '...' : ''}"
|
||||
</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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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})`}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { AIEnhancementSection } from "./ai-enhancement-section";
|
||||
export { AIEnhancementSection } from './ai-enhancement-section';
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { useApiKeyManagement } from './use-api-key-management';
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AppearanceSection } from './appearance-section';
|
||||
@@ -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 "ding" 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 "ding" 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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AudioSection } from './audio-section';
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ClaudeCliStatus } from './claude-cli-status';
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user