mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
Implement initial project structure and features for Automaker application, including environment setup, auto mode services, and session management. Update port configurations to 3007 and add new UI components for enhanced user interaction.
This commit is contained in:
194
app/src/components/views/agent-output-modal.tsx
Normal file
194
app/src/components/views/agent-output-modal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
featureDescription: string;
|
||||
featureId: string;
|
||||
}
|
||||
|
||||
export function AgentOutputModal({
|
||||
open,
|
||||
onClose,
|
||||
featureDescription,
|
||||
featureId,
|
||||
}: AgentOutputModalProps) {
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const projectPathRef = useRef<string>("");
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
// Load existing output from file
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const loadOutput = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Get current project path from store (we'll need to pass this)
|
||||
const currentProject = (window as any).__currentProject;
|
||||
if (!currentProject?.path) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectPathRef.current = currentProject.path;
|
||||
|
||||
// Ensure context directory exists
|
||||
const contextDir = `${currentProject.path}/.automaker/context`;
|
||||
await api.mkdir(contextDir);
|
||||
|
||||
// Try to read existing output file
|
||||
const outputPath = `${contextDir}/${featureId}.md`;
|
||||
const result = await api.readFile(outputPath);
|
||||
|
||||
if (result.success && result.content) {
|
||||
setOutput(result.content);
|
||||
} else {
|
||||
setOutput("");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load output:", error);
|
||||
setOutput("");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOutput();
|
||||
}, [open, featureId]);
|
||||
|
||||
// Save output to file
|
||||
const saveOutput = async (newContent: string) => {
|
||||
if (!projectPathRef.current) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const contextDir = `${projectPathRef.current}/.automaker/context`;
|
||||
const outputPath = `${contextDir}/${featureId}.md`;
|
||||
|
||||
await api.writeFile(outputPath, newContent);
|
||||
} catch (error) {
|
||||
console.error("Failed to save output:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen to auto mode events and update output
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
let newContent = "";
|
||||
|
||||
if (event.type === "auto_mode_progress") {
|
||||
newContent = event.content || "";
|
||||
} else if (event.type === "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}` : ""}`;
|
||||
} else if (event.type === "auto_mode_phase") {
|
||||
const phaseEmoji = event.phase === "planning" ? "📋" : event.phase === "action" ? "⚡" : "✅";
|
||||
newContent = `\n${phaseEmoji} ${event.message}\n`;
|
||||
} else if (event.type === "auto_mode_error") {
|
||||
newContent = `\n❌ Error: ${event.error}\n`;
|
||||
} else if (event.type === "auto_mode_feature_complete") {
|
||||
const emoji = event.passes ? "✅" : "⚠️";
|
||||
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
setOutput((prev) => {
|
||||
const updated = prev + newContent;
|
||||
saveOutput(updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, featureId]);
|
||||
|
||||
// Handle scroll to detect if user scrolled up
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
autoScrollRef.current = isAtBottom;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Loader2 className="w-5 h-5 text-purple-500 animate-spin" />
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{featureDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-sm min-h-[400px] max-h-[60vh]"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading output...
|
||||
</div>
|
||||
) : !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No output yet. The agent will stream output here as it works.
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{autoScrollRef.current
|
||||
? "Auto-scrolling enabled"
|
||||
: "Scroll to bottom to enable auto-scroll"}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
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";
|
||||
@@ -47,7 +53,9 @@ export function AgentToolsView() {
|
||||
// Write File Tool State
|
||||
const [writeFilePath, setWriteFilePath] = useState("");
|
||||
const [writeFileContent, setWriteFileContent] = useState("");
|
||||
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(null);
|
||||
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(
|
||||
null
|
||||
);
|
||||
const [isWritingFile, setIsWritingFile] = useState(false);
|
||||
|
||||
// Terminal Tool State
|
||||
@@ -147,18 +155,20 @@ export function AgentToolsView() {
|
||||
// In mock mode, simulate terminal output
|
||||
// In real Electron mode, this would use child_process
|
||||
const mockOutputs: Record<string, string> = {
|
||||
"ls": "app_spec.txt\nfeature_list.json\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
|
||||
"pwd": currentProject?.path || "/Users/demo/project",
|
||||
ls: "app_spec.txt\nfeature_list.json\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}',
|
||||
whoami: "automaker-agent",
|
||||
date: new Date().toString(),
|
||||
"cat package.json":
|
||||
'{\n "name": "demo-project",\n "version": "1.0.0"\n}',
|
||||
};
|
||||
|
||||
// Simulate command execution delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const output = mockOutputs[terminalCommand.toLowerCase()] ||
|
||||
const output =
|
||||
mockOutputs[terminalCommand.toLowerCase()] ||
|
||||
`Command executed: ${terminalCommand}\n(Mock output - real execution requires Electron mode)`;
|
||||
|
||||
setTerminalResult({
|
||||
@@ -166,7 +176,9 @@ 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,
|
||||
@@ -180,7 +192,10 @@ 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>
|
||||
@@ -193,9 +208,12 @@ export function AgentToolsView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" 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">
|
||||
<div className="flex items-center gap-3 p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Agent Tools</h1>
|
||||
@@ -315,7 +333,11 @@ 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"
|
||||
>
|
||||
@@ -449,7 +471,8 @@ export function AgentToolsView() {
|
||||
<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,82 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useAppStore } 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 } from "lucide-react";
|
||||
import { ImageDropZone } from "@/components/ui/image-drop-zone";
|
||||
import {
|
||||
Bot,
|
||||
Send,
|
||||
User,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
Trash2,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
Paperclip,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let messageCounter = 0;
|
||||
const generateMessageId = () => `msg-${++messageCounter}`;
|
||||
|
||||
const getAgentResponse = (userInput: string): string => {
|
||||
const lowerInput = userInput.toLowerCase();
|
||||
|
||||
if (lowerInput.includes("todo") || lowerInput.includes("task")) {
|
||||
return "I can help you build a todo application! Let me ask a few questions:\n\n1. What tech stack would you prefer? (React, Vue, plain JavaScript)\n2. Do you need user authentication?\n3. Should tasks be stored locally or in a database?\n\nPlease share your preferences and I'll create a detailed spec.";
|
||||
}
|
||||
|
||||
if (lowerInput.includes("api") || lowerInput.includes("backend")) {
|
||||
return "Great! For building an API, I'll need to know:\n\n1. What type of data will it handle?\n2. Do you need authentication?\n3. What database would you like to use? (PostgreSQL, MongoDB, SQLite)\n4. Should I generate OpenAPI documentation?\n\nShare your requirements and I'll design the architecture.";
|
||||
}
|
||||
|
||||
if (lowerInput.includes("help") || lowerInput.includes("what can you do")) {
|
||||
return "I can help you with:\n\n• **Project Planning** - Define your app specification and features\n• **Code Generation** - Write code based on your requirements\n• **Testing** - Create and run tests for your features\n• **Code Review** - Analyze and improve existing code\n\nJust describe what you want to build, and I'll guide you through the process!";
|
||||
}
|
||||
|
||||
return `I understand you want to work on: "${userInput}"\n\nLet me analyze this and create a plan. In the full version, I would:\n\n1. Generate a detailed app_spec.txt\n2. Create feature_list.json with test cases\n3. Start implementing features one by one\n4. Run tests to verify each feature\n\nThis functionality requires API keys to be configured in Settings.`;
|
||||
};
|
||||
import { useElectronAgent } from "@/hooks/use-electron-agent";
|
||||
import { SessionManager } from "@/components/session-manager";
|
||||
import type { ImageAttachment } from "@/store/app-store";
|
||||
|
||||
export function AgentView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [messages, setMessages] = useState<Message[]>(() => [
|
||||
{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content:
|
||||
"Hello! I'm the Automaker Agent. I can help you build software autonomously. What would you like to create today?",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!input.trim() || isProcessing) return;
|
||||
// Scroll management for auto-scroll
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||
|
||||
const userMessage: Message = {
|
||||
id: generateMessageId(),
|
||||
role: "user",
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
const currentInput = input;
|
||||
// Input ref for auto-focus
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use the Electron agent hook (only if we have a session)
|
||||
const {
|
||||
messages,
|
||||
isProcessing,
|
||||
isConnected,
|
||||
sendMessage,
|
||||
clearHistory,
|
||||
error: agentError,
|
||||
} = useElectronAgent({
|
||||
sessionId: currentSessionId || "",
|
||||
workingDirectory: currentProject?.path,
|
||||
onToolUse: (toolName) => {
|
||||
setCurrentTool(toolName);
|
||||
setTimeout(() => setCurrentTool(null), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
|
||||
|
||||
const messageContent = input;
|
||||
const messageImages = selectedImages;
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setIsProcessing(true);
|
||||
setSelectedImages([]);
|
||||
setShowImageDropZone(false);
|
||||
|
||||
// Simulate agent response (in a real implementation, this would call the AI API)
|
||||
setTimeout(() => {
|
||||
const assistantMessage: Message = {
|
||||
id: generateMessageId(),
|
||||
role: "assistant",
|
||||
content: getAgentResponse(currentInput),
|
||||
timestamp: new Date(),
|
||||
await sendMessage(messageContent, messageImages);
|
||||
}, [input, selectedImages, isProcessing, sendMessage]);
|
||||
|
||||
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
||||
setSelectedImages(images);
|
||||
}, []);
|
||||
|
||||
const toggleImageDropZone = useCallback(() => {
|
||||
setShowImageDropZone(!showImageDropZone);
|
||||
}, [showImageDropZone]);
|
||||
|
||||
// Helper function to convert file to base64
|
||||
const fileToBase64 = useCallback((file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Failed to read file as base64"));
|
||||
}
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setIsProcessing(false);
|
||||
}, 1500);
|
||||
}, [input, isProcessing]);
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Process dropped files
|
||||
const processDroppedFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (isProcessing) return;
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_FILES = 5;
|
||||
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(
|
||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
|
||||
errors.push(
|
||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + selectedImages.length >= MAX_FILES) {
|
||||
errors.push(`Maximum ${MAX_FILES} images allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: ImageAttachment = {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn("Image upload errors:", errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||
}
|
||||
},
|
||||
[isProcessing, selectedImages, fileToBase64]
|
||||
);
|
||||
|
||||
// Remove individual image
|
||||
const removeImage = useCallback((imageId: string) => {
|
||||
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
|
||||
}, []);
|
||||
|
||||
// Drag and drop handlers for the input area
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isProcessing || !isConnected) return;
|
||||
|
||||
console.log(
|
||||
"[agent-view] Drag enter types:",
|
||||
Array.from(e.dataTransfer.types)
|
||||
);
|
||||
|
||||
// Check if dragged items contain files
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
},
|
||||
[isProcessing, isConnected]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only set dragOver to false if we're leaving the input container
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (isProcessing || !isConnected) return;
|
||||
|
||||
console.log("[agent-view] Drop event:", {
|
||||
filesCount: e.dataTransfer.files.length,
|
||||
itemsCount: e.dataTransfer.items.length,
|
||||
types: Array.from(e.dataTransfer.types),
|
||||
});
|
||||
|
||||
// Check if we have files
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
console.log("[agent-view] Processing files from dataTransfer.files");
|
||||
processDroppedFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle file paths (from screenshots or other sources)
|
||||
// This is common on macOS when dragging screenshots
|
||||
const items = e.dataTransfer.items;
|
||||
if (items && items.length > 0) {
|
||||
console.log("[agent-view] Processing items");
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
console.log(`[agent-view] Item ${i}:`, {
|
||||
kind: item.kind,
|
||||
type: item.type,
|
||||
});
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
console.log("[agent-view] Got file from item:", {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
processDroppedFiles(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isProcessing, isConnected, processDroppedFiles]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent) => {
|
||||
// Check if clipboard contains files
|
||||
const items = e.clipboardData?.items;
|
||||
if (items) {
|
||||
const files: File[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
console.log("[agent-view] Paste item:", {
|
||||
kind: item.kind,
|
||||
type: item.type,
|
||||
});
|
||||
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
e.preventDefault(); // Prevent default paste of file path
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
console.log(
|
||||
"[agent-view] Processing pasted image files:",
|
||||
files.length
|
||||
);
|
||||
const dataTransfer = new DataTransfer();
|
||||
files.forEach((file) => dataTransfer.items.add(file));
|
||||
await processDroppedFiles(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
},
|
||||
[processDroppedFiles]
|
||||
);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -85,9 +303,78 @@ export function AgentView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearChat = async () => {
|
||||
if (!confirm("Are you sure you want to clear this conversation?")) return;
|
||||
await clearHistory();
|
||||
};
|
||||
|
||||
// Scroll position detection
|
||||
const checkIfUserIsAtBottom = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const threshold = 50; // 50px threshold for "near bottom"
|
||||
const isAtBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <=
|
||||
threshold;
|
||||
|
||||
setIsUserAtBottom(isAtBottom);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom function
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: behavior,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle scroll events
|
||||
const handleScroll = useCallback(() => {
|
||||
checkIfUserIsAtBottom();
|
||||
}, [checkIfUserIsAtBottom]);
|
||||
|
||||
// Auto-scroll effect when messages change
|
||||
useEffect(() => {
|
||||
// Only auto-scroll if user was already at bottom
|
||||
if (isUserAtBottom && messages.length > 0) {
|
||||
// Use a small delay to ensure DOM is updated
|
||||
setTimeout(() => {
|
||||
scrollToBottom("smooth");
|
||||
}, 100);
|
||||
}
|
||||
}, [messages, isUserAtBottom, scrollToBottom]);
|
||||
|
||||
// Initial scroll to bottom when session changes
|
||||
useEffect(() => {
|
||||
if (currentSessionId && messages.length > 0) {
|
||||
// Scroll immediately without animation when switching sessions
|
||||
setTimeout(() => {
|
||||
scrollToBottom("auto");
|
||||
setIsUserAtBottom(true);
|
||||
}, 100);
|
||||
}
|
||||
}, [currentSessionId, scrollToBottom]);
|
||||
|
||||
// Auto-focus input when session is selected/changed
|
||||
useEffect(() => {
|
||||
if (currentSessionId && inputRef.current) {
|
||||
// Small delay to ensure UI has updated
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 200);
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="agent-view-no-project">
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="agent-view-no-project"
|
||||
>
|
||||
<div className="text-center">
|
||||
<Sparkles className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
|
||||
@@ -99,101 +386,336 @@ export function AgentView() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="agent-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">AI Agent</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Autonomous development assistant for {currentProject.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
// Show welcome message if no messages yet
|
||||
const displayMessages =
|
||||
messages.length === 0
|
||||
? [
|
||||
{
|
||||
id: "welcome",
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: messages;
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4" data-testid="message-list">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
message.role === "user" && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
|
||||
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex overflow-hidden content-bg"
|
||||
data-testid="agent-view"
|
||||
>
|
||||
{/* Session Manager Sidebar */}
|
||||
{showSessionManager && currentProject && (
|
||||
<div className="w-80 border-r flex-shrink-0">
|
||||
<SessionManager
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={setCurrentSessionId}
|
||||
projectPath={currentProject.path}
|
||||
isCurrentSessionThinking={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSessionManager(!showSessionManager)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<User className="w-4 h-4" />
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">AI Agent</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProject.name}
|
||||
{currentSessionId && !isConnected && " · Connecting..."}
|
||||
</p>
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
"max-w-[80%]",
|
||||
message.role === "user" && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
<p
|
||||
</div>
|
||||
|
||||
{/* Status indicators & actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTool && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
<Wrench className="w-3 h-3" />
|
||||
<span>{currentTool}</span>
|
||||
</div>
|
||||
)}
|
||||
{agentError && (
|
||||
<span className="text-xs text-destructive">{agentError}</span>
|
||||
)}
|
||||
{currentSessionId && messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearChat}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{!currentSessionId ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" />
|
||||
<h2 className="text-lg font-semibold mb-2">
|
||||
No Session Selected
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create or select a session to start chatting
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowSessionManager(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<PanelLeft className="w-4 h-4 mr-2" />
|
||||
{showSessionManager ? "View" : "Show"} Sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
data-testid="message-list"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{displayMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
message.role === "user" && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs mt-2",
|
||||
message.role === "user"
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
|
||||
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||
{message.role === "assistant" ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card
|
||||
className={cn(
|
||||
"max-w-[80%]",
|
||||
message.role === "user" &&
|
||||
"bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-2",
|
||||
message.role === "user"
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Thinking...
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Describe what you want to build..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isProcessing}
|
||||
data-testid="agent-input"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isProcessing}
|
||||
data-testid="send-message"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Input */}
|
||||
{currentSessionId && (
|
||||
<div className="border-t p-4 space-y-3">
|
||||
{/* Image Drop Zone (when visible) */}
|
||||
{showImageDropZone && (
|
||||
<ImageDropZone
|
||||
onImagesSelected={handleImagesSelected}
|
||||
maxFiles={5}
|
||||
className="mb-3"
|
||||
disabled={isProcessing || !isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text Input and Controls - with drag and drop support */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 transition-all duration-200 rounded-lg",
|
||||
isDragOver &&
|
||||
"bg-blue-50 dark:bg-blue-950/20 ring-2 ring-blue-400 ring-offset-2"
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isDragOver
|
||||
? "Drop your images here..."
|
||||
: "Describe what you want to build..."
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onPaste={handlePaste}
|
||||
disabled={isProcessing || !isConnected}
|
||||
data-testid="agent-input"
|
||||
className={cn(
|
||||
selectedImages.length > 0 &&
|
||||
"border-blue-200 bg-blue-50/50 dark:bg-blue-950/20",
|
||||
isDragOver &&
|
||||
"border-blue-400 bg-blue-50/50 dark:bg-blue-950/20"
|
||||
)}
|
||||
/>
|
||||
{selectedImages.length > 0 && !isDragOver && (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-blue-600 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
{isDragOver && (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-blue-600 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded flex items-center gap-1">
|
||||
<Paperclip className="w-3 h-3" />
|
||||
Drop here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Attachment Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={toggleImageDropZone}
|
||||
disabled={isProcessing || !isConnected}
|
||||
className={cn(
|
||||
showImageDropZone &&
|
||||
"bg-blue-100 text-blue-600 dark:bg-blue-900/50 dark:text-blue-400",
|
||||
selectedImages.length > 0 && "border-blue-400"
|
||||
)}
|
||||
title="Attach images"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!input.trim() && selectedImages.length === 0) ||
|
||||
isProcessing ||
|
||||
!isConnected
|
||||
}
|
||||
data-testid="send-message"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selected Images Preview */}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? "s" : ""} attached
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSelectedImages([])}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Image info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate">
|
||||
{image.filename}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(image.size)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={() => removeImage(image.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Folder,
|
||||
@@ -16,6 +22,10 @@ import {
|
||||
BarChart3,
|
||||
FileCode,
|
||||
Loader2,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -60,7 +70,15 @@ 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);
|
||||
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
|
||||
const [featureListGenerated, setFeatureListGenerated] = useState(false);
|
||||
const [featureListError, setFeatureListError] = useState<string | null>(null);
|
||||
|
||||
// Recursively scan directory
|
||||
const scanDirectory = useCallback(
|
||||
@@ -161,7 +179,541 @@ export function AnalysisView() {
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
|
||||
}, [
|
||||
currentProject,
|
||||
setIsAnalyzing,
|
||||
clearAnalysis,
|
||||
scanDirectory,
|
||||
setProjectAnalysis,
|
||||
]);
|
||||
|
||||
// Generate app_spec.txt from analysis
|
||||
const generateSpec = useCallback(async () => {
|
||||
if (!currentProject || !projectAnalysis) return;
|
||||
|
||||
setIsGeneratingSpec(true);
|
||||
setSpecError(null);
|
||||
setSpecGenerated(false);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Read key files to understand the project better
|
||||
const fileContents: Record<string, string> = {};
|
||||
const keyFiles = ['package.json', 'README.md', 'tsconfig.json'];
|
||||
|
||||
// Collect file paths from analysis
|
||||
const collectFilePaths = (nodes: FileTreeNode[], maxDepth: number = 3, currentDepth: number = 0): string[] => {
|
||||
const paths: string[] = [];
|
||||
for (const node of nodes) {
|
||||
if (!node.isDirectory) {
|
||||
paths.push(node.path);
|
||||
} else if (node.children && currentDepth < maxDepth) {
|
||||
paths.push(...collectFilePaths(node.children, maxDepth, currentDepth + 1));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
|
||||
|
||||
// Try to read key configuration files
|
||||
for (const keyFile of keyFiles) {
|
||||
const filePath = `${currentProject.path}/${keyFile}`;
|
||||
const exists = await api.exists(filePath);
|
||||
if (exists) {
|
||||
const result = await api.readFile(filePath);
|
||||
if (result.success && result.content) {
|
||||
fileContents[keyFile] = result.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect project type and tech stack
|
||||
const detectTechStack = () => {
|
||||
const stack: string[] = [];
|
||||
const extensions = projectAnalysis.filesByExtension;
|
||||
|
||||
// Check package.json for dependencies
|
||||
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');
|
||||
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript) 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');
|
||||
} 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');
|
||||
|
||||
// Remove duplicates
|
||||
return [...new Set(stack)];
|
||||
};
|
||||
|
||||
// Get project name from package.json or folder name
|
||||
const getProjectName = () => {
|
||||
if (fileContents['package.json']) {
|
||||
try {
|
||||
const pkg = JSON.parse(fileContents['package.json']);
|
||||
if (pkg.name) return pkg.name;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
// Fall back to folder name
|
||||
return currentProject.name;
|
||||
};
|
||||
|
||||
// Get project description from package.json or README
|
||||
const getProjectDescription = () => {
|
||||
if (fileContents['package.json']) {
|
||||
try {
|
||||
const pkg = JSON.parse(fileContents['package.json']);
|
||||
if (pkg.description) return pkg.description;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
if (fileContents['README.md']) {
|
||||
// Extract first paragraph from README
|
||||
const lines = fileContents['README.md'].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!') && trimmed.length > 20) {
|
||||
return trimmed.substring(0, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'A software project';
|
||||
};
|
||||
|
||||
// Group files by directory for structure analysis
|
||||
const analyzeStructure = () => {
|
||||
const structure: string[] = [];
|
||||
const topLevelDirs = projectAnalysis.fileTree.filter(n => n.isDirectory).map(n => n.name);
|
||||
|
||||
for (const dir of topLevelDirs) {
|
||||
structure.push(` <directory name="${dir}" />`);
|
||||
}
|
||||
return structure.join('\n');
|
||||
};
|
||||
|
||||
const projectName = getProjectName();
|
||||
const description = getProjectDescription();
|
||||
const techStack = detectTechStack();
|
||||
|
||||
// Generate the spec content
|
||||
const specContent = `<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
|
||||
<overview>
|
||||
${description}
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
<languages>
|
||||
${Object.entries(projectAnalysis.filesByExtension)
|
||||
.filter(([ext]) => ['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([ext, count]) => ` <language ext=".${ext}" count="${count}" />`)
|
||||
.join('\n')}
|
||||
</languages>
|
||||
<frameworks>
|
||||
${techStack.map(tech => ` <framework>${tech}</framework>`).join('\n')}
|
||||
</frameworks>
|
||||
</technology_stack>
|
||||
|
||||
<project_structure>
|
||||
<total_files>${projectAnalysis.totalFiles}</total_files>
|
||||
<total_directories>${projectAnalysis.totalDirectories}</total_directories>
|
||||
<top_level_structure>
|
||||
${analyzeStructure()}
|
||||
</top_level_structure>
|
||||
</project_structure>
|
||||
|
||||
<file_breakdown>
|
||||
${Object.entries(projectAnalysis.filesByExtension)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([ext, count]) => ` <extension type="${ext.startsWith('(') ? ext : '.' + ext}" count="${count}" />`)
|
||||
.join('\n')}
|
||||
</file_breakdown>
|
||||
|
||||
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
// Write the spec file
|
||||
const specPath = `${currentProject.path}/app_spec.txt`;
|
||||
const writeResult = await api.writeFile(specPath, specContent);
|
||||
|
||||
if (writeResult.success) {
|
||||
setSpecGenerated(true);
|
||||
} else {
|
||||
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');
|
||||
} finally {
|
||||
setIsGeneratingSpec(false);
|
||||
}
|
||||
}, [currentProject, projectAnalysis]);
|
||||
|
||||
// Generate feature_list.json from analysis
|
||||
const generateFeatureList = useCallback(async () => {
|
||||
if (!currentProject || !projectAnalysis) return;
|
||||
|
||||
setIsGeneratingFeatureList(true);
|
||||
setFeatureListError(null);
|
||||
setFeatureListGenerated(false);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Read key files to understand the project
|
||||
const fileContents: Record<string, string> = {};
|
||||
const keyFiles = ['package.json', 'README.md'];
|
||||
|
||||
// Try to read key configuration files
|
||||
for (const keyFile of keyFiles) {
|
||||
const filePath = `${currentProject.path}/${keyFile}`;
|
||||
const exists = await api.exists(filePath);
|
||||
if (exists) {
|
||||
const result = await api.readFile(filePath);
|
||||
if (result.success && result.content) {
|
||||
fileContents[keyFile] = result.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect file paths from analysis
|
||||
const collectFilePaths = (nodes: FileTreeNode[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
for (const node of nodes) {
|
||||
if (!node.isDirectory) {
|
||||
paths.push(node.path);
|
||||
} else if (node.children) {
|
||||
paths.push(...collectFilePaths(node.children));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
|
||||
|
||||
// Analyze directories and files to detect features
|
||||
interface DetectedFeature {
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
passes: boolean;
|
||||
}
|
||||
|
||||
const detectedFeatures: DetectedFeature[] = [];
|
||||
|
||||
// Detect features based on project structure and files
|
||||
const detectFeatures = () => {
|
||||
const extensions = projectAnalysis.filesByExtension;
|
||||
const topLevelDirs = projectAnalysis.fileTree.filter(n => n.isDirectory).map(n => n.name.toLowerCase());
|
||||
const topLevelFiles = projectAnalysis.fileTree.filter(n => !n.isDirectory).map(n => n.name.toLowerCase());
|
||||
|
||||
// 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.'));
|
||||
|
||||
if (hasTests) {
|
||||
detectedFeatures.push({
|
||||
category: "Testing",
|
||||
description: "Automated test suite",
|
||||
steps: [
|
||||
"Step 1: Tests directory exists",
|
||||
"Step 2: Test files are present",
|
||||
"Step 3: Run test suite"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check for components directory (UI components)
|
||||
const hasComponents = topLevelDirs.includes('components') ||
|
||||
allFilePaths.some(p => p.toLowerCase().includes('/components/'));
|
||||
|
||||
if (hasComponents) {
|
||||
detectedFeatures.push({
|
||||
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"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check for src directory (organized source code)
|
||||
if (topLevelDirs.includes('src')) {
|
||||
detectedFeatures.push({
|
||||
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"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check package.json for dependencies and detect features
|
||||
if (fileContents['package.json']) {
|
||||
try {
|
||||
const pkg = JSON.parse(fileContents['package.json']);
|
||||
|
||||
// React/Next.js app detection
|
||||
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) {
|
||||
detectedFeatures.push({
|
||||
category: "Frontend",
|
||||
description: "React-based user interface",
|
||||
steps: [
|
||||
"Step 1: React is installed",
|
||||
"Step 2: Components render correctly",
|
||||
"Step 3: State management works"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
if (pkg.dependencies?.next) {
|
||||
detectedFeatures.push({
|
||||
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"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// TypeScript support
|
||||
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript || extensions['ts'] || extensions['tsx']) {
|
||||
detectedFeatures.push({
|
||||
category: "Developer Experience",
|
||||
description: "TypeScript type safety",
|
||||
steps: [
|
||||
"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) {
|
||||
detectedFeatures.push({
|
||||
category: "UI/Design",
|
||||
description: "Tailwind CSS styling",
|
||||
steps: [
|
||||
"Step 1: Tailwind is configured",
|
||||
"Step 2: Styles are applied",
|
||||
"Step 3: Responsive design works"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// ESLint/Prettier (code quality)
|
||||
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
|
||||
detectedFeatures.push({
|
||||
category: "Developer Experience",
|
||||
description: "Code quality tools",
|
||||
steps: [
|
||||
"Step 1: Linter is configured",
|
||||
"Step 2: Code passes lint checks",
|
||||
"Step 3: Formatting is consistent"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Electron (desktop app)
|
||||
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
|
||||
detectedFeatures.push({
|
||||
category: "Platform",
|
||||
description: "Electron desktop application",
|
||||
steps: [
|
||||
"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']) {
|
||||
detectedFeatures.push({
|
||||
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"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Check for documentation
|
||||
if (topLevelFiles.includes('readme.md') || topLevelDirs.includes('docs')) {
|
||||
detectedFeatures.push({
|
||||
category: "Documentation",
|
||||
description: "Project documentation",
|
||||
steps: [
|
||||
"Step 1: README exists",
|
||||
"Step 2: Documentation is comprehensive",
|
||||
"Step 3: Setup instructions are clear"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check for CI/CD configuration
|
||||
const hasCICD = topLevelDirs.includes('.github') ||
|
||||
topLevelFiles.includes('.gitlab-ci.yml') ||
|
||||
topLevelFiles.includes('.travis.yml');
|
||||
|
||||
if (hasCICD) {
|
||||
detectedFeatures.push({
|
||||
category: "DevOps",
|
||||
description: "CI/CD pipeline configuration",
|
||||
steps: [
|
||||
"Step 1: CI config exists",
|
||||
"Step 2: Pipeline runs on push",
|
||||
"Step 3: Automated checks pass"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check for API routes (Next.js API or Express)
|
||||
const hasAPIRoutes = allFilePaths.some(p =>
|
||||
p.includes('/api/') ||
|
||||
p.includes('/routes/') ||
|
||||
p.includes('/endpoints/')
|
||||
);
|
||||
|
||||
if (hasAPIRoutes) {
|
||||
detectedFeatures.push({
|
||||
category: "Backend",
|
||||
description: "API endpoints",
|
||||
steps: [
|
||||
"Step 1: API routes are defined",
|
||||
"Step 2: Endpoints respond correctly",
|
||||
"Step 3: Error handling is implemented"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check for state management
|
||||
const hasStateManagement = allFilePaths.some(p =>
|
||||
p.includes('/store/') ||
|
||||
p.includes('/stores/') ||
|
||||
p.includes('/redux/') ||
|
||||
p.includes('/context/')
|
||||
);
|
||||
|
||||
if (hasStateManagement) {
|
||||
detectedFeatures.push({
|
||||
category: "Architecture",
|
||||
description: "State management system",
|
||||
steps: [
|
||||
"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')) {
|
||||
detectedFeatures.push({
|
||||
category: "Configuration",
|
||||
description: "Project configuration files",
|
||||
steps: [
|
||||
"Step 1: Config files exist",
|
||||
"Step 2: Configuration is valid",
|
||||
"Step 3: Build process works"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
detectFeatures();
|
||||
|
||||
// If no features were detected, add a default feature
|
||||
if (detectedFeatures.length === 0) {
|
||||
detectedFeatures.push({
|
||||
category: "Core",
|
||||
description: "Basic project structure",
|
||||
steps: [
|
||||
"Step 1: Project directory exists",
|
||||
"Step 2: Files are present",
|
||||
"Step 3: Project can be loaded"
|
||||
],
|
||||
passes: true
|
||||
});
|
||||
}
|
||||
|
||||
// Generate the feature list content
|
||||
const featureListContent = JSON.stringify(detectedFeatures, null, 2);
|
||||
|
||||
// Write the feature list file
|
||||
const featureListPath = `${currentProject.path}/feature_list.json`;
|
||||
const writeResult = await api.writeFile(featureListPath, featureListContent);
|
||||
|
||||
if (writeResult.success) {
|
||||
setFeatureListGenerated(true);
|
||||
} else {
|
||||
setFeatureListError(writeResult.error || 'Failed to write feature list file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate feature list:', error);
|
||||
setFeatureListError(error instanceof Error ? error.message : 'Failed to generate feature list');
|
||||
} finally {
|
||||
setIsGeneratingFeatureList(false);
|
||||
}
|
||||
}, [currentProject, projectAnalysis]);
|
||||
|
||||
// Toggle folder expansion
|
||||
const toggleFolder = (path: string) => {
|
||||
@@ -212,11 +764,15 @@ export function AnalysisView() {
|
||||
)}
|
||||
<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) => renderNode(child, depth + 1))}</div>
|
||||
<div>
|
||||
{node.children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -224,21 +780,29 @@ export function AnalysisView() {
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="analysis-view-no-project">
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="analysis-view-no-project"
|
||||
>
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" 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">
|
||||
<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
|
||||
@@ -267,10 +831,13 @@ export function AnalysisView() {
|
||||
<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>
|
||||
@@ -291,19 +858,27 @@ export function AnalysisView() {
|
||||
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>
|
||||
@@ -333,6 +908,102 @@ export function AnalysisView() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Generate Spec Card */}
|
||||
<Card data-testid="generate-spec-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Generate Specification
|
||||
</CardTitle>
|
||||
<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.
|
||||
</p>
|
||||
<Button
|
||||
onClick={generateSpec}
|
||||
disabled={isGeneratingSpec}
|
||||
className="w-full"
|
||||
data-testid="generate-spec-button"
|
||||
>
|
||||
{isGeneratingSpec ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Generate Spec
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{specGenerated && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-500" data-testid="spec-generated-success">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>app_spec.txt created successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
{specError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500" data-testid="spec-generated-error">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>{specError}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Generate Feature List Card */}
|
||||
<Card data-testid="generate-feature-list-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Generate Feature List
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create feature_list.json 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.
|
||||
</p>
|
||||
<Button
|
||||
onClick={generateFeatureList}
|
||||
disabled={isGeneratingFeatureList}
|
||||
className="w-full"
|
||||
data-testid="generate-feature-list-button"
|
||||
>
|
||||
{isGeneratingFeatureList ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ListChecks className="w-4 h-4 mr-2" />
|
||||
Generate Feature List
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{featureListGenerated && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-500" data-testid="feature-list-generated-success">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>feature_list.json created successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
{featureListError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500" data-testid="feature-list-generated-error">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>{featureListError}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* File Tree */}
|
||||
@@ -343,11 +1014,14 @@ export function AnalysisView() {
|
||||
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) => renderNode(node))}
|
||||
</div>
|
||||
|
||||
166
app/src/components/views/auto-mode-log.tsx
Normal file
166
app/src/components/views/auto-mode-log.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useAppStore, AutoModeActivity } from "@/store/app-store";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Wrench,
|
||||
Play,
|
||||
X,
|
||||
ClipboardList,
|
||||
Zap,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutoModeLogProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function AutoModeLog({ onClose }: AutoModeLogProps) {
|
||||
const { autoModeActivityLog, features, clearAutoModeActivity } =
|
||||
useAppStore();
|
||||
|
||||
const getActivityIcon = (type: AutoModeActivity["type"]) => {
|
||||
switch (type) {
|
||||
case "start":
|
||||
return <Play className="w-4 h-4 text-blue-500" />;
|
||||
case "progress":
|
||||
return <Loader2 className="w-4 h-4 text-purple-500 animate-spin" />;
|
||||
case "tool":
|
||||
return <Wrench className="w-4 h-4 text-yellow-500" />;
|
||||
case "complete":
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case "error":
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
case "planning":
|
||||
return <ClipboardList className="w-4 h-4 text-cyan-500" data-testid="planning-phase-icon" />;
|
||||
case "action":
|
||||
return <Zap className="w-4 h-4 text-orange-500" data-testid="action-phase-icon" />;
|
||||
case "verification":
|
||||
return <ShieldCheck className="w-4 h-4 text-emerald-500" data-testid="verification-phase-icon" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityColor = (type: AutoModeActivity["type"]) => {
|
||||
switch (type) {
|
||||
case "start":
|
||||
return "border-l-blue-500";
|
||||
case "progress":
|
||||
return "border-l-purple-500";
|
||||
case "tool":
|
||||
return "border-l-yellow-500";
|
||||
case "complete":
|
||||
return "border-l-green-500";
|
||||
case "error":
|
||||
return "border-l-red-500";
|
||||
case "planning":
|
||||
return "border-l-cyan-500";
|
||||
case "action":
|
||||
return "border-l-orange-500";
|
||||
case "verification":
|
||||
return "border-l-emerald-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getFeatureDescription = (featureId: string) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
return feature?.description || "Unknown feature";
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col border-white/10 bg-zinc-950/50 backdrop-blur-sm">
|
||||
<CardHeader className="p-4 border-b border-white/10 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-5 h-5 text-purple-500 animate-spin" />
|
||||
<CardTitle className="text-lg">Auto Mode Activity</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAutoModeActivity}
|
||||
className="h-8"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 flex-1 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-4 space-y-2">
|
||||
{autoModeActivityLog.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">No activity yet</p>
|
||||
<p className="text-xs mt-1">
|
||||
Start auto mode to see activity here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
autoModeActivityLog
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className={cn(
|
||||
"p-3 rounded-lg bg-zinc-900/50 border-l-4 hover:bg-zinc-900/70 transition-colors",
|
||||
getActivityColor(activity.type)
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">{getActivityIcon(activity.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(activity.timestamp)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-blue-400 truncate">
|
||||
{getFeatureDescription(activity.featureId)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground break-words">
|
||||
{activity.message}
|
||||
</p>
|
||||
{activity.tool && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<Wrench className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{activity.tool}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,22 @@ import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCorners,
|
||||
rectIntersection,
|
||||
pointerWithin,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -28,22 +38,30 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { KanbanColumn } from "./kanban-column";
|
||||
import { KanbanCard } from "./kanban-card";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { AutoModeLog } from "./auto-mode-log";
|
||||
import { AgentOutputModal } from "./agent-output-modal";
|
||||
import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
|
||||
const COLUMNS: { id: ColumnId; title: string; color: string }[] = [
|
||||
{ id: "backlog", title: "Backlog", color: "bg-zinc-500" },
|
||||
{ id: "planned", title: "Planned", color: "bg-blue-500" },
|
||||
{ id: "in_progress", title: "In Progress", color: "bg-yellow-500" },
|
||||
{ id: "review", title: "Review", color: "bg-purple-500" },
|
||||
{ id: "verified", title: "Verified", color: "bg-green-500" },
|
||||
{ id: "failed", title: "Failed", color: "bg-red-500" },
|
||||
];
|
||||
|
||||
export function BoardView() {
|
||||
const { currentProject, features, setFeatures, addFeature, updateFeature, moveFeature } =
|
||||
useAppStore();
|
||||
const {
|
||||
currentProject,
|
||||
features,
|
||||
setFeatures,
|
||||
addFeature,
|
||||
updateFeature,
|
||||
removeFeature,
|
||||
moveFeature,
|
||||
currentAutoTask,
|
||||
} = useAppStore();
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
@@ -53,6 +71,28 @@ export function BoardView() {
|
||||
steps: [""],
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [showActivityLog, setShowActivityLog] = useState(false);
|
||||
const [showOutputModal, setShowOutputModal] = useState(false);
|
||||
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
|
||||
|
||||
// Make current project available globally for modal
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
(window as any).__currentProject = currentProject;
|
||||
}
|
||||
return () => {
|
||||
(window as any).__currentProject = null;
|
||||
};
|
||||
}, [currentProject]);
|
||||
|
||||
// Auto mode hook
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -62,6 +102,23 @@ export function BoardView() {
|
||||
})
|
||||
);
|
||||
|
||||
// Custom collision detection that prioritizes columns over cards
|
||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||
// First, check if pointer is within a column
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
||||
COLUMNS.some((col) => col.id === collision.id)
|
||||
);
|
||||
|
||||
// If we found a column collision, use that
|
||||
if (columnCollisions.length > 0) {
|
||||
return columnCollisions;
|
||||
}
|
||||
|
||||
// Otherwise, use rectangle intersection for cards
|
||||
return rectIntersection(args);
|
||||
}, []);
|
||||
|
||||
// Load features from file
|
||||
const loadFeatures = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
@@ -69,15 +126,17 @@ export function BoardView() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(`${currentProject.path}/feature_list.json`);
|
||||
const result = await api.readFile(
|
||||
`${currentProject.path}/.automaker/feature_list.json`
|
||||
);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const parsed = JSON.parse(result.content);
|
||||
const featuresWithIds = parsed.map(
|
||||
(f: Omit<Feature, "id" | "status">, index: number) => ({
|
||||
(f: any, index: number) => ({
|
||||
...f,
|
||||
id: `feature-${index}-${Date.now()}`,
|
||||
status: f.passes ? "verified" : ("backlog" as ColumnId),
|
||||
id: f.id || `feature-${index}-${Date.now()}`,
|
||||
status: f.status || "backlog",
|
||||
})
|
||||
);
|
||||
setFeatures(featuresWithIds);
|
||||
@@ -89,6 +148,29 @@ export function BoardView() {
|
||||
}
|
||||
}, [currentProject, setFeatures]);
|
||||
|
||||
// Auto-show activity log when auto mode starts
|
||||
useEffect(() => {
|
||||
if (autoMode.isRunning && !showActivityLog) {
|
||||
setShowActivityLog(true);
|
||||
}
|
||||
}, [autoMode.isRunning, showActivityLog]);
|
||||
|
||||
// Listen for auto mode feature completion and reload features
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
if (event.type === "auto_mode_feature_complete") {
|
||||
// Reload features when a feature is completed
|
||||
console.log("[Board] Feature completed, reloading features...");
|
||||
loadFeatures();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [loadFeatures]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatures();
|
||||
}, [loadFeatures]);
|
||||
@@ -100,13 +182,14 @@ export function BoardView() {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const toSave = features.map((f) => ({
|
||||
id: f.id,
|
||||
category: f.category,
|
||||
description: f.description,
|
||||
steps: f.steps,
|
||||
passes: f.status === "verified",
|
||||
status: f.status,
|
||||
}));
|
||||
await api.writeFile(
|
||||
`${currentProject.path}/feature_list.json`,
|
||||
`${currentProject.path}/.automaker/feature_list.json`,
|
||||
JSON.stringify(toSave, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -114,12 +197,12 @@ export function BoardView() {
|
||||
}
|
||||
}, [currentProject, features]);
|
||||
|
||||
// Save when features change
|
||||
// Save when features change (after initial load is complete)
|
||||
useEffect(() => {
|
||||
if (features.length > 0) {
|
||||
if (!isLoading) {
|
||||
saveFeatures();
|
||||
}
|
||||
}, [features, saveFeatures]);
|
||||
}, [features, saveFeatures, isLoading]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
@@ -129,7 +212,7 @@ export function BoardView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveFeature(null);
|
||||
|
||||
@@ -138,17 +221,40 @@ export function BoardView() {
|
||||
const featureId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Find the feature being dragged
|
||||
const draggedFeature = features.find((f) => f.id === featureId);
|
||||
if (!draggedFeature) return;
|
||||
|
||||
// Only allow dragging from backlog
|
||||
if (draggedFeature.status !== "backlog") {
|
||||
console.log("[Board] Cannot drag feature that is already in progress or verified");
|
||||
return;
|
||||
}
|
||||
|
||||
let targetStatus: ColumnId | null = null;
|
||||
|
||||
// Check if we dropped on a column
|
||||
const column = COLUMNS.find((c) => c.id === overId);
|
||||
if (column) {
|
||||
moveFeature(featureId, column.id);
|
||||
targetStatus = column.id;
|
||||
} else {
|
||||
// Dropped on another feature - find its column
|
||||
const overFeature = features.find((f) => f.id === overId);
|
||||
if (overFeature) {
|
||||
moveFeature(featureId, overFeature.status);
|
||||
targetStatus = overFeature.status;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetStatus) return;
|
||||
|
||||
// Move the feature
|
||||
moveFeature(featureId, targetStatus);
|
||||
|
||||
// If moved to in_progress, trigger the agent
|
||||
if (targetStatus === "in_progress") {
|
||||
console.log("[Board] Feature moved to in_progress, starting agent...");
|
||||
await handleRunFeature(draggedFeature);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFeature = () => {
|
||||
@@ -156,7 +262,6 @@ export function BoardView() {
|
||||
category: newFeature.category || "Uncategorized",
|
||||
description: newFeature.description,
|
||||
steps: newFeature.steps.filter((s) => s.trim()),
|
||||
passes: false,
|
||||
status: "backlog",
|
||||
});
|
||||
setNewFeature({ category: "", description: "", steps: [""] });
|
||||
@@ -174,13 +279,89 @@ export function BoardView() {
|
||||
setEditingFeature(null);
|
||||
};
|
||||
|
||||
const handleDeleteFeature = (featureId: string) => {
|
||||
removeFeature(featureId);
|
||||
};
|
||||
|
||||
const handleRunFeature = async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error("Auto mode API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the API to run this specific feature by ID
|
||||
const result = await api.autoMode.runFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature run started successfully");
|
||||
// The feature status will be updated by the auto mode service
|
||||
// and the UI will reload features when the agent completes (via event listener)
|
||||
} else {
|
||||
console.error("[Board] Failed to run feature:", result.error);
|
||||
// Reload to revert the UI status change
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error running feature:", error);
|
||||
// Reload to revert the UI status change
|
||||
await loadFeatures();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyFeature = async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
console.log("[Board] Verifying feature:", { id: feature.id, description: feature.description });
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error("Auto mode API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the API to verify this specific feature by ID
|
||||
const result = await api.autoMode.verifyFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature verification started successfully");
|
||||
// The feature status will be updated by the auto mode service
|
||||
// and the UI will reload features when verification completes
|
||||
} else {
|
||||
console.error("[Board] Failed to verify feature:", result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error verifying feature:", error);
|
||||
await loadFeatures();
|
||||
}
|
||||
};
|
||||
|
||||
const getColumnFeatures = (columnId: ColumnId) => {
|
||||
return features.filter((f) => f.status === columnId);
|
||||
};
|
||||
|
||||
const handleViewOutput = (feature: Feature) => {
|
||||
setOutputFeature(feature);
|
||||
setShowOutputModal(true);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -188,37 +369,102 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="board-view">
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg relative"
|
||||
data-testid="board-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadFeatures} data-testid="refresh-board">
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<>
|
||||
{autoMode.isRunning ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => autoMode.stop()}
|
||||
data-testid="stop-auto-mode"
|
||||
>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop Auto Mode
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => autoMode.start()}
|
||||
data-testid="start-auto-mode"
|
||||
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Auto Mode
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMounted && autoMode.isRunning && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowActivityLog(!showActivityLog)}
|
||||
data-testid="toggle-activity-log"
|
||||
>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-purple-500" />
|
||||
Activity
|
||||
{showActivityLog ? (
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
) : (
|
||||
<ChevronUp className="w-4 h-4 ml-2" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadFeatures}
|
||||
data-testid="refresh-board"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowAddDialog(true)} data-testid="add-feature-button">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanban Columns */}
|
||||
<div className="flex-1 overflow-x-auto p-4">
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Kanban Columns */}
|
||||
<div className={cn(
|
||||
"flex-1 overflow-x-auto p-4",
|
||||
showActivityLog && "transition-all"
|
||||
)}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
@@ -242,6 +488,10 @@ export function BoardView() {
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => setEditingFeature(feature)}
|
||||
onDelete={() => handleDeleteFeature(feature.id)}
|
||||
onViewOutput={() => handleViewOutput(feature)}
|
||||
onVerify={() => handleVerifyFeature(feature)}
|
||||
isCurrentAutoTask={currentAutoTask === feature.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -254,13 +504,25 @@ export function BoardView() {
|
||||
{activeFeature && (
|
||||
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
|
||||
<CardHeader className="p-3">
|
||||
<CardTitle className="text-sm">{activeFeature.description}</CardTitle>
|
||||
<CardDescription className="text-xs">{activeFeature.category}</CardDescription>
|
||||
<CardTitle className="text-sm">
|
||||
{activeFeature.description}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{activeFeature.category}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Activity Log Panel */}
|
||||
{showActivityLog && (
|
||||
<div className="w-96 border-l border-white/10 flex-shrink-0">
|
||||
<AutoModeLog onClose={() => setShowActivityLog(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Feature Dialog */}
|
||||
@@ -268,7 +530,9 @@ export function BoardView() {
|
||||
<DialogContent data-testid="add-feature-dialog">
|
||||
<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>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
@@ -277,7 +541,9 @@ export function BoardView() {
|
||||
id="category"
|
||||
placeholder="e.g., Core, UI, API"
|
||||
value={newFeature.category}
|
||||
onChange={(e) => setNewFeature({ ...newFeature, category: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setNewFeature({ ...newFeature, category: e.target.value })
|
||||
}
|
||||
data-testid="feature-category-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -287,7 +553,9 @@ export function BoardView() {
|
||||
id="description"
|
||||
placeholder="Describe the feature..."
|
||||
value={newFeature.description}
|
||||
onChange={(e) => setNewFeature({ ...newFeature, description: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setNewFeature({ ...newFeature, description: e.target.value })
|
||||
}
|
||||
data-testid="feature-description-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -310,7 +578,10 @@ export function BoardView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setNewFeature({ ...newFeature, steps: [...newFeature.steps, ""] })
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
steps: [...newFeature.steps, ""],
|
||||
})
|
||||
}
|
||||
data-testid="add-step-button"
|
||||
>
|
||||
@@ -335,7 +606,10 @@ export function BoardView() {
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Feature Dialog */}
|
||||
<Dialog open={!!editingFeature} onOpenChange={() => setEditingFeature(null)}>
|
||||
<Dialog
|
||||
open={!!editingFeature}
|
||||
onOpenChange={() => setEditingFeature(null)}
|
||||
>
|
||||
<DialogContent data-testid="edit-feature-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Feature</DialogTitle>
|
||||
@@ -349,7 +623,10 @@ export function BoardView() {
|
||||
id="edit-category"
|
||||
value={editingFeature.category}
|
||||
onChange={(e) =>
|
||||
setEditingFeature({ ...editingFeature, category: e.target.value })
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
category: e.target.value,
|
||||
})
|
||||
}
|
||||
data-testid="edit-feature-category"
|
||||
/>
|
||||
@@ -360,7 +637,10 @@ export function BoardView() {
|
||||
id="edit-description"
|
||||
value={editingFeature.description}
|
||||
onChange={(e) =>
|
||||
setEditingFeature({ ...editingFeature, description: e.target.value })
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
data-testid="edit-feature-description"
|
||||
/>
|
||||
@@ -399,12 +679,23 @@ export function BoardView() {
|
||||
<Button variant="ghost" onClick={() => setEditingFeature(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateFeature} data-testid="confirm-edit-feature">
|
||||
<Button
|
||||
onClick={handleUpdateFeature}
|
||||
data-testid="confirm-edit-feature"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Agent Output Modal */}
|
||||
<AgentOutputModal
|
||||
open={showOutputModal}
|
||||
onClose={() => setShowOutputModal(false)}
|
||||
featureDescription={outputFeature?.description || ""}
|
||||
featureId={outputFeature?.id || ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
251
app/src/components/views/chat-history.tsx
Normal file
251
app/src/components/views/chat-history.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Archive,
|
||||
Trash2,
|
||||
MoreVertical,
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ArchiveRestore,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function ChatHistory() {
|
||||
const {
|
||||
chatSessions,
|
||||
currentProject,
|
||||
currentChatSession,
|
||||
chatHistoryOpen,
|
||||
createChatSession,
|
||||
setCurrentChatSession,
|
||||
archiveChatSession,
|
||||
unarchiveChatSession,
|
||||
deleteChatSession,
|
||||
setChatHistoryOpen,
|
||||
} = useAppStore();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter sessions for current project
|
||||
const projectSessions = chatSessions.filter(
|
||||
(session) => session.projectId === currentProject.id
|
||||
);
|
||||
|
||||
// Filter by search query and archived status
|
||||
const filteredSessions = projectSessions.filter((session) => {
|
||||
const matchesSearch = session.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase());
|
||||
const matchesArchivedStatus = showArchived
|
||||
? session.archived
|
||||
: !session.archived;
|
||||
return matchesSearch && matchesArchivedStatus;
|
||||
});
|
||||
|
||||
// Sort by most recently updated
|
||||
const sortedSessions = filteredSessions.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
|
||||
const handleCreateNewChat = () => {
|
||||
createChatSession();
|
||||
};
|
||||
|
||||
const handleSelectSession = (session: any) => {
|
||||
setCurrentChatSession(session);
|
||||
};
|
||||
|
||||
const handleArchiveSession = (sessionId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
archiveChatSession(sessionId);
|
||||
};
|
||||
|
||||
const handleUnarchiveSession = (sessionId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
unarchiveChatSession(sessionId);
|
||||
};
|
||||
|
||||
const handleDeleteSession = (sessionId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm("Are you sure you want to delete this chat session?")) {
|
||||
deleteChatSession(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-zinc-950/50 backdrop-blur-md border-r border-white/10 transition-all duration-200",
|
||||
chatHistoryOpen ? "w-80" : "w-0 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{chatHistoryOpen && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
<h2 className="font-semibold">Chat History</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setChatHistoryOpen(false)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<div className="p-4 border-b">
|
||||
<Button
|
||||
onClick={handleCreateNewChat}
|
||||
className="w-full justify-start gap-2"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search chats..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Archive Toggle */}
|
||||
<div className="px-4 py-2 border-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
{showArchived ? (
|
||||
<ArchiveRestore className="w-4 h-4" />
|
||||
) : (
|
||||
<Archive className="w-4 h-4" />
|
||||
)}
|
||||
{showArchived ? "Show Active" : "Show Archived"}
|
||||
{showArchived && (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{projectSessions.filter((s) => s.archived).length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Chat Sessions List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sortedSessions.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
{searchQuery ? (
|
||||
<>No chats match your search</>
|
||||
) : showArchived ? (
|
||||
<>No archived chats</>
|
||||
) : (
|
||||
<>No active chats. Create your first chat to get started!</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{sortedSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group",
|
||||
currentChatSession?.id === session.id && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelectSession(session)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm truncate">
|
||||
{session.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{session.messages.length} messages
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(session.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{session.archived ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) =>
|
||||
handleUnarchiveSession(session.id, e)
|
||||
}
|
||||
>
|
||||
<ArchiveRestore className="w-4 h-4 mr-2" />
|
||||
Unarchive
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) =>
|
||||
handleArchiveSession(session.id, e)
|
||||
}
|
||||
>
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleDeleteSession(session.id, e)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,9 @@ export function CodeView() {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
// Load directory tree
|
||||
const loadTree = useCallback(async () => {
|
||||
@@ -204,7 +206,9 @@ 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>
|
||||
);
|
||||
@@ -212,7 +216,10 @@ 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>
|
||||
);
|
||||
@@ -220,24 +227,37 @@ 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" 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">
|
||||
<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>
|
||||
@@ -269,7 +289,9 @@ 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>
|
||||
|
||||
533
app/src/components/views/interview-view.tsx
Normal file
533
app/src/components/views/interview-view.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useAppStore } 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";
|
||||
|
||||
interface InterviewMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface InterviewState {
|
||||
projectName: string;
|
||||
projectDescription: string;
|
||||
techStack: string[];
|
||||
features: string[];
|
||||
additionalNotes: string;
|
||||
}
|
||||
|
||||
// Interview questions flow
|
||||
const INTERVIEW_QUESTIONS = [
|
||||
{
|
||||
id: "project-description",
|
||||
question: "What do you want to build?",
|
||||
hint: "Describe your project idea in a few sentences",
|
||||
field: "projectDescription" as const,
|
||||
},
|
||||
{
|
||||
id: "tech-stack",
|
||||
question: "What tech stack would you like to use?",
|
||||
hint: "e.g., React, Next.js, Node.js, Python, etc.",
|
||||
field: "techStack" as const,
|
||||
},
|
||||
{
|
||||
id: "core-features",
|
||||
question: "What are the core features you want to include?",
|
||||
hint: "List the main functionalities your app should have",
|
||||
field: "features" as const,
|
||||
},
|
||||
{
|
||||
id: "additional",
|
||||
question: "Any additional requirements or preferences?",
|
||||
hint: "Design preferences, integrations, deployment needs, etc.",
|
||||
field: "additionalNotes" as const,
|
||||
},
|
||||
];
|
||||
|
||||
export function InterviewView() {
|
||||
const { setCurrentView, addProject, setCurrentProject, setAppSpec } = useAppStore();
|
||||
const [input, setInput] = useState("");
|
||||
const [messages, setMessages] = useState<InterviewMessage[]>([]);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [interviewData, setInterviewData] = useState<InterviewState>({
|
||||
projectName: "",
|
||||
projectDescription: "",
|
||||
techStack: [],
|
||||
features: [],
|
||||
additionalNotes: "",
|
||||
});
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [generatedSpec, setGeneratedSpec] = useState<string | null>(null);
|
||||
const [projectPath, setProjectPath] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [showProjectSetup, setShowProjectSetup] = useState(false);
|
||||
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Initialize with first question
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
const welcomeMessage: InterviewMessage = {
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content: `Hello! I'm here to help you plan your new project. Let's go through a few questions to understand what you want to build.\n\n**${INTERVIEW_QUESTIONS[0].question}**\n\n_${INTERVIEW_QUESTIONS[0].hint}_`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages([welcomeMessage]);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTo({
|
||||
top: messagesContainerRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Auto-focus input
|
||||
useEffect(() => {
|
||||
if (inputRef.current && !isComplete) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [currentQuestionIndex, isComplete]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!input.trim() || isGenerating || isComplete) return;
|
||||
|
||||
const userMessage: InterviewMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Update interview data based on current question
|
||||
const currentQuestion = INTERVIEW_QUESTIONS[currentQuestionIndex];
|
||||
if (currentQuestion) {
|
||||
setInterviewData((prev) => {
|
||||
const newData = { ...prev };
|
||||
if (currentQuestion.field === "techStack" || currentQuestion.field === "features") {
|
||||
// Parse comma-separated values into array
|
||||
newData[currentQuestion.field] = input.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
} else {
|
||||
(newData as Record<string, string | string[]>)[currentQuestion.field] = input;
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
|
||||
setInput("");
|
||||
|
||||
// Move to next question or complete
|
||||
const nextIndex = currentQuestionIndex + 1;
|
||||
|
||||
setTimeout(() => {
|
||||
if (nextIndex < INTERVIEW_QUESTIONS.length) {
|
||||
const nextQuestion = INTERVIEW_QUESTIONS[nextIndex];
|
||||
const assistantMessage: InterviewMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: `Great! **${nextQuestion.question}**\n\n_${nextQuestion.hint}_`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setCurrentQuestionIndex(nextIndex);
|
||||
} else {
|
||||
// All questions answered - generate spec
|
||||
const summaryMessage: InterviewMessage = {
|
||||
id: `assistant-summary-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: "Perfect! I have all the information I need. Now let me generate your project specification...",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, summaryMessage]);
|
||||
generateSpec({
|
||||
...interviewData,
|
||||
projectDescription: currentQuestionIndex === 0 ? input : interviewData.projectDescription,
|
||||
techStack: currentQuestionIndex === 1 ? input.split(",").map(s => s.trim()).filter(Boolean) : interviewData.techStack,
|
||||
features: currentQuestionIndex === 2 ? input.split(",").map(s => s.trim()).filter(Boolean) : interviewData.features,
|
||||
additionalNotes: currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}, [input, isGenerating, isComplete, currentQuestionIndex, interviewData]);
|
||||
|
||||
const generateSpec = useCallback(async (data: InterviewState) => {
|
||||
setIsGenerating(true);
|
||||
|
||||
// Generate a draft app_spec.txt based on the interview responses
|
||||
const spec = generateAppSpec(data);
|
||||
|
||||
// Simulate some processing time for better UX
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
setGeneratedSpec(spec);
|
||||
setIsGenerating(false);
|
||||
setIsComplete(true);
|
||||
setShowProjectSetup(true);
|
||||
|
||||
const completionMessage: InterviewMessage = {
|
||||
id: `assistant-complete-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: `I've generated a draft project specification based on our conversation!\n\nPlease provide a project name and choose where to save your project, then click "Create Project" to get started.`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, completionMessage]);
|
||||
}, []);
|
||||
|
||||
const generateAppSpec = (data: InterviewState): string => {
|
||||
const projectName = data.projectDescription
|
||||
.split(" ")
|
||||
.slice(0, 3)
|
||||
.join("-")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
|
||||
return `<project_specification>
|
||||
<project_name>${projectName || "my-project"}</project_name>
|
||||
|
||||
<overview>
|
||||
${data.projectDescription}
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
${data.techStack.length > 0 ? data.techStack.map((tech) => `<technology>${tech}</technology>`).join("\n ") : "<!-- Define your tech stack -->"}
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
${data.features.length > 0 ? data.features.map((feature) => `<capability>${feature}</capability>`).join("\n ") : "<!-- List core features -->"}
|
||||
</core_capabilities>
|
||||
|
||||
<additional_requirements>
|
||||
${data.additionalNotes || "None specified"}
|
||||
</additional_requirements>
|
||||
|
||||
<development_guidelines>
|
||||
<guideline>Write clean, production-quality code</guideline>
|
||||
<guideline>Include proper error handling</guideline>
|
||||
<guideline>Write comprehensive Playwright tests</guideline>
|
||||
<guideline>Ensure all tests pass before marking features complete</guideline>
|
||||
</development_guidelines>
|
||||
</project_specification>`;
|
||||
};
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
setProjectPath(result.filePaths[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!projectName || !projectPath || !generatedSpec) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const fullProjectPath = `${projectPath}/${projectName}`;
|
||||
|
||||
// Create project directory
|
||||
await api.mkdir(fullProjectPath);
|
||||
|
||||
// Write app_spec.txt with generated content
|
||||
await api.writeFile(`${fullProjectPath}/app_spec.txt`, generatedSpec);
|
||||
|
||||
// Create initial feature_list.json
|
||||
await api.writeFile(
|
||||
`${fullProjectPath}/feature_list.json`,
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
category: "Core",
|
||||
description: "Initial project setup",
|
||||
steps: ["Step 1: Review app_spec.txt", "Step 2: Set up development environment", "Step 3: Start implementing features"],
|
||||
passes: false,
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name: projectName,
|
||||
path: fullProjectPath,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update app spec in store
|
||||
setAppSpec(generatedSpec);
|
||||
|
||||
// Add and select the project
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
} catch (error) {
|
||||
console.error("Failed to create project:", error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
setCurrentView("welcome");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col content-bg" data-testid="interview-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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoBack}
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid="interview-back-button"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">New Project Interview</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isComplete ? "Specification generated!" : `Question ${currentQuestionIndex + 1} of ${INTERVIEW_QUESTIONS.length}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{INTERVIEW_QUESTIONS.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-colors",
|
||||
index < currentQuestionIndex
|
||||
? "bg-green-500"
|
||||
: index === currentQuestionIndex
|
||||
? "bg-primary"
|
||||
: "bg-zinc-700"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{isComplete && <CheckCircle className="w-4 h-4 text-green-500 ml-2" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
data-testid="interview-messages"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
message.role === "user" && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
|
||||
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
"max-w-[80%]",
|
||||
message.role === "user" && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-2",
|
||||
message.role === "user"
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isGenerating && !showProjectSetup && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Generating specification...
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Setup Form */}
|
||||
{showProjectSetup && (
|
||||
<div className="mt-6">
|
||||
<Card className="bg-zinc-900/50 border-white/10" data-testid="project-setup-form">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Create Your Project</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="project-name" className="text-sm font-medium text-zinc-300">
|
||||
Project Name
|
||||
</label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="my-awesome-project"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className="bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
|
||||
data-testid="interview-project-name-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="project-path" className="text-sm font-medium text-zinc-300">
|
||||
Parent Directory
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="project-path"
|
||||
placeholder="/path/to/projects"
|
||||
value={projectPath}
|
||||
onChange={(e) => setProjectPath(e.target.value)}
|
||||
className="flex-1 bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
|
||||
data-testid="interview-project-path-input"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSelectDirectory}
|
||||
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
|
||||
data-testid="interview-browse-directory"
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview of generated spec */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">
|
||||
Generated Specification Preview
|
||||
</label>
|
||||
<div
|
||||
className="bg-zinc-950/50 border border-white/10 rounded-md p-3 max-h-48 overflow-y-auto"
|
||||
data-testid="spec-preview"
|
||||
>
|
||||
<pre className="text-xs text-zinc-400 whitespace-pre-wrap font-mono">
|
||||
{generatedSpec}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateProject}
|
||||
disabled={!projectName || !projectPath || isGenerating}
|
||||
className="w-full bg-gradient-to-r from-brand-500 to-purple-600 hover:from-brand-600 hover:to-purple-700 text-white border-0"
|
||||
data-testid="interview-create-project"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Create Project
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{!isComplete && (
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Type your answer..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isGenerating}
|
||||
data-testid="interview-input"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isGenerating}
|
||||
data-testid="interview-send"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,19 +3,39 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { GripVertical, Edit, Play, CheckCircle2, Circle } from "lucide-react";
|
||||
import { GripVertical, Edit, CheckCircle2, Circle, Loader2, Trash2, Eye, PlayCircle } from "lucide-react";
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onVerify?: () => void;
|
||||
isCurrentAutoTask?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanCard({ feature, onEdit }: KanbanCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
export function KanbanCard({ feature, onEdit, onDelete, onViewOutput, onVerify, isCurrentAutoTask }: KanbanCardProps) {
|
||||
// Disable dragging if the feature is in progress or verified
|
||||
const isDraggable = feature.status === "backlog";
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable,
|
||||
});
|
||||
|
||||
const style = {
|
||||
@@ -28,24 +48,38 @@ export function KanbanCard({ feature, onEdit }: KanbanCardProps) {
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"cursor-grab active:cursor-grabbing transition-all",
|
||||
isDragging && "opacity-50 scale-105 shadow-lg"
|
||||
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-white/10 relative",
|
||||
isDragging && "opacity-50 scale-105 shadow-lg",
|
||||
isCurrentAutoTask && "border-purple-500 border-2 shadow-purple-500/50 shadow-lg animate-pulse"
|
||||
)}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
{...attributes}
|
||||
>
|
||||
<CardHeader className="p-3 pb-2">
|
||||
{isCurrentAutoTask && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 bg-purple-500/20 border border-purple-500 rounded px-2 py-0.5">
|
||||
<Loader2 className="w-4 h-4 text-purple-400 animate-spin" />
|
||||
<span className="text-xs text-purple-400 font-medium">Running...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
{...listeners}
|
||||
className="mt-0.5 cursor-grab touch-none"
|
||||
className={cn(
|
||||
"mt-0.5 touch-none",
|
||||
isDraggable ? "cursor-grab" : "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
data-testid={`drag-handle-${feature.id}`}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm leading-tight">{feature.description}</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">{feature.category}</CardDescription>
|
||||
<CardTitle className="text-sm leading-tight">
|
||||
{feature.description}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{feature.category}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -54,8 +88,11 @@ export function KanbanCard({ feature, onEdit }: KanbanCardProps) {
|
||||
{feature.steps.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{feature.steps.slice(0, 3).map((step, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
{feature.passes ? (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{feature.status === "verified" ? (
|
||||
<CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
@@ -73,27 +110,84 @@ export function KanbanCard({ feature, onEdit }: KanbanCardProps) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-feature-${feature.id}`}
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-primary hover:text-primary"
|
||||
data-testid={`run-feature-${feature.id}`}
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
</Button>
|
||||
{isCurrentAutoTask && onViewOutput && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs bg-purple-600 hover:bg-purple-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
data-testid={`view-output-${feature.id}`}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
View Output
|
||||
</Button>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||
<>
|
||||
{onVerify && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs bg-green-600 hover:bg-green-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVerify();
|
||||
}}
|
||||
data-testid={`verify-feature-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
data-testid={`view-output-inprogress-${feature.id}`}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
Output
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status !== "in_progress" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-feature-${feature.id}`}
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
data-testid={`delete-feature-${feature.id}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -12,20 +12,26 @@ interface KanbanColumnProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KanbanColumn({ id, title, color, count, children }: KanbanColumnProps) {
|
||||
export function KanbanColumn({
|
||||
id,
|
||||
title,
|
||||
color,
|
||||
count,
|
||||
children,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex flex-col w-72 h-full rounded-lg bg-muted/50 transition-colors",
|
||||
isOver && "bg-muted"
|
||||
"flex flex-col w-72 h-full rounded-lg bg-zinc-900/50 backdrop-blur-sm border border-white/5 transition-colors",
|
||||
isOver && "bg-zinc-800/50"
|
||||
)}
|
||||
data-testid={`kanban-column-${id}`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center gap-2 p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 p-3 border-b border-white/5">
|
||||
<div className={cn("w-3 h-3 rounded-full", color)} />
|
||||
<h3 className="font-medium text-sm flex-1">{title}</h3>
|
||||
<span className="text-xs text-muted-foreground bg-background px-2 py-0.5 rounded-full">
|
||||
|
||||
@@ -6,21 +6,79 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Settings, Key, Eye, EyeOff, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { Settings, Key, Eye, EyeOff, CheckCircle2, AlertCircle, Loader2, Zap, Sun, Moon, Palette } from "lucide-react";
|
||||
|
||||
export function SettingsView() {
|
||||
const { apiKeys, setApiKeys, setCurrentView } = useAppStore();
|
||||
const { apiKeys, setApiKeys, setCurrentView, theme, setTheme } = useAppStore();
|
||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
||||
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
|
||||
const [geminiTestResult, setGeminiTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAnthropicKey(apiKeys.anthropic);
|
||||
setGoogleKey(apiKeys.google);
|
||||
}, [apiKeys]);
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestingConnection(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/claude/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ apiKey: anthropicKey }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setTestResult({ success: true, message: data.message || "Connection successful! Claude responded." });
|
||||
} else {
|
||||
setTestResult({ success: false, message: data.error || "Failed to connect to Claude API." });
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({ success: false, message: "Network error. Please check your connection." });
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestGeminiConnection = async () => {
|
||||
setTestingGeminiConnection(true);
|
||||
setGeminiTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/gemini/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ apiKey: googleKey }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setGeminiTestResult({ success: true, message: data.message || "Connection successful! Gemini responded." });
|
||||
} else {
|
||||
setGeminiTestResult({ success: false, message: data.error || "Failed to connect to Gemini API." });
|
||||
}
|
||||
} catch (error) {
|
||||
setGeminiTestResult({ success: false, message: "Network error. Please check your connection." });
|
||||
} finally {
|
||||
setTestingGeminiConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setApiKeys({
|
||||
anthropic: anthropicKey,
|
||||
@@ -30,50 +88,48 @@ export function SettingsView() {
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
const maskKey = (key: string) => {
|
||||
if (!key) return "";
|
||||
if (key.length <= 8) return "*".repeat(key.length);
|
||||
return key.slice(0, 4) + "*".repeat(key.length - 8) + key.slice(-4);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col" data-testid="settings-view">
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-6 h-6" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your API keys and preferences
|
||||
</p>
|
||||
<div className="flex-1 flex flex-col content-bg" data-testid="settings-view">
|
||||
{/* Header Section */}
|
||||
<div className="flex-shrink-0 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500 to-purple-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
||||
<Settings className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<p className="text-sm text-zinc-400">Configure your API keys and preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* API Keys Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
API Keys
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-900/50 backdrop-blur-md overflow-hidden">
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Key className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-semibold text-white">API Keys</h2>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Configure your AI provider API keys. Keys are stored locally in your browser.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Anthropic API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="anthropic-key" className="flex items-center gap-2">
|
||||
Anthropic API Key
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Claude/Anthropic API Key */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="anthropic-key" className="text-zinc-300">
|
||||
Anthropic API Key (Claude)
|
||||
</Label>
|
||||
{apiKeys.anthropic && (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
@@ -82,14 +138,14 @@ export function SettingsView() {
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="pr-10"
|
||||
className="pr-10 bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
|
||||
data-testid="anthropic-api-key-input"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
className="absolute right-0 top-0 h-full px-3 text-zinc-400 hover:text-white hover:bg-transparent"
|
||||
onClick={() => setShowAnthropicKey(!showAnthropicKey)}
|
||||
data-testid="toggle-anthropic-visibility"
|
||||
>
|
||||
@@ -100,28 +156,68 @@ export function SettingsView() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTestConnection}
|
||||
disabled={!anthropicKey || testingConnection}
|
||||
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
|
||||
data-testid="test-claude-connection"
|
||||
>
|
||||
{testingConnection ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Used for Claude AI features. Get your key at{" "}
|
||||
<a
|
||||
href="https://console.anthropic.com"
|
||||
href="https://console.anthropic.com/account/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-brand-500 hover:text-brand-400 hover:underline"
|
||||
>
|
||||
console.anthropic.com
|
||||
</a>
|
||||
. Alternatively, the CLAUDE_CODE_OAUTH_TOKEN environment variable can be used.
|
||||
</p>
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex items-center gap-2 p-3 rounded-lg ${
|
||||
testResult.success
|
||||
? 'bg-green-500/10 border border-green-500/20 text-green-400'
|
||||
: 'bg-red-500/10 border border-red-500/20 text-red-400'
|
||||
}`}
|
||||
data-testid="test-connection-result"
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
<span className="text-sm" data-testid="test-connection-message">{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Google API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="google-key" className="flex items-center gap-2">
|
||||
Google API Key (Gemini)
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="google-key" className="text-zinc-300">
|
||||
Google API Key (Gemini)
|
||||
</Label>
|
||||
{apiKeys.google && (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
@@ -130,14 +226,14 @@ export function SettingsView() {
|
||||
value={googleKey}
|
||||
onChange={(e) => setGoogleKey(e.target.value)}
|
||||
placeholder="AIza..."
|
||||
className="pr-10"
|
||||
className="pr-10 bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
|
||||
data-testid="google-api-key-input"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
className="absolute right-0 top-0 h-full px-3 text-zinc-400 hover:text-white hover:bg-transparent"
|
||||
onClick={() => setShowGoogleKey(!showGoogleKey)}
|
||||
data-testid="toggle-google-visibility"
|
||||
>
|
||||
@@ -148,40 +244,121 @@ export function SettingsView() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTestGeminiConnection}
|
||||
disabled={!googleKey || testingGeminiConnection}
|
||||
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
|
||||
data-testid="test-gemini-connection"
|
||||
>
|
||||
{testingGeminiConnection ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for Gemini AI features. Get your key at{" "}
|
||||
<p className="text-xs text-zinc-500">
|
||||
Used for Gemini AI features (including image/design prompts). Get your key at{" "}
|
||||
<a
|
||||
href="https://makersuite.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-brand-500 hover:text-brand-400 hover:underline"
|
||||
>
|
||||
makersuite.google.com
|
||||
</a>
|
||||
</p>
|
||||
{geminiTestResult && (
|
||||
<div
|
||||
className={`flex items-center gap-2 p-3 rounded-lg ${
|
||||
geminiTestResult.success
|
||||
? 'bg-green-500/10 border border-green-500/20 text-green-400'
|
||||
: 'bg-red-500/10 border border-red-500/20 text-red-400'
|
||||
}`}
|
||||
data-testid="gemini-test-connection-result"
|
||||
>
|
||||
{geminiTestResult.success ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
<span className="text-sm" data-testid="gemini-test-connection-message">{geminiTestResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 text-yellow-500 border border-yellow-500/20">
|
||||
<AlertCircle className="w-5 h-5 mt-0.5 shrink-0" />
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Security Notice</p>
|
||||
<p className="text-xs opacity-80 mt-1">
|
||||
<p className="font-medium text-yellow-500">Security Notice</p>
|
||||
<p className="text-yellow-500/80 text-xs mt-1">
|
||||
API keys are stored in your browser's local storage. Never share your API keys
|
||||
or commit them to version control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-900/50 backdrop-blur-md overflow-hidden">
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Palette className="w-5 h-5 text-brand-500" />
|
||||
<h2 className="text-lg font-semibold text-white">Appearance</h2>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Customize the look and feel of your application.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-zinc-300">Theme</Label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setTheme("dark")}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-all ${
|
||||
theme === "dark"
|
||||
? "bg-white/5 border-brand-500 text-white"
|
||||
: "bg-zinc-950/50 border-white/10 text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
data-testid="dark-mode-button"
|
||||
>
|
||||
<Moon className="w-4 h-4" />
|
||||
<span className="font-medium text-sm">Dark Mode</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme("light")}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-all ${
|
||||
theme === "light"
|
||||
? "bg-white/5 border-brand-500 text-white"
|
||||
: "bg-zinc-950/50 border-white/10 text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
data-testid="light-mode-button"
|
||||
>
|
||||
<Sun className="w-4 h-4" />
|
||||
<span className="font-medium text-sm">Light Mode</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
data-testid="save-settings"
|
||||
className="min-w-[100px]"
|
||||
className="min-w-[120px] bg-gradient-to-r from-brand-500 to-purple-600 hover:from-brand-600 hover:to-purple-700 text-white border-0"
|
||||
>
|
||||
{saved ? (
|
||||
<>
|
||||
@@ -193,8 +370,9 @@ export function SettingsView() {
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={() => setCurrentView("welcome")}
|
||||
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
|
||||
data-testid="back-to-home"
|
||||
>
|
||||
Back to Home
|
||||
|
||||
@@ -60,7 +60,10 @@ export function SpecView() {
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="spec-view-no-project">
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="spec-view-no-project"
|
||||
>
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
@@ -68,16 +71,22 @@ export function SpecView() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="spec-view-loading">
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="spec-view-loading"
|
||||
>
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="spec-view">
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="spec-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<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">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
|
||||
@@ -15,10 +15,16 @@ import {
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { FolderOpen, Plus, Sparkles, Folder, Clock } from "lucide-react";
|
||||
import { FolderOpen, Plus, Cpu, Folder, Clock, Sparkles, MessageSquare, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function WelcomeView() {
|
||||
const { projects, addProject, setCurrentProject } = useAppStore();
|
||||
const { projects, addProject, setCurrentProject, setCurrentView } = useAppStore();
|
||||
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [newProjectPath, setNewProjectPath] = useState("");
|
||||
@@ -50,6 +56,10 @@ export function WelcomeView() {
|
||||
setShowNewProjectDialog(true);
|
||||
};
|
||||
|
||||
const handleInteractiveMode = () => {
|
||||
setCurrentView("interview");
|
||||
};
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
@@ -132,144 +142,222 @@ export function WelcomeView() {
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8" data-testid="welcome-view">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-primary/10 mb-6">
|
||||
<Sparkles className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to Automaker</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-md">
|
||||
Your autonomous AI development studio. Build software with intelligent orchestration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl w-full mb-12">
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={handleNewProject}
|
||||
data-testid="new-project-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
|
||||
<Plus className="w-6 h-6 text-primary" />
|
||||
<div className="flex-1 flex flex-col content-bg" data-testid="welcome-view">
|
||||
{/* Header Section */}
|
||||
<div className="flex-shrink-0 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500 to-purple-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
||||
<Cpu className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<CardTitle>New Project</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new project from scratch or use interactive mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="w-full" data-testid="create-new-project">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Project
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={handleOpenProject}
|
||||
data-testid="open-project-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
|
||||
<FolderOpen className="w-6 h-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Welcome to Automaker</h1>
|
||||
<p className="text-sm text-zinc-400">Your autonomous AI development studio</p>
|
||||
</div>
|
||||
<CardTitle>Open Project</CardTitle>
|
||||
<CardDescription>
|
||||
Open an existing project folder to continue working
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="secondary" className="w-full" data-testid="open-existing-project">
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Browse Folder
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Projects */}
|
||||
{recentProjects.length > 0 && (
|
||||
<div className="max-w-2xl w-full">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Recent Projects
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{recentProjects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => setCurrentProject(project)}
|
||||
data-testid={`recent-project-${project.id}`}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Folder className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{project.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{project.path}</p>
|
||||
</div>
|
||||
{project.lastOpened && (
|
||||
<p className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(project.lastOpened).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
||||
<div
|
||||
className="group relative overflow-hidden rounded-xl border border-white/10 bg-zinc-900/50 backdrop-blur-md hover:bg-zinc-900/70 hover:border-white/20 transition-all duration-200"
|
||||
data-testid="new-project-card"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/5 to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="relative p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-brand-500 to-purple-600 shadow-lg shadow-brand-500/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Plus className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white mb-1">New Project</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Create a new project from scratch with AI-powered development
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-brand-500 to-purple-600 hover:from-brand-600 hover:to-purple-700 text-white border-0"
|
||||
data-testid="create-new-project"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Project
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleNewProject}
|
||||
data-testid="quick-setup-option"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Quick Setup
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleInteractiveMode}
|
||||
data-testid="interactive-mode-option"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Interactive Mode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="group relative overflow-hidden rounded-xl border border-white/10 bg-zinc-900/50 backdrop-blur-md hover:bg-zinc-900/70 hover:border-white/20 transition-all duration-200 cursor-pointer"
|
||||
onClick={handleOpenProject}
|
||||
data-testid="open-project-card"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="relative p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-zinc-800 border border-white/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<FolderOpen className="w-6 h-6 text-zinc-400 group-hover:text-white transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white mb-1">Open Project</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Open an existing project folder to continue working
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full bg-white/5 hover:bg-white/10 text-white border border-white/10 hover:border-white/20"
|
||||
data-testid="open-existing-project"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Browse Folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Projects */}
|
||||
{recentProjects.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock className="w-5 h-5 text-zinc-400" />
|
||||
<h2 className="text-lg font-semibold text-white">Recent Projects</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{recentProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="group relative overflow-hidden rounded-xl border border-white/10 bg-zinc-900/50 backdrop-blur-md hover:bg-zinc-900/70 hover:border-brand-500/50 transition-all duration-200 cursor-pointer"
|
||||
onClick={() => setCurrentProject(project)}
|
||||
data-testid={`recent-project-${project.id}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all"></div>
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-zinc-800 border border-white/10 flex items-center justify-center group-hover:border-brand-500/50 transition-colors">
|
||||
<Folder className="w-5 h-5 text-zinc-400 group-hover:text-brand-500 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate group-hover:text-brand-500 transition-colors">
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 truncate mt-0.5">{project.path}</p>
|
||||
{project.lastOpened && (
|
||||
<p className="text-xs text-zinc-600 mt-1">
|
||||
{new Date(project.lastOpened).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State for No Projects */}
|
||||
{recentProjects.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-zinc-900/50 border border-white/10 flex items-center justify-center mb-4">
|
||||
<Sparkles className="w-8 h-8 text-zinc-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No projects yet</h3>
|
||||
<p className="text-sm text-zinc-400 max-w-md">
|
||||
Get started by creating a new project or opening an existing one
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Project Dialog */}
|
||||
<Dialog open={showNewProjectDialog} onOpenChange={setShowNewProjectDialog}>
|
||||
<DialogContent data-testid="new-project-dialog">
|
||||
<DialogContent
|
||||
className="bg-zinc-900 border-white/10"
|
||||
data-testid="new-project-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogTitle className="text-white">Create New Project</DialogTitle>
|
||||
<DialogDescription className="text-zinc-400">
|
||||
Set up a new project directory with initial configuration files.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Project Name</Label>
|
||||
<Label htmlFor="project-name" className="text-zinc-300">
|
||||
Project Name
|
||||
</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="my-awesome-project"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
className="bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
|
||||
data-testid="project-name-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-path">Parent Directory</Label>
|
||||
<Label htmlFor="project-path" className="text-zinc-300">
|
||||
Parent Directory
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="project-path"
|
||||
placeholder="/path/to/projects"
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
className="flex-1"
|
||||
className="flex-1 bg-zinc-950/50 border-white/10 text-white placeholder:text-zinc-500"
|
||||
data-testid="project-path-input"
|
||||
/>
|
||||
<Button variant="secondary" onClick={handleSelectDirectory} data-testid="browse-directory">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSelectDirectory}
|
||||
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
|
||||
data-testid="browse-directory"
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowNewProjectDialog(false)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowNewProjectDialog(false)}
|
||||
className="text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateProject}
|
||||
disabled={!newProjectName || !newProjectPath || isCreating}
|
||||
className="bg-gradient-to-r from-brand-500 to-purple-600 hover:from-brand-600 hover:to-purple-700 text-white border-0"
|
||||
data-testid="confirm-create-project"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create Project"}
|
||||
|
||||
Reference in New Issue
Block a user