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:
Cody Seibert
2025-12-08 21:11:00 -05:00
parent 3c8e786f29
commit 9392422d35
67 changed files with 16275 additions and 696 deletions

View File

@@ -0,0 +1,167 @@
import {
query,
Options,
SDKAssistantMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { NextRequest, NextResponse } from "next/server";
import path from "path";
const systemPrompt = `You are an AI assistant helping users build software. You are part of the Automaker application,
which is designed to help developers plan, design, and implement software projects autonomously.
Your role is to:
- Help users define their project requirements and specifications
- Ask clarifying questions to better understand their needs
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
When discussing projects, help users think through:
- Core functionality and features
- Technical stack choices
- Data models and architecture
- User experience considerations
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds`;
export async function POST(request: NextRequest) {
try {
const { messages, workingDirectory } = await request.json();
console.log(
"[API] CLAUDE_CODE_OAUTH_TOKEN present:",
!!process.env.CLAUDE_CODE_OAUTH_TOKEN
);
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
return NextResponse.json(
{ error: "CLAUDE_CODE_OAUTH_TOKEN not configured" },
{ status: 500 }
);
}
// Get the last user message
const lastMessage = messages[messages.length - 1];
// Determine working directory - default to parent of app directory
const cwd = workingDirectory || path.resolve(process.cwd(), "..");
console.log("[API] Working directory:", cwd);
// Create query with options that enable code modification
const options: Options = {
// model: "claude-sonnet-4-20250514",
model: "claude-opus-4-5-20251101",
systemPrompt,
maxTurns: 20,
cwd,
// Enable all core tools for code modification
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
// Auto-accept file edits within the working directory
permissionMode: "acceptEdits",
// Enable sandbox for safer bash execution
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
};
// Convert message history to SDK format to preserve conversation context
// Include both user and assistant messages for full context
const sessionId = `api-session-${Date.now()}`;
const conversationMessages = messages.map((msg: { role: string; content: string }) => {
if (msg.role === 'user') {
return {
type: 'user' as const,
message: {
role: 'user' as const,
content: msg.content
},
parent_tool_use_id: null,
session_id: sessionId,
};
} else {
// Assistant message
return {
type: 'assistant' as const,
message: {
role: 'assistant' as const,
content: [
{
type: 'text' as const,
text: msg.content
}
]
},
session_id: sessionId,
};
}
});
// Execute query with full conversation context
const queryResult = query({
prompt: conversationMessages.length > 0 ? conversationMessages : lastMessage.content,
options,
});
let responseText = "";
const toolUses: Array<{ name: string; input: unknown }> = [];
// Collect the response from the async generator
for await (const msg of queryResult) {
if (msg.type === "assistant") {
const assistantMsg = msg as SDKAssistantMessage;
if (assistantMsg.message.content) {
for (const block of assistantMsg.message.content) {
if (block.type === "text") {
responseText += block.text;
} else if (block.type === "tool_use") {
// Track tool usage for transparency
toolUses.push({
name: block.name,
input: block.input,
});
}
}
}
} else if (msg.type === "result") {
if (msg.subtype === "success") {
if (msg.result) {
responseText = msg.result;
}
}
}
}
return NextResponse.json({
content: responseText || "Sorry, I couldn't generate a response.",
toolUses: toolUses.length > 0 ? toolUses : undefined,
});
} catch (error: unknown) {
console.error("Claude API error:", error);
const errorMessage =
error instanceof Error
? error.message
: "Failed to get response from Claude";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View File

@@ -0,0 +1,85 @@
import Anthropic from "@anthropic-ai/sdk";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { apiKey } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Create Anthropic client with the provided key
const anthropic = new Anthropic({
apiKey: effectiveApiKey,
});
// Send a simple test prompt
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 100,
messages: [
{
role: "user",
content: "Respond with exactly: 'Claude SDK connection successful!' and nothing else.",
},
],
});
// Check if we got a valid response
if (response.content && response.content.length > 0) {
const textContent = response.content.find((block) => block.type === "text");
if (textContent && textContent.type === "text") {
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${textContent.text}"`,
model: response.model,
});
}
}
return NextResponse.json({
success: true,
message: "Connection successful! Claude responded.",
model: response.model,
});
} catch (error: unknown) {
console.error("Claude API test error:", error);
// Handle specific Anthropic API errors
if (error instanceof Anthropic.AuthenticationError) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
{ status: 401 }
);
}
if (error instanceof Anthropic.RateLimitError) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
if (error instanceof Anthropic.APIError) {
return NextResponse.json(
{ success: false, error: `API error: ${error.message}` },
{ status: error.status || 500 }
);
}
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Claude API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
interface GeminiContent {
parts: Array<{
text?: string;
inlineData?: {
mimeType: string;
data: string;
};
}>;
role?: string;
}
interface GeminiRequest {
contents: GeminiContent[];
generationConfig?: {
maxOutputTokens?: number;
temperature?: number;
};
}
interface GeminiResponse {
candidates?: Array<{
content: {
parts: Array<{
text: string;
}>;
role: string;
};
finishReason: string;
safetyRatings?: Array<{
category: string;
probability: string;
}>;
}>;
promptFeedback?: {
safetyRatings?: Array<{
category: string;
probability: string;
}>;
};
error?: {
code: number;
message: string;
status: string;
};
}
export async function POST(request: NextRequest) {
try {
const { apiKey, imageData, mimeType, prompt } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Build the request body
const requestBody: GeminiRequest = {
contents: [
{
parts: [],
},
],
generationConfig: {
maxOutputTokens: 150,
temperature: 0.4,
},
};
// Add image if provided
if (imageData && mimeType) {
requestBody.contents[0].parts.push({
inlineData: {
mimeType: mimeType,
data: imageData,
},
});
}
// Add text prompt
const textPrompt = prompt || (imageData
? "Describe what you see in this image briefly."
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
requestBody.contents[0].parts.push({
text: textPrompt,
});
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
const response = await fetch(geminiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const data: GeminiResponse = await response.json();
// Check for API errors
if (data.error) {
const errorMessage = data.error.message || "Unknown Gemini API error";
const statusCode = data.error.code || 500;
if (statusCode === 400 && errorMessage.includes("API key")) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Google API key." },
{ status: 401 }
);
}
if (statusCode === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: statusCode }
);
}
// Check for valid response
if (!response.ok) {
return NextResponse.json(
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
{ status: response.status }
);
}
// Extract response text
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
const responseText = data.candidates[0].content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join("");
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
model: model,
hasImage: !!imageData,
});
}
// Handle blocked responses
if (data.promptFeedback?.safetyRatings) {
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded (response may have been filtered).",
model: model,
hasImage: !!imageData,
});
}
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded.",
model: model,
hasImage: !!imageData,
});
} catch (error: unknown) {
console.error("Gemini API test error:", error);
if (error instanceof TypeError && error.message.includes("fetch")) {
return NextResponse.json(
{ success: false, error: "Network error. Unable to reach Gemini API." },
{ status: 503 }
);
}
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Gemini API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -5,9 +5,15 @@
@theme inline {
--color-background: var(--background);
--color-background-50: var(--background-50);
--color-background-80: var(--background-80);
--color-foreground: var(--foreground);
--color-foreground-secondary: var(--foreground-secondary);
--color-foreground-muted: var(--foreground-muted);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
/* Sidebar colors */
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -16,14 +22,19 @@
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
/* Chart colors */
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
/* UI element colors */
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-border-glass: var(--border-glass);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
@@ -37,6 +48,13 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
/* Brand colors */
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
/* Border radius */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -79,36 +97,59 @@
}
.dark {
--background: oklch(0.1 0 0);
--foreground: oklch(0.95 0 0);
--card: oklch(0.13 0 0);
--card-foreground: oklch(0.95 0 0);
--popover: oklch(0.13 0 0);
--popover-foreground: oklch(0.95 0 0);
--primary: oklch(0.55 0.25 265);
/* Deep dark backgrounds - zinc-950 family */
--background: oklch(0.04 0 0); /* zinc-950 */
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
--background-80: oklch(0.04 0 0 / 0.8); /* zinc-950/80 */
/* Text colors following hierarchy */
--foreground: oklch(1 0 0); /* text-white */
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
/* Glass morphism effects */
--card: oklch(0.04 0 0 / 0.5); /* zinc-950/50 with transparency */
--card-foreground: oklch(1 0 0);
--popover: oklch(0.04 0 0 / 0.8); /* zinc-950/80 for popover */
--popover-foreground: oklch(1 0 0);
/* Brand colors - purple/violet theme */
--primary: oklch(0.55 0.25 265); /* brand-500 */
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.2 0 0);
--secondary-foreground: oklch(0.95 0 0);
--muted: oklch(0.2 0 0);
--muted-foreground: oklch(0.65 0 0);
--accent: oklch(0.55 0.25 265);
--brand-400: oklch(0.6 0.22 265);
--brand-500: oklch(0.55 0.25 265);
--brand-600: oklch(0.5 0.28 270); /* purple-600 for gradients */
/* Glass morphism borders and accents */
--secondary: oklch(1 0 0 / 0.05); /* bg-white/5 */
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.176 0 0); /* zinc-800 */
--muted-foreground: oklch(0.588 0 0); /* text-zinc-400 */
--accent: oklch(1 0 0 / 0.1); /* bg-white/10 for hover */
--accent-foreground: oklch(1 0 0);
/* Borders with transparency for glass effect */
--border: oklch(0.176 0 0); /* zinc-800 */
--border-glass: oklch(1 0 0 / 0.1); /* white/10 for glass morphism */
--destructive: oklch(0.6 0.25 25);
--border: oklch(0.25 0 0);
--input: oklch(0.2 0 0);
--input: oklch(0.04 0 0 / 0.8); /* Semi-transparent dark */
--ring: oklch(0.55 0.25 265);
/* Chart colors with brand theme */
--chart-1: oklch(0.55 0.25 265);
--chart-2: oklch(0.65 0.2 160);
--chart-3: oklch(0.75 0.2 70);
--chart-4: oklch(0.6 0.25 300);
--chart-5: oklch(0.6 0.25 20);
--sidebar: oklch(0.08 0 0);
--sidebar-foreground: oklch(0.95 0 0);
/* Sidebar with glass morphism */
--sidebar: oklch(0.04 0 0 / 0.5); /* zinc-950/50 with backdrop blur */
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.55 0.25 265);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2 0.05 265);
--sidebar-accent-foreground: oklch(0.95 0 0);
--sidebar-border: oklch(0.25 0 0);
--sidebar-accent: oklch(1 0 0 / 0.05); /* bg-white/5 */
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(1 0 0 / 0.1); /* white/10 for glass borders */
--sidebar-ring: oklch(0.55 0.25 265);
}
@@ -140,6 +181,74 @@
background: oklch(0.4 0 0);
}
/* Glass morphism utilities */
@layer utilities {
.glass {
@apply backdrop-blur-md border-white/10;
}
.glass-subtle {
@apply backdrop-blur-sm border-white/5;
}
.glass-strong {
@apply backdrop-blur-xl border-white/20;
}
/* Text color hierarchy utilities */
.text-primary-white {
color: oklch(1 0 0);
}
.text-secondary {
color: oklch(0.588 0 0); /* zinc-400 equivalent */
}
.text-muted {
color: oklch(0.525 0 0); /* zinc-500 equivalent */
}
/* Brand gradient utilities */
.gradient-brand {
background: linear-gradient(135deg, oklch(0.55 0.25 265), oklch(0.5 0.28 270));
}
.gradient-brand-subtle {
background: linear-gradient(135deg, oklch(0.55 0.25 265 / 0.1), oklch(0.5 0.28 270 / 0.1));
}
/* Glass morphism background utilities */
.bg-glass {
background: oklch(0.04 0 0 / 0.5);
}
.bg-glass-80 {
background: oklch(0.04 0 0 / 0.8);
}
/* Hover state utilities */
.hover-glass {
transition: background-color 0.2s ease;
}
.hover-glass:hover {
background: oklch(1 0 0 / 0.05);
}
.hover-glass-strong {
transition: background-color 0.2s ease;
}
.hover-glass-strong:hover {
background: oklch(1 0 0 / 0.1);
}
/* Content area background */
.content-bg {
background: linear-gradient(135deg, oklch(0.04 0 0), oklch(0.08 0 0), oklch(0.04 0 0));
}
}
/* Electron title bar drag region */
.titlebar-drag-region {
-webkit-app-region: drag;

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { WelcomeView } from "@/components/views/welcome-view";
import { BoardView } from "@/components/views/board-view";
@@ -10,11 +10,18 @@ import { AgentView } from "@/components/views/agent-view";
import { SettingsView } from "@/components/views/settings-view";
import { AnalysisView } from "@/components/views/analysis-view";
import { AgentToolsView } from "@/components/views/agent-tools-view";
import { InterviewView } from "@/components/views/interview-view";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
export default function Home() {
const { currentView, setIpcConnected, theme } = useAppStore();
const [isMounted, setIsMounted] = useState(false);
// Prevent hydration issues
useEffect(() => {
setIsMounted(true);
}, []);
// Test IPC connection on mount
useEffect(() => {
@@ -68,6 +75,8 @@ export default function Home() {
return <AnalysisView />;
case "tools":
return <AgentToolsView />;
case "interview":
return <InterviewView />;
default:
return <WelcomeView />;
}
@@ -78,9 +87,9 @@ export default function Home() {
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">{renderView()}</div>
{/* Environment indicator */}
{!isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20">
{/* Environment indicator - only show after mount to prevent hydration issues */}
{isMounted && !isElectron() && (
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
Web Mode (Mock IPC)
</div>
)}

View File

@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useAppStore } from "@/store/app-store";
import Link from "next/link";
import {
FolderOpen,
Plus,
@@ -16,11 +16,34 @@ import {
ChevronRight,
Folder,
X,
Moon,
Sun,
Search,
Wrench,
PanelLeft,
PanelLeftClose,
Sparkles,
User,
LogOut,
Cpu,
ChevronDown,
Check,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface NavSection {
label?: string;
items: NavItem[];
}
interface NavItem {
id: string;
label: string;
icon: any;
}
export function Sidebar() {
const {
@@ -28,192 +51,358 @@ export function Sidebar() {
currentProject,
currentView,
sidebarOpen,
theme,
setCurrentProject,
setCurrentView,
toggleSidebar,
removeProject,
setTheme,
} = useAppStore();
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const navItems = [
{ id: "spec" as const, label: "Spec Editor", icon: FileText },
{ id: "board" as const, label: "Kanban Board", icon: LayoutGrid },
{ id: "code" as const, label: "Code View", icon: Code },
{ id: "analysis" as const, label: "Analysis", icon: Search },
{ id: "agent" as const, label: "Agent Chat", icon: Bot },
{ id: "tools" as const, label: "Agent Tools", icon: Wrench },
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
userMenuRef.current &&
!userMenuRef.current.contains(event.target as Node)
) {
setUserMenuOpen(false);
}
}
if (userMenuOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [userMenuOpen]);
const navSections: NavSection[] = [
{
label: "Project",
items: [
{ id: "board", label: "Kanban Board", icon: LayoutGrid },
{ id: "agent", label: "Agent Runner", icon: Bot },
],
},
{
label: "Tools",
items: [
{ id: "spec", label: "Spec Editor", icon: FileText },
{ id: "code", label: "Code View", icon: Code },
{ id: "analysis", label: "Analysis", icon: Search },
{ id: "tools", label: "Agent Tools", icon: Wrench },
],
},
];
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
const isActiveRoute = (id: string) => {
return currentView === id;
};
return (
<aside
className={cn(
"flex flex-col h-full bg-sidebar border-r border-sidebar-border transition-all duration-300",
sidebarOpen ? "w-64" : "w-16"
"flex-shrink-0 border-r border-white/10 bg-zinc-950/50 backdrop-blur-md flex flex-col z-30 transition-all duration-300 relative",
sidebarOpen ? "w-16 lg:w-60" : "w-16"
)}
data-testid="sidebar"
>
{/* Header */}
<div className="flex items-center justify-between h-14 px-4 border-b border-sidebar-border titlebar-drag-region">
{sidebarOpen && (
<h1 className="text-lg font-bold text-sidebar-foreground">
Automaker
</h1>
{/* Floating Collapse Toggle Button - Desktop only */}
<button
onClick={toggleSidebar}
className="hidden lg:flex absolute top-20 -right-3 z-50 items-center justify-center w-6 h-6 rounded-full bg-zinc-800 border border-white/10 text-zinc-400 hover:text-white hover:bg-zinc-700 hover:border-white/20 transition-all shadow-lg titlebar-no-drag"
data-testid="sidebar-collapse-button"
title={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
>
{sidebarOpen ? (
<PanelLeftClose className="w-3.5 h-3.5" />
) : (
<PanelLeft className="w-3.5 h-3.5" />
)}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="titlebar-no-drag text-sidebar-foreground hover:bg-sidebar-accent"
data-testid="toggle-sidebar"
>
{sidebarOpen ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
</button>
{/* Project Actions */}
<div className="p-2 space-y-1">
<Button
variant="ghost"
<div className="flex-1 flex flex-col overflow-hidden">
{/* Logo */}
<div
className={cn(
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
!sidebarOpen && "justify-center px-2"
"h-20 pt-8 flex items-center justify-between border-b border-zinc-800 flex-shrink-0 titlebar-drag-region",
sidebarOpen ? "px-3 lg:px-6" : "px-3"
)}
onClick={() => setCurrentView("welcome")}
data-testid="new-project-button"
>
<Plus className="h-4 w-4" />
{sidebarOpen && <span>New Project</span>}
</Button>
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
!sidebarOpen && "justify-center px-2"
)}
onClick={() => setCurrentView("welcome")}
data-testid="open-project-button"
>
<FolderOpen className="h-4 w-4" />
{sidebarOpen && <span>Open Project</span>}
</Button>
</div>
<div
className="flex items-center titlebar-no-drag cursor-pointer"
onClick={() => setCurrentView("welcome")}
data-testid="logo-button"
>
<div className="relative flex items-center justify-center w-8 h-8 bg-gradient-to-br from-brand-500 to-purple-600 rounded-lg shadow-lg shadow-brand-500/20 group">
<Cpu className="text-white w-5 h-5 group-hover:rotate-12 transition-transform" />
</div>
<span
className={cn(
"ml-3 font-bold text-white text-base tracking-tight",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Auto<span className="text-brand-500">maker</span>
</span>
</div>
{/* Projects List */}
{sidebarOpen && projects.length > 0 && (
<div className="flex-1 overflow-y-auto px-2">
<p className="px-2 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Projects
</p>
<div className="space-y-1">
{projects.map((project) => (
<div
key={project.id}
className={cn(
"group flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer transition-colors",
currentProject?.id === project.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "hover:bg-sidebar-accent/50 text-sidebar-foreground"
)}
onClick={() => setCurrentProject(project)}
onMouseEnter={() => setHoveredProject(project.id)}
onMouseLeave={() => setHoveredProject(null)}
data-testid={`project-${project.id}`}
>
<Folder className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate text-sm">{project.name}</span>
{hoveredProject === project.id && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
removeProject(project.id);
}}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
{/* Project Actions */}
<div className="flex items-center gap-1 titlebar-no-drag">
<button
onClick={() => setCurrentView("welcome")}
className="group flex items-center justify-center w-8 h-8 rounded-lg relative overflow-hidden transition-all text-zinc-400 hover:text-white hover:bg-white/5"
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-4 h-4 flex-shrink-0" />
</button>
<button
onClick={() => setCurrentView("welcome")}
className="group flex items-center justify-center w-8 h-8 rounded-lg relative overflow-hidden transition-all text-zinc-400 hover:text-white hover:bg-white/5"
title="Open Project"
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 flex-shrink-0" />
</button>
</div>
</div>
)}
{/* Navigation - Only show when a project is open */}
{currentProject && (
<div className="border-t border-sidebar-border p-2 space-y-1">
{sidebarOpen && (
<p className="px-2 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Views
</p>
)}
{navItems.map((item) => (
<Button
key={item.id}
variant="ghost"
className={cn(
"w-full justify-start gap-3",
!sidebarOpen && "justify-center px-2",
currentView === item.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
{/* Project Selector */}
{sidebarOpen && projects.length > 0 && (
<div className="px-2 mt-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 transition-all text-white titlebar-no-drag"
data-testid="project-selector"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Folder className="h-4 w-4 text-brand-500 flex-shrink-0" />
<span className="text-sm font-medium truncate">
{currentProject?.name || "Select Project"}
</span>
</div>
<ChevronDown className="h-4 w-4 text-zinc-400 flex-shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56 bg-zinc-800 border-zinc-700"
align="start"
>
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => setCurrentProject(project)}
className="flex items-center gap-2 cursor-pointer text-zinc-300 hover:text-white hover:bg-zinc-700/50"
data-testid={`project-option-${project.id}`}
>
<Folder className="h-4 w-4" />
<span className="flex-1 truncate">{project.name}</span>
{currentProject?.id === project.id && (
<Check className="h-4 w-4 text-brand-500" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Nav Items - Scrollable */}
<nav className="flex-1 overflow-y-auto px-2 mt-4 pb-2">
{navSections.map((section, sectionIdx) => (
<div key={sectionIdx} className={sectionIdx > 0 ? "mt-6" : ""}>
{/* Section Label */}
{section.label && sidebarOpen && (
<div className="hidden lg:block px-4 mb-2">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
{section.label}
</span>
</div>
)}
{section.label && !sidebarOpen && (
<div className="h-px bg-zinc-800 mx-2 mb-2"></div>
)}
onClick={() => setCurrentView(item.id)}
data-testid={`nav-${item.id}`}
>
<item.icon className="h-4 w-4" />
{sidebarOpen && <span>{item.label}</span>}
</Button>
))}
</div>
)}
{/* Bottom Actions */}
<div className="mt-auto border-t border-sidebar-border p-2 space-y-1">
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
!sidebarOpen && "justify-center px-2"
)}
onClick={toggleTheme}
data-testid="toggle-theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
{sidebarOpen && (
<span>{theme === "dark" ? "Light Mode" : "Dark Mode"}</span>
)}
</Button>
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
!sidebarOpen && "justify-center px-2",
currentView === "settings" && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
onClick={() => setCurrentView("settings")}
data-testid="settings-button"
>
<Settings className="h-4 w-4" />
{sidebarOpen && <span>Settings</span>}
</Button>
{/* Nav Items */}
<div className="space-y-1">
{section.items.map((item) => {
const isActive = isActiveRoute(item.id);
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => setCurrentView(item.id as any)}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActive
? "bg-white/5 text-white border border-white/10"
: "text-zinc-400 hover:text-white hover:bg-white/5"
)}
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
{isActive && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Icon
className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
isActive
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
<span
className={cn(
"ml-2.5 font-medium text-sm",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
{item.label}
</span>
{/* Tooltip for collapsed state */}
{!sidebarOpen && (
<span
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
>
{item.label}
</span>
)}
</button>
);
})}
</div>
</div>
))}
</nav>
</div>
{/* Bottom Section - User / Settings */}
<div className="border-t border-zinc-800 bg-zinc-900/50 flex-shrink-0">
{/* Settings Link */}
<div className="p-2">
<button
onClick={() => setCurrentView("settings")}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActiveRoute("settings")
? "bg-white/5 text-white border border-white/10"
: "text-zinc-400 hover:text-white hover:bg-white/5",
sidebarOpen ? "justify-start" : "justify-center"
)}
title={!sidebarOpen ? "Settings" : undefined}
data-testid="settings-button"
>
{isActiveRoute("settings") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Settings
className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
isActiveRoute("settings")
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
<span
className={cn(
"ml-2.5 font-medium text-sm",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Settings
</span>
{!sidebarOpen && (
<span className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700">
Settings
</span>
)}
</button>
</div>
{/* User Profile */}
<div className="p-3 border-t border-zinc-800" ref={userMenuRef}>
<div className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className={cn(
"flex items-center p-1.5 rounded-lg transition-colors group relative w-full hover:bg-white/5 titlebar-no-drag",
sidebarOpen ? "lg:space-x-2.5" : "justify-center"
)}
>
<div className="relative">
<div className="w-8 h-8 rounded-full border border-zinc-600 bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center">
<User className="w-4 h-4 text-white" />
</div>
<div className="absolute bottom-0 right-0 w-2 h-2 bg-green-500 border-2 border-zinc-900 rounded-full"></div>
</div>
<div
className={cn(
"overflow-hidden",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
<p className="text-xs font-medium text-white truncate">
Developer
</p>
<p className="text-[10px] text-zinc-500 truncate">
Active Session
</p>
</div>
{/* Tooltip for user when collapsed */}
{!sidebarOpen && (
<span
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
data-testid="sidebar-tooltip-user"
>
Developer
</span>
)}
</button>
{/* Dropdown Menu */}
{userMenuOpen && (
<div
className={cn(
"absolute bottom-full mb-2 bg-zinc-800 border border-zinc-700 rounded-xl shadow-lg overflow-hidden z-50",
sidebarOpen ? "left-0 right-0" : "left-0"
)}
>
<div className="py-2">
<button
onClick={() => {
setUserMenuOpen(false);
setCurrentView("settings");
}}
className="flex items-center w-full px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors titlebar-no-drag"
>
<Settings className="w-4 h-4 mr-3" />
<span>Settings</span>
</button>
<div className="border-t border-zinc-700 my-2"></div>
<button
onClick={() => {
setUserMenuOpen(false);
// Add logout logic here if needed
}}
className="flex items-center w-full px-4 py-2 text-sm text-red-400 hover:bg-zinc-700/50 hover:text-red-300 transition-colors titlebar-no-drag"
>
<LogOut className="w-4 h-4 mr-3" />
<span>Exit</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
</aside>
);

View File

@@ -0,0 +1,367 @@
"use client";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Plus,
MessageSquare,
Archive,
Trash2,
Edit2,
Check,
X,
ArchiveRestore,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { SessionListItem } from "@/types/electron";
interface SessionManagerProps {
currentSessionId: string | null;
onSelectSession: (sessionId: string) => void;
projectPath: string;
isCurrentSessionThinking?: boolean;
}
export function SessionManager({
currentSessionId,
onSelectSession,
projectPath,
isCurrentSessionThinking = false,
}: SessionManagerProps) {
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [newSessionName, setNewSessionName] = useState("");
// Load sessions
const loadSessions = async () => {
if (!window.electronAPI?.sessions) return;
// Always load all sessions and filter client-side
const result = await window.electronAPI.sessions.list(true);
if (result.success && result.sessions) {
setSessions(result.sessions);
}
};
useEffect(() => {
loadSessions();
}, []);
// Create new session
const handleCreateSession = async () => {
if (!newSessionName.trim() || !window.electronAPI?.sessions) return;
const result = await window.electronAPI.sessions.create(
newSessionName,
projectPath,
projectPath
);
if (result.success && result.sessionId) {
setNewSessionName("");
setIsCreating(false);
await loadSessions();
onSelectSession(result.sessionId);
}
};
// Rename session
const handleRenameSession = async (sessionId: string) => {
if (!editingName.trim() || !window.electronAPI?.sessions) return;
const result = await window.electronAPI.sessions.update(
sessionId,
editingName,
undefined
);
if (result.success) {
setEditingSessionId(null);
setEditingName("");
await loadSessions();
}
};
// Archive session
const handleArchiveSession = async (sessionId: string) => {
if (!window.electronAPI?.sessions) return;
const result = await window.electronAPI.sessions.archive(sessionId);
if (result.success) {
await loadSessions();
}
};
// Unarchive session
const handleUnarchiveSession = async (sessionId: string) => {
if (!window.electronAPI?.sessions) return;
const result = await window.electronAPI.sessions.unarchive(sessionId);
if (result.success) {
await loadSessions();
}
};
// Delete session
const handleDeleteSession = async (sessionId: string) => {
if (!window.electronAPI?.sessions) return;
if (!confirm("Are you sure you want to delete this session?")) return;
const result = await window.electronAPI.sessions.delete(sessionId);
if (result.success) {
await loadSessions();
if (currentSessionId === sessionId) {
// Switch to another session or create a new one
const activeSessionsList = sessions.filter((s) => !s.isArchived);
if (activeSessionsList.length > 0) {
onSelectSession(activeSessionsList[0].id);
}
}
}
};
const activeSessions = sessions.filter((s) => !s.isArchived);
const archivedSessions = sessions.filter((s) => s.isArchived);
const displayedSessions = activeTab === "active" ? activeSessions : archivedSessions;
return (
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-center justify-between mb-4">
<CardTitle>Agent Sessions</CardTitle>
{activeTab === "active" && (
<Button
variant="default"
size="sm"
onClick={() => setIsCreating(true)}
>
<Plus className="w-4 h-4 mr-1" />
New
</Button>
)}
</div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "active" | "archived")}
className="w-full"
>
<TabsList className="w-full">
<TabsTrigger value="active" className="flex-1">
<MessageSquare className="w-4 h-4 mr-2" />
Active ({activeSessions.length})
</TabsTrigger>
<TabsTrigger value="archived" className="flex-1">
<Archive className="w-4 h-4 mr-2" />
Archived ({archivedSessions.length})
</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto space-y-2">
{/* Create new session */}
{isCreating && (
<div className="p-3 border rounded-lg bg-muted/50">
<div className="flex gap-2">
<Input
placeholder="Session name..."
value={newSessionName}
onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateSession();
if (e.key === "Escape") {
setIsCreating(false);
setNewSessionName("");
}
}}
autoFocus
/>
<Button size="sm" onClick={handleCreateSession}>
<Check className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsCreating(false);
setNewSessionName("");
}}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
{/* Session list */}
{displayedSessions.map((session) => (
<div
key={session.id}
className={cn(
"p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50",
currentSessionId === session.id && "bg-primary/10 border-primary",
session.isArchived && "opacity-60"
)}
onClick={() => !session.isArchived && onSelectSession(session.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{editingSessionId === session.id ? (
<div className="flex gap-2 mb-2">
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter")
handleRenameSession(session.id);
if (e.key === "Escape") {
setEditingSessionId(null);
setEditingName("");
}
}}
onClick={(e) => e.stopPropagation()}
autoFocus
className="h-7"
/>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRenameSession(session.id);
}}
className="h-7"
>
<Check className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setEditingSessionId(null);
setEditingName("");
}}
className="h-7"
>
<X className="w-3 h-3" />
</Button>
</div>
) : (
<>
<div className="flex items-center gap-2 mb-1">
{currentSessionId === session.id && isCurrentSessionThinking ? (
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<h3 className="font-medium truncate">{session.name}</h3>
{currentSessionId === session.id && isCurrentSessionThinking && (
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
thinking...
</span>
)}
</div>
{session.preview && (
<p className="text-xs text-muted-foreground truncate">
{session.preview}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">
{session.messageCount} messages
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</span>
</div>
</>
)}
</div>
{/* Actions */}
{!session.isArchived && (
<div
className="flex gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingSessionId(session.id);
setEditingName(session.name);
}}
className="h-7 w-7 p-0"
>
<Edit2 className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleArchiveSession(session.id)}
className="h-7 w-7 p-0"
>
<Archive className="w-3 h-3" />
</Button>
</div>
)}
{session.isArchived && (
<div
className="flex gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
onClick={() => handleUnarchiveSession(session.id)}
className="h-7 w-7 p-0"
>
<ArchiveRestore className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSession(session.id)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
)}
</div>
</div>
))}
{displayedSessions.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{activeTab === "active" ? "No active sessions" : "No archived sessions"}
</p>
<p className="text-xs">
{activeTab === "active"
? "Create your first session to get started"
: "Archive sessions to see them here"}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,18 +1,18 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-white/10 backdrop-blur-sm py-6 shadow-sm",
className
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -58,7 +58,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};

View File

@@ -0,0 +1,198 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,280 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Upload } from "lucide-react";
import type { ImageAttachment } from "@/store/app-store";
interface ImageDropZoneProps {
onImagesSelected: (images: ImageAttachment[]) => void;
maxFiles?: number;
maxFileSize?: number; // in bytes, default 10MB
className?: string;
children?: React.ReactNode;
disabled?: boolean;
}
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function ImageDropZone({
onImagesSelected,
maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
className,
children,
disabled = false,
}: ImageDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const processFiles = useCallback(async (files: FileList) => {
if (disabled || isProcessing) return;
setIsProcessing(true);
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 > maxFileSize) {
const maxSizeMB = maxFileSize / (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 >= maxFiles) {
errors.push(`Maximum ${maxFiles} 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);
// You could show these errors to the user via a toast or notification
}
if (newImages.length > 0) {
const allImages = [...selectedImages, ...newImages];
setSelectedImages(allImages);
onImagesSelected(allImages);
}
setIsProcessing(false);
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, onImagesSelected]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFiles(files);
}
}, [disabled, processFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [processFiles]);
const handleBrowseClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, [disabled]);
const removeImage = useCallback((imageId: string) => {
const updated = selectedImages.filter(img => img.id !== imageId);
setSelectedImages(updated);
onImagesSelected(updated);
}, [selectedImages, onImagesSelected]);
const clearAllImages = useCallback(() => {
setSelectedImages([]);
onImagesSelected([]);
}, [onImagesSelected]);
return (
<div className={cn("relative", className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
/>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200",
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
}
)}
>
{children || (
<div className="flex flex-col items-center justify-center p-6 text-center">
<div className={cn(
"rounded-full p-3 mb-4",
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
)}>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<p className="text-sm font-medium text-foreground mb-1">
{isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"}
</p>
<p className="text-xs text-muted-foreground">
{maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each
</p>
{!disabled && (
<button
onClick={handleBrowseClick}
className="mt-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
disabled={isProcessing}
>
Browse files
</button>
)}
</div>
)}
</div>
{/* Image previews */}
{selectedImages.length > 0 && (
<div className="mt-3 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' : ''} selected
</p>
<button
onClick={clearAllImages}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
>
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 */}
{!disabled && (
<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"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

View File

@@ -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>
);
}

View File

@@ -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&apos;s developer console to see detailed agent tool logs.
Open your browser&apos;s developer console to see detailed agent
tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>

View File

@@ -1,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];
}

View File

@@ -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 &quot;Analyze Project&quot; to scan your codebase and get insights about its
structure.
Click &quot;Analyze Project&quot; to scan your codebase and get
insights about its structure.
</p>
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
<Button
onClick={runAnalysis}
data-testid="analyze-project-button-empty"
>
<Search className="w-4 h-4 mr-2" />
Start Analysis
</Button>
@@ -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>

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

View File

@@ -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"}

View File

@@ -0,0 +1,179 @@
import { useEffect, useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
/**
* Hook for managing auto mode
*/
export function useAutoMode() {
const {
isAutoModeRunning,
setAutoModeRunning,
currentAutoTask,
setCurrentAutoTask,
currentProject,
addAutoModeActivity,
} = useAppStore(
useShallow((state) => ({
isAutoModeRunning: state.isAutoModeRunning,
setAutoModeRunning: state.setAutoModeRunning,
currentAutoTask: state.currentAutoTask,
setCurrentAutoTask: state.setCurrentAutoTask,
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
}))
);
// Handle auto mode events
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
console.log("[AutoMode Event]", event);
switch (event.type) {
case "auto_mode_feature_start":
setCurrentAutoTask(event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: "start",
message: `Started working on feature`,
});
break;
case "auto_mode_feature_complete":
// Feature completed - UI will reload features on its own
console.log(
"[AutoMode] Feature completed:",
event.featureId,
"passes:",
event.passes
);
addAutoModeActivity({
featureId: event.featureId,
type: "complete",
message: event.passes
? "Feature completed successfully"
: "Feature completed with failures",
passes: event.passes,
});
break;
case "auto_mode_complete":
// All features completed
setAutoModeRunning(false);
setCurrentAutoTask(null);
console.log("[AutoMode] All features completed!");
break;
case "auto_mode_error":
console.error("[AutoMode Error]", event.error);
if (event.featureId) {
addAutoModeActivity({
featureId: event.featureId,
type: "error",
message: event.error,
});
}
break;
case "auto_mode_progress":
// Log progress updates (throttle to avoid spam)
if (event.content && event.content.length > 10) {
addAutoModeActivity({
featureId: event.featureId,
type: "progress",
message: event.content.substring(0, 200), // Limit message length
});
}
break;
case "auto_mode_tool":
// Log tool usage
addAutoModeActivity({
featureId: event.featureId,
type: "tool",
message: `Using tool: ${event.tool}`,
tool: event.tool,
});
break;
case "auto_mode_phase":
// Log phase transitions (Planning, Action, Verification)
console.log(`[AutoMode] Phase: ${event.phase} for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: event.phase,
message: event.message,
phase: event.phase,
});
break;
}
});
return unsubscribe;
}, [setCurrentAutoTask, setAutoModeRunning, addAutoModeActivity]);
// Start auto mode
const start = useCallback(async () => {
if (!currentProject) {
console.error("No project selected");
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
throw new Error("Auto mode API not available");
}
const result = await api.autoMode.start(currentProject.path);
if (result.success) {
setAutoModeRunning(true);
console.log("[AutoMode] Started successfully");
} else {
console.error("[AutoMode] Failed to start:", result.error);
throw new Error(result.error || "Failed to start auto mode");
}
} catch (error) {
console.error("[AutoMode] Error starting:", error);
setAutoModeRunning(false);
throw error;
}
}, [currentProject, setAutoModeRunning]);
// Stop auto mode
const stop = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.autoMode) {
throw new Error("Auto mode API not available");
}
const result = await api.autoMode.stop();
if (result.success) {
setAutoModeRunning(false);
setCurrentAutoTask(null);
console.log("[AutoMode] Stopped successfully");
} else {
console.error("[AutoMode] Failed to stop:", result.error);
throw new Error(result.error || "Failed to stop auto mode");
}
} catch (error) {
console.error("[AutoMode] Error stopping:", error);
throw error;
}
}, [setAutoModeRunning, setCurrentAutoTask]);
return {
isRunning: isAutoModeRunning,
currentTask: currentAutoTask,
start,
stop,
};
}

View File

@@ -0,0 +1,402 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { Message, StreamEvent } from "@/types/electron";
import { useMessageQueue } from "./use-message-queue";
import type { ImageAttachment } from "@/store/app-store";
interface UseElectronAgentOptions {
sessionId: string;
workingDirectory?: string;
onToolUse?: (toolName: string, toolInput: unknown) => void;
}
interface UseElectronAgentResult {
messages: Message[];
isProcessing: boolean;
isConnected: boolean;
sendMessage: (content: string, images?: ImageAttachment[]) => Promise<void>;
stopExecution: () => Promise<void>;
clearHistory: () => Promise<void>;
error: string | null;
// Queue-related state
queuedMessages: { id: string; content: string; images?: ImageAttachment[]; timestamp: Date }[];
isQueueProcessing: boolean;
clearMessageQueue: () => void;
}
/**
* React hook for interacting with the Electron-based Claude agent
*
* This hook provides a clean interface to the agent running in the Electron
* main process, which survives Next.js restarts.
*/
export function useElectronAgent({
sessionId,
workingDirectory,
onToolUse,
}: UseElectronAgentOptions): UseElectronAgentResult {
const [messages, setMessages] = useState<Message[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const currentMessageRef = useRef<Message | null>(null);
// Send message directly to the agent (bypassing queue)
const sendMessageDirectly = useCallback(
async (content: string, images?: ImageAttachment[]) => {
if (!window.electronAPI?.agent) {
setError("Electron API not available");
return;
}
if (isProcessing) {
throw new Error("Agent is already processing a message");
}
setIsProcessing(true);
setError(null);
try {
console.log("[useElectronAgent] Sending message directly", {
hasImages: images && images.length > 0,
imageCount: images?.length || 0
});
// Save images to temp files and get paths
let imagePaths: string[] | undefined;
if (images && images.length > 0) {
imagePaths = [];
for (const image of images) {
const result = await window.electronAPI.saveImageToTemp(
image.data,
image.filename,
image.mimeType
);
if (result.success && result.path) {
imagePaths.push(result.path);
console.log("[useElectronAgent] Saved image to temp:", result.path);
} else {
console.error("[useElectronAgent] Failed to save image:", result.error);
}
}
}
const result = await window.electronAPI.agent.send(
sessionId,
content,
workingDirectory,
imagePaths
);
if (!result.success) {
setError(result.error || "Failed to send message");
setIsProcessing(false);
}
// Note: We don't set isProcessing to false here because
// it will be set by the "complete" or "error" stream event
} catch (err) {
console.error("[useElectronAgent] Failed to send message:", err);
setError(err instanceof Error ? err.message : "Failed to send message");
setIsProcessing(false);
throw err;
}
},
[sessionId, workingDirectory, isProcessing]
);
// Message queue for queuing messages when agent is busy
const {
queuedMessages,
isProcessingQueue,
addToQueue,
clearQueue,
processNext,
} = useMessageQueue({
onProcessNext: async (queuedMessage) => {
await sendMessageDirectly(queuedMessage.content, queuedMessage.images);
},
});
// Initialize connection and load history
useEffect(() => {
if (!window.electronAPI?.agent) {
setError("Electron API not available. Please run in Electron.");
return;
}
if (!sessionId) {
// No session selected - reset state
setMessages([]);
setIsConnected(false);
setIsProcessing(false);
setError(null);
return;
}
let mounted = true;
const initialize = async () => {
// Reset state when switching sessions
setIsProcessing(false);
setError(null);
try {
console.log("[useElectronAgent] Starting session:", sessionId);
const result = await window.electronAPI.agent.start(
sessionId,
workingDirectory
);
if (!mounted) return;
if (result.success && result.messages) {
console.log("[useElectronAgent] Loaded", result.messages.length, "messages");
setMessages(result.messages);
setIsConnected(true);
} else {
setError(result.error || "Failed to start session");
}
} catch (err) {
if (!mounted) return;
console.error("[useElectronAgent] Failed to initialize:", err);
setError(err instanceof Error ? err.message : "Failed to initialize");
}
};
initialize();
return () => {
mounted = false;
};
}, [sessionId, workingDirectory]);
// Auto-process queue when agent finishes processing
useEffect(() => {
if (!isProcessing && !isProcessingQueue && queuedMessages.length > 0) {
console.log("[useElectronAgent] Auto-processing next queued message");
processNext();
}
}, [isProcessing, isProcessingQueue, queuedMessages.length, processNext]);
// Subscribe to streaming events
useEffect(() => {
if (!window.electronAPI?.agent) return;
if (!sessionId) return; // Don't subscribe if no session
console.log("[useElectronAgent] Subscribing to stream events for session:", sessionId);
const handleStream = (event: StreamEvent) => {
// CRITICAL: Only process events for our specific session
if (event.sessionId !== sessionId) {
console.log("[useElectronAgent] Ignoring event for different session:", event.sessionId);
return;
}
console.log("[useElectronAgent] Stream event for", sessionId, ":", event.type);
switch (event.type) {
case "message":
// User message added
setMessages((prev) => [...prev, event.message]);
break;
case "stream":
// Assistant message streaming
if (event.isComplete) {
// Final update
setMessages((prev) =>
prev.map((msg) =>
msg.id === event.messageId
? { ...msg, content: event.content }
: msg
)
);
currentMessageRef.current = null;
} else {
// Streaming update
setMessages((prev) => {
const existingIndex = prev.findIndex((m) => m.id === event.messageId);
if (existingIndex >= 0) {
// Update existing message
return prev.map((msg) =>
msg.id === event.messageId
? { ...msg, content: event.content }
: msg
);
} else {
// Create new message
const newMessage: Message = {
id: event.messageId,
role: "assistant",
content: event.content,
timestamp: new Date().toISOString(),
};
currentMessageRef.current = newMessage;
return [...prev, newMessage];
}
});
}
break;
case "tool_use":
// Tool being used
console.log("[useElectronAgent] Tool use:", event.tool.name);
onToolUse?.(event.tool.name, event.tool.input);
break;
case "complete":
// Agent finished processing for THIS session
console.log("[useElectronAgent] Processing complete for session:", sessionId);
setIsProcessing(false);
if (event.messageId) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === event.messageId
? { ...msg, content: event.content }
: msg
)
);
}
break;
case "error":
// Error occurred for THIS session
console.error("[useElectronAgent] Agent error for session:", sessionId, event.error);
setIsProcessing(false);
setError(event.error);
if (event.message) {
setMessages((prev) => [...prev, event.message]);
}
break;
}
};
unsubscribeRef.current = window.electronAPI.agent.onStream(handleStream);
return () => {
if (unsubscribeRef.current) {
console.log("[useElectronAgent] Unsubscribing from stream events for session:", sessionId);
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}, [sessionId, onToolUse]);
// Send a message to the agent
const sendMessage = useCallback(
async (content: string, images?: ImageAttachment[]) => {
if (!window.electronAPI?.agent) {
setError("Electron API not available");
return;
}
if (isProcessing) {
console.warn("[useElectronAgent] Already processing a message");
return;
}
setIsProcessing(true);
setError(null);
try {
console.log("[useElectronAgent] Sending message", {
hasImages: images && images.length > 0,
imageCount: images?.length || 0
});
// Save images to temp files and get paths
let imagePaths: string[] | undefined;
if (images && images.length > 0) {
imagePaths = [];
for (const image of images) {
const result = await window.electronAPI.saveImageToTemp(
image.data,
image.filename,
image.mimeType
);
if (result.success && result.path) {
imagePaths.push(result.path);
console.log("[useElectronAgent] Saved image to temp:", result.path);
} else {
console.error("[useElectronAgent] Failed to save image:", result.error);
}
}
}
const result = await window.electronAPI.agent.send(
sessionId,
content,
workingDirectory,
imagePaths
);
if (!result.success) {
setError(result.error || "Failed to send message");
setIsProcessing(false);
}
// Note: We don't set isProcessing to false here because
// it will be set by the "complete" or "error" stream event
} catch (err) {
console.error("[useElectronAgent] Failed to send message:", err);
setError(err instanceof Error ? err.message : "Failed to send message");
setIsProcessing(false);
}
},
[sessionId, workingDirectory, isProcessing]
);
// Stop current execution
const stopExecution = useCallback(async () => {
if (!window.electronAPI?.agent) {
setError("Electron API not available");
return;
}
try {
console.log("[useElectronAgent] Stopping execution");
const result = await window.electronAPI.agent.stop(sessionId);
if (!result.success) {
setError(result.error || "Failed to stop execution");
} else {
setIsProcessing(false);
}
} catch (err) {
console.error("[useElectronAgent] Failed to stop:", err);
setError(err instanceof Error ? err.message : "Failed to stop execution");
}
}, [sessionId]);
// Clear conversation history
const clearHistory = useCallback(async () => {
if (!window.electronAPI?.agent) {
setError("Electron API not available");
return;
}
try {
console.log("[useElectronAgent] Clearing history");
const result = await window.electronAPI.agent.clear(sessionId);
if (result.success) {
setMessages([]);
setError(null);
} else {
setError(result.error || "Failed to clear history");
}
} catch (err) {
console.error("[useElectronAgent] Failed to clear:", err);
setError(err instanceof Error ? err.message : "Failed to clear history");
}
}, [sessionId]);
return {
messages,
isProcessing,
isConnected,
sendMessage,
stopExecution,
clearHistory,
error,
};
}

View File

@@ -0,0 +1,81 @@
import { useState, useCallback } from 'react';
import type { ImageAttachment } from '@/store/app-store';
export interface QueuedMessage {
id: string;
content: string;
images?: ImageAttachment[];
timestamp: Date;
}
interface UseMessageQueueOptions {
onProcessNext: (message: QueuedMessage) => Promise<void>;
}
interface UseMessageQueueResult {
queuedMessages: QueuedMessage[];
isProcessingQueue: boolean;
addToQueue: (content: string, images?: ImageAttachment[]) => void;
clearQueue: () => void;
removeFromQueue: (messageId: string) => void;
processNext: () => Promise<void>;
}
/**
* React hook for managing a queue of messages to be sent to the agent
*
* This allows users to queue up multiple messages while one is being processed,
* improving the chat experience by removing blocking behavior.
*/
export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseMessageQueueResult {
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const addToQueue = useCallback((content: string, images?: ImageAttachment[]) => {
const queuedMessage: QueuedMessage = {
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
content: content.trim(),
images,
timestamp: new Date(),
};
setQueuedMessages(prev => [...prev, queuedMessage]);
}, []);
const removeFromQueue = useCallback((messageId: string) => {
setQueuedMessages(prev => prev.filter(msg => msg.id !== messageId));
}, []);
const clearQueue = useCallback(() => {
setQueuedMessages([]);
}, []);
const processNext = useCallback(async () => {
if (queuedMessages.length === 0 || isProcessingQueue) {
return;
}
const nextMessage = queuedMessages[0];
setIsProcessingQueue(true);
try {
await onProcessNext(nextMessage);
// Remove the processed message from queue
setQueuedMessages(prev => prev.slice(1));
} catch (error) {
console.error('Error processing queued message:', error);
// Keep the message in queue for retry or manual removal
} finally {
setIsProcessingQueue(false);
}
}, [queuedMessages, isProcessingQueue, onProcessNext]);
return {
queuedMessages,
isProcessingQueue,
addToQueue,
clearQueue,
removeFromQueue,
processNext,
};
}

View File

@@ -41,6 +41,31 @@ export interface StatResult {
error?: string;
}
// Auto Mode types
export type AutoModePhase = "planning" | "action" | "verification";
export interface AutoModeEvent {
type: "auto_mode_feature_start" | "auto_mode_progress" | "auto_mode_tool" | "auto_mode_feature_complete" | "auto_mode_error" | "auto_mode_complete" | "auto_mode_phase";
featureId?: string;
feature?: object;
content?: string;
tool?: string;
input?: unknown;
passes?: boolean;
message?: string;
error?: string;
phase?: AutoModePhase;
}
export interface AutoModeAPI {
start: (projectPath: string) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{ success: boolean; isRunning?: boolean; currentFeatureId?: string | null; error?: string }>;
runFeature: (projectPath: string, featureId: string) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
verifyFeature: (projectPath: string, featureId: string) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
}
export interface ElectronAPI {
ping: () => Promise<string>;
openDirectory: () => Promise<DialogResult>;
@@ -52,6 +77,7 @@ export interface ElectronAPI {
exists: (filePath: string) => Promise<boolean>;
stat: (filePath: string) => Promise<StatResult>;
getPath: (name: string) => Promise<string>;
autoMode?: AutoModeAPI;
}
declare global {
@@ -254,9 +280,208 @@ export const getElectronAPI = (): ElectronAPI => {
}
return `/mock/${name}`;
},
// Mock Auto Mode API
autoMode: createMockAutoModeAPI(),
};
};
// Mock Auto Mode state and implementation
let mockAutoModeRunning = false;
let mockAutoModeCallbacks: ((event: AutoModeEvent) => void)[] = [];
let mockAutoModeTimeout: NodeJS.Timeout | null = null;
function createMockAutoModeAPI(): AutoModeAPI {
return {
start: async (projectPath: string) => {
if (mockAutoModeRunning) {
return { success: false, error: "Auto mode is already running" };
}
mockAutoModeRunning = true;
// Simulate auto mode with Plan-Act-Verify phases
simulateAutoModeLoop(projectPath);
return { success: true };
},
stop: async () => {
mockAutoModeRunning = false;
if (mockAutoModeTimeout) {
clearTimeout(mockAutoModeTimeout);
mockAutoModeTimeout = null;
}
return { success: true };
},
status: async () => {
return {
success: true,
isRunning: mockAutoModeRunning,
currentFeatureId: mockAutoModeRunning ? "feature-0" : null,
};
},
runFeature: async (projectPath: string, featureId: string) => {
if (mockAutoModeRunning) {
return { success: false, error: "Auto mode is already running" };
}
mockAutoModeRunning = true;
simulateAutoModeLoop(projectPath);
return { success: true, passes: true };
},
verifyFeature: async (projectPath: string, featureId: string) => {
if (mockAutoModeRunning) {
return { success: false, error: "Auto mode is already running" };
}
mockAutoModeRunning = true;
simulateAutoModeLoop(projectPath);
return { success: true, passes: true };
},
onEvent: (callback: (event: AutoModeEvent) => void) => {
mockAutoModeCallbacks.push(callback);
return () => {
mockAutoModeCallbacks = mockAutoModeCallbacks.filter(cb => cb !== callback);
};
},
};
}
function emitAutoModeEvent(event: AutoModeEvent) {
mockAutoModeCallbacks.forEach(cb => cb(event));
}
async function simulateAutoModeLoop(projectPath: string) {
const featureId = "feature-0";
const mockFeature = {
id: featureId,
category: "Core",
description: "Sample Feature",
steps: ["Step 1", "Step 2"],
passes: false,
};
// Start feature
emitAutoModeEvent({
type: "auto_mode_feature_start",
featureId,
feature: mockFeature,
});
await delay(300);
if (!mockAutoModeRunning) return;
// Phase 1: PLANNING
emitAutoModeEvent({
type: "auto_mode_phase",
featureId,
phase: "planning",
message: `Planning implementation for: ${mockFeature.description}`,
});
emitAutoModeEvent({
type: "auto_mode_progress",
featureId,
content: "Analyzing codebase structure and creating implementation plan...",
});
await delay(500);
if (!mockAutoModeRunning) return;
// Phase 2: ACTION
emitAutoModeEvent({
type: "auto_mode_phase",
featureId,
phase: "action",
message: `Executing implementation for: ${mockFeature.description}`,
});
emitAutoModeEvent({
type: "auto_mode_progress",
featureId,
content: "Starting code implementation...",
});
await delay(300);
if (!mockAutoModeRunning) return;
// Simulate tool use
emitAutoModeEvent({
type: "auto_mode_tool",
featureId,
tool: "Read",
input: { file: "package.json" },
});
await delay(300);
if (!mockAutoModeRunning) return;
emitAutoModeEvent({
type: "auto_mode_tool",
featureId,
tool: "Write",
input: { file: "src/feature.ts", content: "// Feature code" },
});
await delay(500);
if (!mockAutoModeRunning) return;
// Phase 3: VERIFICATION
emitAutoModeEvent({
type: "auto_mode_phase",
featureId,
phase: "verification",
message: `Verifying implementation for: ${mockFeature.description}`,
});
emitAutoModeEvent({
type: "auto_mode_progress",
featureId,
content: "Verifying implementation and checking test results...",
});
await delay(500);
if (!mockAutoModeRunning) return;
emitAutoModeEvent({
type: "auto_mode_progress",
featureId,
content: "✓ Verification successful: All tests passed",
});
// Feature complete
emitAutoModeEvent({
type: "auto_mode_feature_complete",
featureId,
passes: true,
message: "Feature implemented successfully",
});
await delay(300);
if (!mockAutoModeRunning) return;
// All features complete
emitAutoModeEvent({
type: "auto_mode_complete",
message: "All features completed!",
});
mockAutoModeRunning = false;
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => {
mockAutoModeTimeout = setTimeout(resolve, ms);
});
}
// Utility functions for project management
export interface Project {

View File

@@ -2,7 +2,7 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Project } from "@/lib/electron";
export type ViewMode = "welcome" | "spec" | "board" | "code" | "agent" | "settings" | "analysis" | "tools";
export type ViewMode = "welcome" | "spec" | "board" | "code" | "agent" | "settings" | "analysis" | "tools" | "interview";
export type ThemeMode = "light" | "dark" | "system";
export interface ApiKeys {
@@ -10,13 +10,38 @@ export interface ApiKeys {
google: string;
}
export interface ImageAttachment {
id: string;
data: string; // base64 encoded image data
mimeType: string; // e.g., "image/png", "image/jpeg"
filename: string;
size: number; // file size in bytes
}
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
images?: ImageAttachment[];
}
export interface ChatSession {
id: string;
title: string;
projectId: string;
messages: ChatMessage[];
createdAt: Date;
updatedAt: Date;
archived: boolean;
}
export interface Feature {
id: string;
category: string;
description: string;
steps: string[];
passes: boolean;
status: "backlog" | "planned" | "in_progress" | "review" | "verified" | "failed";
status: "backlog" | "in_progress" | "verified";
}
export interface FileTreeNode {
@@ -63,6 +88,27 @@ export interface AppState {
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
// Chat Sessions
chatSessions: ChatSession[];
currentChatSession: ChatSession | null;
chatHistoryOpen: boolean;
// Auto Mode
isAutoModeRunning: boolean;
currentAutoTask: string | null; // Feature ID being worked on
autoModeActivityLog: AutoModeActivity[];
}
export interface AutoModeActivity {
id: string;
featureId: string;
timestamp: Date;
type: "start" | "progress" | "tool" | "complete" | "error" | "planning" | "action" | "verification";
message: string;
tool?: string;
passes?: boolean;
phase?: "planning" | "action" | "verification";
}
export interface AppActions {
@@ -101,6 +147,23 @@ export interface AppActions {
setIsAnalyzing: (isAnalyzing: boolean) => void;
clearAnalysis: () => void;
// Chat Session actions
createChatSession: (title?: string) => ChatSession;
updateChatSession: (sessionId: string, updates: Partial<ChatSession>) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
setCurrentChatSession: (session: ChatSession | null) => void;
archiveChatSession: (sessionId: string) => void;
unarchiveChatSession: (sessionId: string) => void;
deleteChatSession: (sessionId: string) => void;
setChatHistoryOpen: (open: boolean) => void;
toggleChatHistory: () => void;
// Auto Mode actions
setAutoModeRunning: (running: boolean) => void;
setCurrentAutoTask: (taskId: string | null) => void;
addAutoModeActivity: (activity: Omit<AutoModeActivity, "id" | "timestamp">) => void;
clearAutoModeActivity: () => void;
// Reset
reset: () => void;
}
@@ -120,6 +183,12 @@ const initialState: AppState = {
},
projectAnalysis: null,
isAnalyzing: false,
chatSessions: [],
currentChatSession: null,
chatHistoryOpen: false,
isAutoModeRunning: false,
currentAutoTask: null,
autoModeActivityLog: [],
};
export const useAppStore = create<AppState & AppActions>()(
@@ -207,6 +276,124 @@ export const useAppStore = create<AppState & AppActions>()(
setIsAnalyzing: (isAnalyzing) => set({ isAnalyzing }),
clearAnalysis: () => set({ projectAnalysis: null, isAnalyzing: false }),
// Chat Session actions
createChatSession: (title) => {
const currentProject = get().currentProject;
if (!currentProject) {
throw new Error("No project selected");
}
const now = new Date();
const session: ChatSession = {
id: `chat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
title: title || `Chat ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}`,
projectId: currentProject.id,
messages: [{
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: now,
}],
createdAt: now,
updatedAt: now,
archived: false,
};
set({
chatSessions: [...get().chatSessions, session],
currentChatSession: session,
});
return session;
},
updateChatSession: (sessionId, updates) => {
set({
chatSessions: get().chatSessions.map((session) =>
session.id === sessionId
? { ...session, ...updates, updatedAt: new Date() }
: session
),
});
// Update current session if it's the one being updated
const currentSession = get().currentChatSession;
if (currentSession && currentSession.id === sessionId) {
set({
currentChatSession: { ...currentSession, ...updates, updatedAt: new Date() }
});
}
},
addMessageToSession: (sessionId, message) => {
const sessions = get().chatSessions;
const sessionIndex = sessions.findIndex(s => s.id === sessionId);
if (sessionIndex >= 0) {
const updatedSessions = [...sessions];
updatedSessions[sessionIndex] = {
...updatedSessions[sessionIndex],
messages: [...updatedSessions[sessionIndex].messages, message],
updatedAt: new Date(),
};
set({ chatSessions: updatedSessions });
// Update current session if it's the one being updated
const currentSession = get().currentChatSession;
if (currentSession && currentSession.id === sessionId) {
set({
currentChatSession: updatedSessions[sessionIndex]
});
}
}
},
setCurrentChatSession: (session) => {
set({ currentChatSession: session });
},
archiveChatSession: (sessionId) => {
get().updateChatSession(sessionId, { archived: true });
},
unarchiveChatSession: (sessionId) => {
get().updateChatSession(sessionId, { archived: false });
},
deleteChatSession: (sessionId) => {
const currentSession = get().currentChatSession;
set({
chatSessions: get().chatSessions.filter((s) => s.id !== sessionId),
currentChatSession: currentSession?.id === sessionId ? null : currentSession,
});
},
setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }),
toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }),
// Auto Mode actions
setAutoModeRunning: (running) => set({ isAutoModeRunning: running }),
setCurrentAutoTask: (taskId) => set({ currentAutoTask: taskId }),
addAutoModeActivity: (activity) => {
const id = `activity-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newActivity: AutoModeActivity = {
...activity,
id,
timestamp: new Date(),
};
// Keep only the last 100 activities to avoid memory issues
const currentLog = get().autoModeActivityLog;
const updatedLog = [...currentLog, newActivity].slice(-100);
set({ autoModeActivityLog: updatedLog });
},
clearAutoModeActivity: () => set({ autoModeActivityLog: [] }),
// Reset
reset: () => set(initialState),
}),
@@ -217,6 +404,8 @@ export const useAppStore = create<AppState & AppActions>()(
theme: state.theme,
sidebarOpen: state.sidebarOpen,
apiKeys: state.apiKeys,
chatSessions: state.chatSessions,
chatHistoryOpen: state.chatHistoryOpen,
}),
}
)

310
app/src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,310 @@
/**
* Electron API type definitions
*/
export interface ImageAttachment {
id: string;
data: string; // base64 encoded image data
mimeType: string; // e.g., "image/png", "image/jpeg"
filename: string;
size: number; // file size in bytes
}
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp: string;
isError?: boolean;
images?: ImageAttachment[];
}
export interface ToolUse {
name: string;
input: unknown;
}
export type StreamEvent =
| {
type: "message";
sessionId: string;
message: Message;
}
| {
type: "stream";
sessionId: string;
messageId: string;
content: string;
isComplete: boolean;
}
| {
type: "tool_use";
sessionId: string;
tool: ToolUse;
}
| {
type: "complete";
sessionId: string;
messageId?: string;
content: string;
toolUses: ToolUse[];
}
| {
type: "error";
sessionId: string;
error: string;
message?: Message;
};
export interface SessionListItem {
id: string;
name: string;
projectPath: string;
createdAt: string;
updatedAt: string;
messageCount: number;
isArchived: boolean;
isDirty?: boolean; // Indicates session has completed work that needs review
tags: string[];
preview: string;
}
export interface AgentAPI {
start: (
sessionId: string,
workingDirectory?: string
) => Promise<{
success: boolean;
messages?: Message[];
sessionId?: string;
error?: string;
}>;
send: (
sessionId: string,
message: string,
workingDirectory?: string,
imagePaths?: string[]
) => Promise<{
success: boolean;
error?: string;
}>;
getHistory: (sessionId: string) => Promise<{
success: boolean;
messages?: Message[];
isRunning?: boolean;
error?: string;
}>;
stop: (sessionId: string) => Promise<{
success: boolean;
error?: string;
}>;
clear: (sessionId: string) => Promise<{
success: boolean;
error?: string;
}>;
onStream: (callback: (event: StreamEvent) => void) => () => void;
}
export interface SessionsAPI {
list: (includeArchived?: boolean) => Promise<{
success: boolean;
sessions?: SessionListItem[];
error?: string;
}>;
create: (
name: string,
projectPath: string,
workingDirectory?: string
) => Promise<{
success: boolean;
sessionId?: string;
session?: unknown;
error?: string;
}>;
update: (
sessionId: string,
name?: string,
tags?: string[]
) => Promise<{
success: boolean;
error?: string;
}>;
archive: (sessionId: string) => Promise<{
success: boolean;
error?: string;
}>;
unarchive: (sessionId: string) => Promise<{
success: boolean;
error?: string;
}>;
delete: (sessionId: string) => Promise<{
success: boolean;
error?: string;
}>;
markClean: (sessionId: string) => Promise<{
success: boolean;
error?: string;
}>;
}
export type AutoModeEvent =
| {
type: "auto_mode_feature_start";
featureId: string;
feature: unknown;
}
| {
type: "auto_mode_progress";
featureId: string;
content: string;
}
| {
type: "auto_mode_tool";
featureId: string;
tool: string;
input: unknown;
}
| {
type: "auto_mode_feature_complete";
featureId: string;
passes: boolean;
message: string;
}
| {
type: "auto_mode_error";
error: string;
featureId?: string;
}
| {
type: "auto_mode_complete";
message: string;
}
| {
type: "auto_mode_phase";
featureId: string;
phase: "planning" | "action" | "verification";
message: string;
};
export interface AutoModeAPI {
start: (projectPath: string) => Promise<{
success: boolean;
error?: string;
}>;
stop: () => Promise<{
success: boolean;
error?: string;
}>;
status: () => Promise<{
success: boolean;
isRunning?: boolean;
currentFeatureId?: string | null;
error?: string;
}>;
runFeature: (projectPath: string, featureId: string) => Promise<{
success: boolean;
passes?: boolean;
error?: string;
}>;
verifyFeature: (projectPath: string, featureId: string) => Promise<{
success: boolean;
passes?: boolean;
error?: string;
}>;
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
}
export interface ElectronAPI {
ping: () => Promise<string>;
// Dialog APIs
openDirectory: () => Promise<{
canceled: boolean;
filePaths: string[];
}>;
openFile: (options?: unknown) => Promise<{
canceled: boolean;
filePaths: string[];
}>;
// File system APIs
readFile: (filePath: string) => Promise<{
success: boolean;
content?: string;
error?: string;
}>;
writeFile: (filePath: string, content: string) => Promise<{
success: boolean;
error?: string;
}>;
mkdir: (dirPath: string) => Promise<{
success: boolean;
error?: string;
}>;
readdir: (dirPath: string) => Promise<{
success: boolean;
entries?: Array<{
name: string;
isDirectory: boolean;
isFile: boolean;
}>;
error?: string;
}>;
exists: (filePath: string) => Promise<boolean>;
stat: (filePath: string) => Promise<{
success: boolean;
stats?: {
isDirectory: boolean;
isFile: boolean;
size: number;
mtime: Date;
};
error?: string;
}>;
// App APIs
getPath: (name: string) => Promise<string>;
saveImageToTemp: (
data: string,
filename: string,
mimeType: string
) => Promise<{
success: boolean;
path?: string;
error?: string;
}>;
// Agent APIs
agent: AgentAPI;
// Session Management APIs
sessions: SessionsAPI;
// Auto Mode APIs
autoMode: AutoModeAPI;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
isElectron: boolean;
}
}
export {};

31
app/src/types/session.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Session types for agent conversations
*/
export interface AgentSession {
id: string;
name: string;
projectPath: string;
createdAt: string;
updatedAt: string;
messageCount: number;
isArchived: boolean;
isDirty?: boolean; // Indicates session has completed work that needs review
tags?: string[];
}
export interface SessionListItem extends AgentSession {
preview?: string; // Last message preview
}
export interface CreateSessionParams {
name: string;
projectPath: string;
workingDirectory?: string;
}
export interface UpdateSessionParams {
id: string;
name?: string;
tags?: string[];
}