import { useState, useCallback } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { useNavigate } from '@tanstack/react-router'; import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useOSDetection } from '@/hooks/use-os-detection'; import { getElectronAPI, isElectron } from '@/lib/electron'; import { initializeProject } from '@/lib/project-init'; import { getHttpApiClient } from '@/lib/http-api-client'; import { isMac } from '@/lib/utils'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal'; import type { StarterTemplate } from '@/lib/templates'; import { FolderOpen, Plus, Folder, Star, Clock, ChevronDown, MessageSquare, MoreVertical, Trash2, Search, X, type LucideIcon, } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { Input } from '@/components/ui/input'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; const logger = createLogger('DashboardView'); function getOSAbbreviation(os: string): string { switch (os) { case 'mac': return 'M'; case 'windows': return 'W'; case 'linux': return 'L'; default: return '?'; } } function getIconComponent(iconName?: string): LucideIcon { if (iconName && iconName in LucideIcons) { return (LucideIcons as unknown as Record)[iconName]; } return Folder; } export function DashboardView() { const navigate = useNavigate(); const { os } = useOSDetection(); const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; const appMode = import.meta.env.VITE_APP_MODE || '?'; const versionSuffix = `${getOSAbbreviation(os)}${appMode}`; const { projects, trashedProjects, currentProject, upsertAndSetCurrentProject, addProject, setCurrentProject, toggleProjectFavorite, moveProjectToTrash, theme: globalTheme, } = useAppStore(); const [showNewProjectModal, setShowNewProjectModal] = useState(false); const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); const [isCreating, setIsCreating] = useState(false); const [isOpening, setIsOpening] = useState(false); const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null); const [searchQuery, setSearchQuery] = useState(''); // Sort projects: favorites first, then by last opened const sortedProjects = [...projects].sort((a, b) => { // Favorites first if (a.isFavorite && !b.isFavorite) return -1; if (!a.isFavorite && b.isFavorite) return 1; // Then by last opened const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0; const dateB = b.lastOpened ? new Date(b.lastOpened).getTime() : 0; return dateB - dateA; }); // Filter projects based on search query const filteredProjects = sortedProjects.filter((project) => { if (!searchQuery.trim()) return true; const query = searchQuery.toLowerCase(); return project.name.toLowerCase().includes(query) || project.path.toLowerCase().includes(query); }); const favoriteProjects = filteredProjects.filter((p) => p.isFavorite); const recentProjects = filteredProjects.filter((p) => !p.isFavorite); /** * Initialize project and navigate to board */ const initializeAndOpenProject = useCallback( async (path: string, name: string) => { setIsOpening(true); try { const initResult = await initializeProject(path); if (!initResult.success) { // If the project directory doesn't exist, automatically remove it from the project list if (initResult.error?.includes('does not exist')) { const projectToRemove = projects.find((p) => p.path === path); if (projectToRemove) { logger.warn(`[Dashboard] Removing project with non-existent path: ${path}`); moveProjectToTrash(projectToRemove.id); toast.error('Project directory not found', { description: `Removed ${name} from your projects list since the directory no longer exists.`, }); return; } } toast.error('Failed to initialize project', { description: initResult.error || 'Unknown error occurred', }); return; } const trashedProject = trashedProjects.find((p) => p.path === path); const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || (currentProject?.theme as ThemeMode | undefined) || globalTheme; upsertAndSetCurrentProject(path, name, effectiveTheme); toast.success('Project opened', { description: `Opened ${name}`, }); navigate({ to: '/board' }); } catch (error) { logger.error('[Dashboard] Failed to open project:', error); toast.error('Failed to open project', { description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsOpening(false); } }, [ projects, trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate, moveProjectToTrash, ] ); const handleOpenProject = useCallback(async () => { try { const httpClient = getHttpApiClient(); const configResult = await httpClient.workspace.getConfig(); if (configResult.success && configResult.configured) { setShowWorkspacePicker(true); } else { const api = getElectronAPI(); const result = await api.openDirectory(); if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; await initializeAndOpenProject(path, name); } } } catch (error) { logger.error('[Dashboard] Failed to check workspace config:', error); const api = getElectronAPI(); const result = await api.openDirectory(); if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; await initializeAndOpenProject(path, name); } } }, [initializeAndOpenProject]); const handleWorkspaceSelect = useCallback( async (path: string, name: string) => { setShowWorkspacePicker(false); await initializeAndOpenProject(path, name); }, [initializeAndOpenProject] ); const handleProjectClick = useCallback( async (project: { id: string; name: string; path: string }) => { await initializeAndOpenProject(project.path, project.name); }, [initializeAndOpenProject] ); const handleToggleFavorite = useCallback( (e: React.MouseEvent, projectId: string) => { e.stopPropagation(); toggleProjectFavorite(projectId); }, [toggleProjectFavorite] ); const handleRemoveProject = useCallback( (e: React.MouseEvent, project: { id: string; name: string }) => { e.stopPropagation(); setProjectToRemove(project); }, [] ); const handleConfirmRemove = useCallback(() => { if (projectToRemove) { moveProjectToTrash(projectToRemove.id); toast.success('Project removed', { description: `${projectToRemove.name} has been removed from your projects list`, }); setProjectToRemove(null); } }, [projectToRemove, moveProjectToTrash]); const handleNewProject = () => { setShowNewProjectModal(true); }; const handleInteractiveMode = () => { navigate({ to: '/interview' }); }; const handleCreateBlankProject = async (projectName: string, parentDir: string) => { setIsCreating(true); try { const api = getElectronAPI(); const projectPath = `${parentDir}/${projectName}`; const parentExists = await api.exists(parentDir); if (!parentExists) { toast.error('Parent directory does not exist', { description: `Cannot create project in non-existent directory: ${parentDir}`, }); return; } const parentStat = await api.stat(parentDir); if (parentStat && !parentStat.stats?.isDirectory) { toast.error('Parent path is not a directory', { description: `${parentDir} is not a directory`, }); return; } const mkdirResult = await api.mkdir(projectPath); if (!mkdirResult.success) { toast.error('Failed to create project directory', { description: mkdirResult.error || 'Unknown error occurred', }); return; } const initResult = await initializeProject(projectPath); if (!initResult.success) { toast.error('Failed to initialize project', { description: initResult.error || 'Unknown error occurred', }); return; } await api.writeFile( `${projectPath}/.automaker/app_spec.txt`, ` ${projectName} Describe your project here. This file will be analyzed by an AI agent to understand your project structure and tech stack. ` ); const project = { id: `project-${Date.now()}`, name: projectName, path: projectPath, lastOpened: new Date().toISOString(), }; addProject(project); setCurrentProject(project); setShowNewProjectModal(false); toast.success('Project created', { description: `Created ${projectName}`, }); navigate({ to: '/board' }); } catch (error) { logger.error('Failed to create project:', error); toast.error('Failed to create project', { description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); } }; const handleCreateFromTemplate = async ( template: StarterTemplate, projectName: string, parentDir: string ) => { setIsCreating(true); try { const httpClient = getHttpApiClient(); const api = getElectronAPI(); const cloneResult = await httpClient.templates.clone( template.repoUrl, projectName, parentDir ); if (!cloneResult.success || !cloneResult.projectPath) { toast.error('Failed to clone template', { description: cloneResult.error || 'Unknown error occurred', }); return; } const projectPath = cloneResult.projectPath; const initResult = await initializeProject(projectPath); if (!initResult.success) { toast.error('Failed to initialize project', { description: initResult.error || 'Unknown error occurred', }); return; } await api.writeFile( `${projectPath}/.automaker/app_spec.txt`, ` ${projectName} This project was created from the "${template.name}" starter template. ${template.description} ${template.techStack.map((tech) => `${tech}`).join('\n ')} ${template.features.map((feature) => `${feature}`).join('\n ')} ` ); const project = { id: `project-${Date.now()}`, name: projectName, path: projectPath, lastOpened: new Date().toISOString(), }; addProject(project); setCurrentProject(project); setShowNewProjectModal(false); toast.success('Project created from template', { description: `Created ${projectName} from ${template.name}`, }); navigate({ to: '/board' }); } catch (error) { logger.error('Failed to create project from template:', error); toast.error('Failed to create project', { description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); } }; const handleCreateFromCustomUrl = async ( repoUrl: string, projectName: string, parentDir: string ) => { setIsCreating(true); try { const httpClient = getHttpApiClient(); const api = getElectronAPI(); const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir); if (!cloneResult.success || !cloneResult.projectPath) { toast.error('Failed to clone repository', { description: cloneResult.error || 'Unknown error occurred', }); return; } const projectPath = cloneResult.projectPath; const initResult = await initializeProject(projectPath); if (!initResult.success) { toast.error('Failed to initialize project', { description: initResult.error || 'Unknown error occurred', }); return; } await api.writeFile( `${projectPath}/.automaker/app_spec.txt`, ` ${projectName} This project was cloned from ${repoUrl}. The AI agent will analyze the project structure. ` ); const project = { id: `project-${Date.now()}`, name: projectName, path: projectPath, lastOpened: new Date().toISOString(), }; addProject(project); setCurrentProject(project); setShowNewProjectModal(false); toast.success('Project created from repository', { description: `Created ${projectName}`, }); navigate({ to: '/board' }); } catch (error) { logger.error('Failed to create project from custom URL:', error); toast.error('Failed to create project', { description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreating(false); } }; const hasProjects = projects.length > 0; return (
{/* Header with logo */}
{/* Electron titlebar drag region */} {isElectron() && (
{/* Main content */}
{/* No projects - show getting started */} {!hasProjects && (

Welcome to Automaker

Your autonomous AI development studio. Get started by creating a new project or opening an existing one.

{/* New Project Card */}

New Project

Create a new project from scratch with AI-powered development

Quick Setup Interactive Mode
{/* Open Project Card */}

Open Project

Open an existing project folder to continue working

)} {/* Has projects - show project list */} {hasProjects && (
{/* Search and actions header */}

Your Projects

{/* Search input */}
setSearchQuery(e.target.value)} className="pl-9 pr-8 w-full sm:w-64" data-testid="project-search-input" /> {searchQuery && ( )}
{/* Desktop only buttons */} Quick Setup Interactive Mode
{/* Favorites section */} {favoriteProjects.length > 0 && (

Favorites

{favoriteProjects.map((project) => (
handleProjectClick(project)} data-testid={`project-card-${project.id}`} >
{project.customIconPath ? ( {project.name} ) : ( (() => { const IconComponent = getIconComponent(project.icon); return ( ); })() )}

{project.name}

{project.path}

{project.lastOpened && (

{new Date(project.lastOpened).toLocaleDateString()}

)}
handleRemoveProject(e, project)} className="text-destructive focus:text-destructive" > Remove from Automaker
))}
)} {/* Recent projects section */} {recentProjects.length > 0 && (

Recent Projects

{recentProjects.map((project) => (
handleProjectClick(project)} data-testid={`project-card-${project.id}`} >
{project.customIconPath ? ( {project.name} ) : ( (() => { const IconComponent = getIconComponent(project.icon); return ( ); })() )}

{project.name}

{project.path}

{project.lastOpened && (

{new Date(project.lastOpened).toLocaleDateString()}

)}
handleRemoveProject(e, project)} className="text-destructive focus:text-destructive" > Remove from Automaker
))}
)} {/* No search results */} {searchQuery && favoriteProjects.length === 0 && recentProjects.length === 0 && (

No projects found

No projects match "{searchQuery}"

)}
)}
{/* Modals */} {/* Remove project confirmation dialog */} !open && setProjectToRemove(null)}> Remove Project Are you sure you want to remove {projectToRemove?.name} from Automaker?

This will only remove the project from your Automaker projects list. The project files on your computer will not be deleted.

{/* Loading overlay */} {isOpening && (

Opening project...

)}
); }