diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 2b8c1e8a..e59c6744 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -1,92 +1,35 @@ import { useState, useCallback } from 'react'; import { useNavigate, useLocation } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; -import { useAppStore, formatShortcut, type ThemeMode } from '@/store/app-store'; -import { - Settings, - Folder, - X, - ChevronDown, - Redo2, - Check, - GripVertical, - RotateCcw, - Trash2, - Undo2, - MoreVertical, - Palette, - Monitor, - Search, - Activity, - Sparkles, - Loader2, - Rocket, - Zap, - CheckCircle2, - ArrowRight, - Moon, - Sun, -} from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuLabel, -} from '@/components/ui/dropdown-menu'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; +import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; -import { getElectronAPI, Project, TrashedProject, RunningAgent } from '@/lib/electron'; +import { getElectronAPI } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { toast } from 'sonner'; -import { themeOptions } from '@/config/theme-options'; import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; -import type { FeatureCount } from '@/components/views/spec-view/types'; -import { DndContext, closestCenter } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import type { StarterTemplate } from '@/lib/templates'; // Local imports from subfolder import { - SortableProjectItem, - ThemeMenuItem, - BugReportButton, CollapseToggleButton, SidebarHeader, ProjectActions, SidebarNavigation, + ProjectSelectorWithOptions, + SidebarFooter, } from './sidebar/components'; +import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; +import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; import { - PROJECT_DARK_THEMES, - PROJECT_LIGHT_THEMES, - SIDEBAR_FEATURE_FLAGS, -} from './sidebar/constants'; -import { - useThemePreview, useSidebarAutoCollapse, - useDragAndDrop, useRunningAgents, - useTrashOperations, - useProjectPicker, useSpecRegeneration, useNavigation, + useProjectCreation, + useSetupDialog, + useTrashDialog, + useProjectTheme, } from './sidebar/hooks'; export function Sidebar() { @@ -100,19 +43,12 @@ export function Sidebar() { sidebarOpen, projectHistory, upsertAndSetCurrentProject, - setCurrentProject, toggleSidebar, restoreTrashedProject, deleteTrashedProject, emptyTrash, - reorderProjects, cyclePrevProject, cycleNextProject, - clearProjectHistory, - setProjectTheme, - setTheme, - setPreviewTheme, - theme: globalTheme, moveProjectToTrash, specCreatingForProject, setSpecCreatingForProject, @@ -125,33 +61,61 @@ export function Sidebar() { // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); - // State for project picker dropdown + // State for project picker (needed for keyboard shortcuts) const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); - const [showTrashDialog, setShowTrashDialog] = useState(false); // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); - // State for new project modal - const [showNewProjectModal, setShowNewProjectModal] = useState(false); - const [isCreatingProject, setIsCreatingProject] = useState(false); + // Project theme management (must come before useProjectCreation which uses globalTheme) + const { globalTheme } = useProjectTheme(); - // State for new project onboarding dialog - const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); - const [newProjectName, setNewProjectName] = useState(''); - const [newProjectPath, setNewProjectPath] = useState(''); + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + }); - // State for new project setup dialog - const [showSetupDialog, setShowSetupDialog] = useState(false); - const [setupProjectPath, setSetupProjectPath] = useState(''); - const [projectOverview, setProjectOverview] = useState(''); - const [generateFeatures, setGenerateFeatures] = useState(true); - const [analyzeProject, setAnalyzeProject] = useState(true); - const [featureCount, setFeatureCount] = useState(50); - const [showSpecIndicator, setShowSpecIndicator] = useState(true); - - // Debounced preview theme handlers - const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + // Setup dialog state and handlers + const { + showSetupDialog, + setShowSetupDialog, + setupProjectPath, + setSetupProjectPath, + projectOverview, + setProjectOverview, + generateFeatures, + setGenerateFeatures, + analyzeProject, + setAnalyzeProject, + featureCount, + setFeatureCount, + handleCreateInitialSpec, + handleSkipSetup, + handleOnboardingGenerateSpec, + handleOnboardingSkip, + } = useSetupDialog({ + setSpecCreatingForProject, + newProjectPath, + setNewProjectName, + setNewProjectPath, + setShowOnboardingDialog, + }); // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; @@ -160,36 +124,19 @@ export function Sidebar() { // Auto-collapse sidebar on small screens useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); - // Project picker with search and keyboard navigation - const { - projectSearchQuery, - setProjectSearchQuery, - selectedProjectIndex, - setSelectedProjectIndex, - projectSearchInputRef, - filteredProjects, - selectHighlightedProject, - } = useProjectPicker({ - projects, - isProjectPickerOpen, - setIsProjectPickerOpen, - setCurrentProject, - }); - - // Drag-and-drop for project reordering - const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); - // Running agents count const { runningAgentsCount } = useRunningAgents(); - // Trash operations + // Trash dialog and operations const { + showTrashDialog, + setShowTrashDialog, activeTrashId, isEmptyingTrash, handleRestoreProject, handleDeleteProjectFromDisk, handleEmptyTrash, - } = useTrashOperations({ + } = useTrashDialog({ restoreTrashedProject, deleteTrashedProject, emptyTrash, @@ -208,355 +155,6 @@ export function Sidebar() { setNewProjectPath, }); - // Handle creating initial spec for new project - const handleCreateInitialSpec = useCallback(async () => { - if (!setupProjectPath || !projectOverview.trim()) return; - - // Set store state immediately so the loader shows up right away - setSpecCreatingForProject(setupProjectPath); - setShowSpecIndicator(true); - setShowSetupDialog(false); - - try { - const api = getElectronAPI(); - if (!api.specRegeneration) { - toast.error('Spec regeneration not available'); - setSpecCreatingForProject(null); - return; - } - const result = await api.specRegeneration.create( - setupProjectPath, - projectOverview.trim(), - generateFeatures, - analyzeProject, - generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features - ); - - if (!result.success) { - console.error('[Sidebar] Failed to start spec creation:', result.error); - setSpecCreatingForProject(null); - toast.error('Failed to create specification', { - description: result.error, - }); - } else { - // Show processing toast to inform user - toast.info('Generating app specification...', { - description: "This may take a minute. You'll be notified when complete.", - }); - } - // If successful, we'll wait for the events to update the state - } catch (error) { - console.error('[Sidebar] Failed to create spec:', error); - setSpecCreatingForProject(null); - toast.error('Failed to create specification', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } - }, [ - setupProjectPath, - projectOverview, - generateFeatures, - analyzeProject, - featureCount, - setSpecCreatingForProject, - ]); - - // Handle skipping setup - const handleSkipSetup = useCallback(() => { - setShowSetupDialog(false); - setProjectOverview(''); - setSetupProjectPath(''); - // Clear onboarding state if we came from onboarding - if (newProjectPath) { - setNewProjectName(''); - setNewProjectPath(''); - } - toast.info('Setup skipped', { - description: 'You can set up your app_spec.txt later from the Spec view.', - }); - }, [newProjectPath]); - - // Handle onboarding dialog - generate spec - const handleOnboardingGenerateSpec = useCallback(() => { - setShowOnboardingDialog(false); - // Navigate to the setup dialog flow - setSetupProjectPath(newProjectPath); - setProjectOverview(''); - setShowSetupDialog(true); - }, [newProjectPath]); - - // Handle onboarding dialog - skip - const handleOnboardingSkip = useCallback(() => { - setShowOnboardingDialog(false); - setNewProjectName(''); - setNewProjectPath(''); - toast.info('You can generate your app_spec.txt anytime from the Spec view', { - description: 'Your project is ready to use!', - }); - }, []); - - /** - * Create a blank project with just .automaker directory structure - */ - const handleCreateBlankProject = useCallback( - async (projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const api = getElectronAPI(); - const projectPath = `${parentDir}/${projectName}`; - - // Create project directory - const mkdirResult = await api.mkdir(projectPath); - if (!mkdirResult.success) { - toast.error('Failed to create project directory', { - description: mkdirResult.error || 'Unknown error occurred', - }); - return; - } - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with the project name - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - 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 trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); - - setShowNewProjectModal(false); - - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); - - toast.success('Project created', { - description: `Created ${projectName} with .automaker directory`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); - - /** - * Create a project from a GitHub starter template - */ - const handleCreateFromTemplate = useCallback( - async (template: StarterTemplate, projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const httpClient = getHttpApiClient(); - const api = getElectronAPI(); - - // Clone the template repository - 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; - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with template-specific info - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - 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 trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); - - setShowNewProjectModal(false); - - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); - - toast.success('Project created from template', { - description: `Created ${projectName} from ${template.name}`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project from template:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); - - /** - * Create a project from a custom GitHub URL - */ - const handleCreateFromCustomUrl = useCallback( - async (repoUrl: string, projectName: string, parentDir: string) => { - setIsCreatingProject(true); - try { - const httpClient = getHttpApiClient(); - const api = getElectronAPI(); - - // Clone the repository - 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; - - // Initialize .automaker directory with all necessary files - const initResult = await initializeProject(projectPath); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Update the app_spec.txt with basic info - // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts - await api.writeFile( - `${projectPath}/.automaker/app_spec.txt`, - ` - ${projectName} - - - This project was cloned from ${repoUrl}. - The AI agent will analyze the project structure. - - - - - - - - - - - - - -` - ); - - const trashedProject = trashedProjects.find((p) => p.path === projectPath); - const effectiveTheme = - (trashedProject?.theme as ThemeMode | undefined) || - (currentProject?.theme as ThemeMode | undefined) || - globalTheme; - const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); - - setShowNewProjectModal(false); - - // Show onboarding dialog for new project - setNewProjectName(projectName); - setNewProjectPath(projectPath); - setShowOnboardingDialog(true); - - toast.success('Project created from repository', { - description: `Created ${projectName} from ${repoUrl}`, - }); - } catch (error) { - console.error('[Sidebar] Failed to create project from URL:', error); - toast.error('Failed to create project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } finally { - setIsCreatingProject(false); - } - }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] - ); - // Handle bug report button click const handleBugReportClick = useCallback(() => { const api = getElectronAPI(); @@ -597,7 +195,7 @@ export function Sidebar() { (trashedProject?.theme as ThemeMode | undefined) || (currentProject?.theme as ThemeMode | undefined) || globalTheme; - const project = upsertAndSetCurrentProject(path, name, effectiveTheme); + upsertAndSetCurrentProject(path, name, effectiveTheme); // Check if app_spec.txt exists const specExists = await hasAppSpec(path); @@ -692,290 +290,12 @@ export function Sidebar() { /> )} - {/* Project Selector with Cycle Buttons */} - {sidebarOpen && projects.length > 0 && ( -
- - - - - - {/* Search input for type-ahead filtering */} -
-
- - setProjectSearchQuery(e.target.value)} - className={cn( - 'w-full h-9 pl-8 pr-3 text-sm rounded-lg', - 'border border-border bg-background/50', - 'text-foreground placeholder:text-muted-foreground', - 'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50', - 'transition-all duration-200' - )} - data-testid="project-search-input" - /> -
-
- - {filteredProjects.length === 0 ? ( -
- No projects found -
- ) : ( - - p.id)} - strategy={verticalListSortingStrategy} - > -
- {filteredProjects.map((project, index) => ( - { - setCurrentProject(p); - setIsProjectPickerOpen(false); - }} - /> - ))} -
-
-
- )} - - {/* Keyboard hint */} -
-

- arrow navigate{' '} - |{' '} - enter select{' '} - |{' '} - esc close -

-
-
-
- - {/* Project Options Menu - theme and history */} - {currentProject && ( - { - // Clear preview theme when the menu closes - if (!open) { - setPreviewTheme(null); - } - }} - > - - - - - {/* Project Theme Submenu */} - - - - Project Theme - {currentProject.theme && ( - - {currentProject.theme} - - )} - - { - // Clear preview theme when leaving the dropdown - setPreviewTheme(null); - }} - > - {/* Use Global Option */} - { - if (currentProject) { - setPreviewTheme(null); - if (value !== '') { - setTheme(value as any); - } else { - setTheme(globalTheme); - } - setProjectTheme( - currentProject.id, - value === '' ? null : (value as any) - ); - } - }} - > -
handlePreviewEnter(globalTheme)} - onPointerLeave={() => setPreviewTheme(null)} - > - - - Use Global - - ({globalTheme}) - - -
- - {/* Two Column Layout */} -
- {/* Dark Themes Column */} -
-
- - Dark -
-
- {PROJECT_DARK_THEMES.map((option) => ( - - ))} -
-
- {/* Light Themes Column */} -
-
- - Light -
-
- {PROJECT_LIGHT_THEMES.map((option) => ( - - ))} -
-
-
-
-
-
- - {/* Project History Section - only show when there's history */} - {projectHistory.length > 1 && ( - <> - - - Project History - - - - Previous - - {formatShortcut(shortcuts.cyclePrevProject, true)} - - - - - Next - - {formatShortcut(shortcuts.cycleNextProject, true)} - - - - - Clear history - - - )} - - {/* Move to Trash Section */} - - setShowDeleteProjectDialog(true)} - className="text-destructive focus:text-destructive focus:bg-destructive/10" - data-testid="move-project-to-trash" - > - - Move to Trash - -
-
- )} -
- )} + - {/* Bottom Section - Running Agents / Bug Report / Settings */} -
- {/* Wiki Link */} - {!hideWiki && ( -
- -
- )} - {/* Running Agents Link */} - {!hideRunningAgents && ( -
- -
- )} - {/* Settings Link */} -
- -
-
- - - - Recycle Bin - - Restore projects to the sidebar or delete their folders using your system Trash. - - - - {trashedProjects.length === 0 ? ( -

Recycle bin is empty.

- ) : ( -
- {trashedProjects.map((project) => ( -
-
-

{project.name}

-

{project.path}

-

- Trashed {new Date(project.trashedAt).toLocaleString()} -

-
-
- - - -
-
- ))} -
- )} - - - - {trashedProjects.length > 0 && ( - - )} - -
-
+ + {/* New Project Setup Dialog */} - {/* New Project Onboarding Dialog */} - { - if (!open) { - handleOnboardingSkip(); - } - }} - > - - -
-
- -
-
- Welcome to {newProjectName}! - - Your new project is ready. Let's get you started. - -
-
-
- -
- {/* Main explanation */} -
-

- Would you like to auto-generate your app_spec.txt? This file helps - describe your project and is used to pre-populate your backlog with features to work - on. -

-
- - {/* Benefits list */} -
-
- -
-

Pre-populate your backlog

-

- Automatically generate features based on your project specification -

-
-
-
- -
-

Better AI assistance

-

- Help AI agents understand your project structure and tech stack -

-
-
-
- -
-

Project documentation

-

- Keep a clear record of your project's capabilities and features -

-
-
-
- - {/* Info box */} -
-

- Tip: You can always generate or edit - your app_spec.txt later from the Spec Editor in the sidebar. -

-
-
- - - - - -
-
+ onOpenChange={setShowOnboardingDialog} + newProjectName={newProjectName} + onSkip={handleOnboardingSkip} + onGenerateSpec={handleOnboardingGenerateSpec} + /> {/* Delete Project Confirmation Dialog */} void; + setShowDeleteProjectDialog: (show: boolean) => void; +} + +export function ProjectSelectorWithOptions({ + sidebarOpen, + isProjectPickerOpen, + setIsProjectPickerOpen, + setShowDeleteProjectDialog, +}: ProjectSelectorWithOptionsProps) { + // Get data from store + const { + projects, + currentProject, + projectHistory, + setCurrentProject, + reorderProjects, + cyclePrevProject, + cycleNextProject, + clearProjectHistory, + } = useAppStore(); + + // Get keyboard shortcuts + const shortcuts = useKeyboardShortcutsConfig(); + const { + projectSearchQuery, + setProjectSearchQuery, + selectedProjectIndex, + projectSearchInputRef, + filteredProjects, + } = useProjectPicker({ + projects, + isProjectPickerOpen, + setIsProjectPickerOpen, + setCurrentProject, + }); + + // Drag-and-drop handlers + const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); + + // Theme management + const { + globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + handlePreviewEnter, + handlePreviewLeave, + } = useProjectTheme(); + + if (!sidebarOpen || projects.length === 0) { + return null; + } + + return ( +
+ + + + + + {/* Search input for type-ahead filtering */} +
+
+ + setProjectSearchQuery(e.target.value)} + className={cn( + 'w-full h-9 pl-8 pr-3 text-sm rounded-lg', + 'border border-border bg-background/50', + 'text-foreground placeholder:text-muted-foreground', + 'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50', + 'transition-all duration-200' + )} + data-testid="project-search-input" + /> +
+
+ + {filteredProjects.length === 0 ? ( +
+ No projects found +
+ ) : ( + + p.id)} + strategy={verticalListSortingStrategy} + > +
+ {filteredProjects.map((project, index) => ( + { + setCurrentProject(p); + setIsProjectPickerOpen(false); + }} + /> + ))} +
+
+
+ )} + + {/* Keyboard hint */} +
+

+ arrow navigate{' '} + |{' '} + enter select{' '} + |{' '} + esc close +

+
+
+
+ + {/* Project Options Menu - theme and history */} + {currentProject && ( + { + // Clear preview theme when the menu closes + if (!open) { + setPreviewTheme(null); + } + }} + > + + + + + {/* Project Theme Submenu */} + + + + Project Theme + {currentProject.theme && ( + + {currentProject.theme} + + )} + + { + // Clear preview theme when leaving the dropdown + setPreviewTheme(null); + }} + > + {/* Use Global Option */} + { + if (currentProject) { + setPreviewTheme(null); + if (value !== '') { + setTheme(value as ThemeMode); + } else { + setTheme(globalTheme); + } + setProjectTheme( + currentProject.id, + value === '' ? null : (value as ThemeMode) + ); + } + }} + > +
handlePreviewEnter(globalTheme)} + onPointerLeave={() => setPreviewTheme(null)} + > + + + Use Global + + ({globalTheme}) + + +
+ + {/* Two Column Layout */} +
+ {/* Dark Themes Column */} +
+
+ + Dark +
+
+ {PROJECT_DARK_THEMES.map((option) => ( + + ))} +
+
+ {/* Light Themes Column */} +
+
+ + Light +
+
+ {PROJECT_LIGHT_THEMES.map((option) => ( + + ))} +
+
+
+
+
+
+ + {/* Project History Section - only show when there's history */} + {projectHistory.length > 1 && ( + <> + + + Project History + + + + Previous + + {formatShortcut(shortcuts.cyclePrevProject, true)} + + + + + Next + + {formatShortcut(shortcuts.cycleNextProject, true)} + + + + + Clear history + + + )} + + {/* Move to Trash Section */} + + setShowDeleteProjectDialog(true)} + className="text-destructive focus:text-destructive focus:bg-destructive/10" + data-testid="move-project-to-trash" + > + + Move to Trash + +
+
+ )} +
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx new file mode 100644 index 00000000..664797b6 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -0,0 +1,269 @@ +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import { BookOpen, Activity, Settings } from 'lucide-react'; + +interface SidebarFooterProps { + sidebarOpen: boolean; + isActiveRoute: (id: string) => boolean; + navigate: (opts: NavigateOptions) => void; + hideWiki: boolean; + hideRunningAgents: boolean; + runningAgentsCount: number; + shortcuts: { + settings: string; + }; +} + +export function SidebarFooter({ + sidebarOpen, + isActiveRoute, + navigate, + hideWiki, + hideRunningAgents, + runningAgentsCount, + shortcuts, +}: SidebarFooterProps) { + return ( +
+ {/* Wiki Link */} + {!hideWiki && ( +
+ +
+ )} + {/* Running Agents Link */} + {!hideRunningAgents && ( +
+ +
+ )} + {/* Settings Link */} +
+ +
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/dialogs/index.ts b/apps/ui/src/components/layout/sidebar/dialogs/index.ts new file mode 100644 index 00000000..9b9235df --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/index.ts @@ -0,0 +1,2 @@ +export { TrashDialog } from './trash-dialog'; +export { OnboardingDialog } from './onboarding-dialog'; diff --git a/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx b/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx new file mode 100644 index 00000000..4a9e3558 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx @@ -0,0 +1,122 @@ +import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface OnboardingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + newProjectName: string; + onSkip: () => void; + onGenerateSpec: () => void; +} + +export function OnboardingDialog({ + open, + onOpenChange, + newProjectName, + onSkip, + onGenerateSpec, +}: OnboardingDialogProps) { + return ( + { + if (!isOpen) { + onSkip(); + } + onOpenChange(isOpen); + }} + > + + +
+
+ +
+
+ Welcome to {newProjectName}! + + Your new project is ready. Let's get you started. + +
+
+
+ +
+ {/* Main explanation */} +
+

+ Would you like to auto-generate your app_spec.txt? This file helps + describe your project and is used to pre-populate your backlog with features to work + on. +

+
+ + {/* Benefits list */} +
+
+ +
+

Pre-populate your backlog

+

+ Automatically generate features based on your project specification +

+
+
+
+ +
+

Better AI assistance

+

+ Help AI agents understand your project structure and tech stack +

+
+
+
+ +
+

Project documentation

+

+ Keep a clear record of your project's capabilities and features +

+
+
+
+ + {/* Info box */} +
+

+ Tip: You can always generate or edit your + app_spec.txt later from the Spec Editor in the sidebar. +

+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx b/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx new file mode 100644 index 00000000..bb231436 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx @@ -0,0 +1,116 @@ +import { X, Trash2, Undo2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import type { TrashedProject } from '@/lib/electron'; + +interface TrashDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trashedProjects: TrashedProject[]; + activeTrashId: string | null; + handleRestoreProject: (id: string) => void; + handleDeleteProjectFromDisk: (project: TrashedProject) => void; + deleteTrashedProject: (id: string) => void; + handleEmptyTrash: () => void; + isEmptyingTrash: boolean; +} + +export function TrashDialog({ + open, + onOpenChange, + trashedProjects, + activeTrashId, + handleRestoreProject, + handleDeleteProjectFromDisk, + deleteTrashedProject, + handleEmptyTrash, + isEmptyingTrash, +}: TrashDialogProps) { + return ( + + + + Recycle Bin + + Restore projects to the sidebar or delete their folders using your system Trash. + + + + {trashedProjects.length === 0 ? ( +

Recycle bin is empty.

+ ) : ( +
+ {trashedProjects.map((project) => ( +
+
+

{project.name}

+

{project.path}

+

+ Trashed {new Date(project.trashedAt).toLocaleString()} +

+
+
+ + + +
+
+ ))} +
+ )} + + + + {trashedProjects.length > 0 && ( + + )} + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/index.ts b/apps/ui/src/components/layout/sidebar/hooks/index.ts index c5cca3b8..7a047f8a 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/index.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts @@ -6,3 +6,7 @@ export { useTrashOperations } from './use-trash-operations'; export { useProjectPicker } from './use-project-picker'; export { useSpecRegeneration } from './use-spec-regeneration'; export { useNavigation } from './use-navigation'; +export { useProjectCreation } from './use-project-creation'; +export { useSetupDialog } from './use-setup-dialog'; +export { useTrashDialog } from './use-trash-dialog'; +export { useProjectTheme } from './use-project-theme'; diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts new file mode 100644 index 00000000..3d75fabb --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts @@ -0,0 +1,175 @@ +import { useState, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; +import { initializeProject } from '@/lib/project-init'; +import { toast } from 'sonner'; +import type { StarterTemplate } from '@/lib/templates'; +import type { ThemeMode } from '@/store/app-store'; +import type { TrashedProject, Project } from '@/lib/electron'; + +interface UseProjectCreationProps { + trashedProjects: TrashedProject[]; + currentProject: Project | null; + globalTheme: ThemeMode; + upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project; +} + +export function useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, +}: UseProjectCreationProps) { + // Modal state + const [showNewProjectModal, setShowNewProjectModal] = useState(false); + const [isCreatingProject, setIsCreatingProject] = useState(false); + + // Onboarding state + const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); + const [newProjectName, setNewProjectName] = useState(''); + const [newProjectPath, setNewProjectPath] = useState(''); + + /** + * Common logic for all project creation flows + */ + const finalizeProjectCreation = useCallback( + async (projectPath: string, projectName: string) => { + try { + // Initialize .automaker directory structure + await initializeProject(projectPath); + + // Write initial app_spec.txt with basic XML structure + const api = getElectronAPI(); + await api.fs.writeFile( + `${projectPath}/app_spec.txt`, + `\n\n ${projectName}\n Add your project description here\n` + ); + + // Determine theme: try trashed project theme, then current project theme, then global + const trashedProject = trashedProjects.find((p) => p.path === projectPath); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + + upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); + + setShowNewProjectModal(false); + + // Show onboarding dialog for new project + setNewProjectName(projectName); + setNewProjectPath(projectPath); + setShowOnboardingDialog(true); + + toast.success('Project created successfully'); + } catch (error) { + console.error('[ProjectCreation] Failed to finalize project:', error); + toast.error('Failed to initialize project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } + }, + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject] + ); + + /** + * Create a blank project with .automaker structure + */ + const handleCreateBlankProject = useCallback( + async (projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Create project directory + await api.fs.createFolder(projectPath); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } catch (error) { + console.error('[ProjectCreation] Failed to create blank project:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [finalizeProjectCreation] + ); + + /** + * Create project from a starter template + */ + const handleCreateFromTemplate = useCallback( + async (template: StarterTemplate, projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Clone template repository + await api.git.clone(template.githubUrl, projectPath); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } catch (error) { + console.error('[ProjectCreation] Failed to create from template:', error); + toast.error('Failed to create project from template', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [finalizeProjectCreation] + ); + + /** + * Create project from a custom GitHub URL + */ + const handleCreateFromCustomUrl = useCallback( + async (repoUrl: string, projectName: string, parentDir: string) => { + setIsCreatingProject(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + // Clone custom repository + await api.git.clone(repoUrl, projectPath); + + // Finalize project setup + await finalizeProjectCreation(projectPath, projectName); + } catch (error) { + console.error('[ProjectCreation] Failed to create from custom URL:', error); + toast.error('Failed to create project from URL', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreatingProject(false); + } + }, + [finalizeProjectCreation] + ); + + return { + // Modal state + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + + // Onboarding state + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + + // Handlers + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts new file mode 100644 index 00000000..b80e605d --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts @@ -0,0 +1,25 @@ +import { useAppStore } from '@/store/app-store'; +import { useThemePreview } from './use-theme-preview'; + +/** + * Hook that manages project theme state and preview handlers + */ +export function useProjectTheme() { + // Get theme-related values from store + const { theme: globalTheme, setTheme, setProjectTheme, setPreviewTheme } = useAppStore(); + + // Get debounced preview handlers + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + + return { + // Theme state + globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + + // Preview handlers + handlePreviewEnter, + handlePreviewLeave, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts b/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts new file mode 100644 index 00000000..8a94fd18 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts @@ -0,0 +1,147 @@ +import { useState, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; +import { toast } from 'sonner'; +import type { FeatureCount } from '@/components/views/spec-view/types'; + +interface UseSetupDialogProps { + setSpecCreatingForProject: (path: string | null) => void; + newProjectPath: string; + setNewProjectName: (name: string) => void; + setNewProjectPath: (path: string) => void; + setShowOnboardingDialog: (show: boolean) => void; +} + +export function useSetupDialog({ + setSpecCreatingForProject, + newProjectPath, + setNewProjectName, + setNewProjectPath, + setShowOnboardingDialog, +}: UseSetupDialogProps) { + // Setup dialog state + const [showSetupDialog, setShowSetupDialog] = useState(false); + const [setupProjectPath, setSetupProjectPath] = useState(''); + const [projectOverview, setProjectOverview] = useState(''); + const [generateFeatures, setGenerateFeatures] = useState(true); + const [analyzeProject, setAnalyzeProject] = useState(true); + const [featureCount, setFeatureCount] = useState(50); + + /** + * Handle creating initial spec for new project + */ + const handleCreateInitialSpec = useCallback(async () => { + if (!setupProjectPath || !projectOverview.trim()) return; + + // Set store state immediately so the loader shows up right away + setSpecCreatingForProject(setupProjectPath); + setShowSetupDialog(false); + + try { + const api = getElectronAPI(); + if (!api.specRegeneration) { + toast.error('Spec regeneration not available'); + setSpecCreatingForProject(null); + return; + } + + const result = await api.specRegeneration.create( + setupProjectPath, + projectOverview.trim(), + generateFeatures, + analyzeProject, + generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features + ); + + if (!result.success) { + console.error('[SetupDialog] Failed to start spec creation:', result.error); + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: result.error, + }); + } else { + // Show processing toast to inform user + toast.info('Generating app specification...', { + description: "This may take a minute. You'll be notified when complete.", + }); + } + // If successful, we'll wait for the events to update the state + } catch (error) { + console.error('[SetupDialog] Failed to create spec:', error); + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, [ + setupProjectPath, + projectOverview, + generateFeatures, + analyzeProject, + featureCount, + setSpecCreatingForProject, + ]); + + /** + * Handle skipping setup + */ + const handleSkipSetup = useCallback(() => { + setShowSetupDialog(false); + setProjectOverview(''); + setSetupProjectPath(''); + + // Clear onboarding state if we came from onboarding + if (newProjectPath) { + setNewProjectName(''); + setNewProjectPath(''); + } + + toast.info('Setup skipped', { + description: 'You can set up your app_spec.txt later from the Spec view.', + }); + }, [newProjectPath, setNewProjectName, setNewProjectPath]); + + /** + * Handle onboarding dialog - generate spec + */ + const handleOnboardingGenerateSpec = useCallback(() => { + setShowOnboardingDialog(false); + // Navigate to the setup dialog flow + setSetupProjectPath(newProjectPath); + setProjectOverview(''); + setShowSetupDialog(true); + }, [newProjectPath, setShowOnboardingDialog]); + + /** + * Handle onboarding dialog - skip + */ + const handleOnboardingSkip = useCallback(() => { + setShowOnboardingDialog(false); + setNewProjectName(''); + setNewProjectPath(''); + toast.info('You can generate your app_spec.txt anytime from the Spec view', { + description: 'Your project is ready to use!', + }); + }, [setShowOnboardingDialog, setNewProjectName, setNewProjectPath]); + + return { + // State + showSetupDialog, + setShowSetupDialog, + setupProjectPath, + setSetupProjectPath, + projectOverview, + setProjectOverview, + generateFeatures, + setGenerateFeatures, + analyzeProject, + setAnalyzeProject, + featureCount, + setFeatureCount, + + // Handlers + handleCreateInitialSpec, + handleSkipSetup, + handleOnboardingGenerateSpec, + handleOnboardingSkip, + }; +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts b/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts new file mode 100644 index 00000000..74c1ee9b --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import { useTrashOperations } from './use-trash-operations'; +import type { TrashedProject } from '@/lib/electron'; + +interface UseTrashDialogProps { + restoreTrashedProject: (projectId: string) => void; + deleteTrashedProject: (projectId: string) => void; + emptyTrash: () => void; + trashedProjects: TrashedProject[]; +} + +/** + * Hook that combines trash operations with dialog state management + */ +export function useTrashDialog({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, +}: UseTrashDialogProps) { + // Dialog state + const [showTrashDialog, setShowTrashDialog] = useState(false); + + // Reuse existing trash operations logic + const trashOperations = useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + trashedProjects, + }); + + return { + // Dialog state + showTrashDialog, + setShowTrashDialog, + + // Trash operations (spread from existing hook) + ...trashOperations, + }; +}