From a40bb6df24e04e986e0b9544c51a9ac62a2ef871 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 21 Dec 2025 21:23:04 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20streamline=20s?= =?UTF-8?q?idebar=20component=20structure=20and=20enhance=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extracted new components: ProjectSelectorWithOptions, SidebarFooter, TrashDialog, and OnboardingDialog to improve code organization and reusability. - Introduced new hooks: useProjectCreation, useSetupDialog, and useTrashDialog for better state management and modularity. - Updated sidebar.tsx to utilize the new components and hooks, reducing complexity and improving maintainability. - Enhanced project creation and setup processes with dedicated dialogs and streamlined user interactions. This refactor aims to enhance the user experience and maintainability of the sidebar by modularizing functionality and improving the overall structure. --- apps/ui/src/components/layout/sidebar.tsx | 1255 ++--------------- .../layout/sidebar/components/index.ts | 2 + .../project-selector-with-options.tsx | 374 +++++ .../sidebar/components/sidebar-footer.tsx | 269 ++++ .../layout/sidebar/dialogs/index.ts | 2 + .../sidebar/dialogs/onboarding-dialog.tsx | 122 ++ .../layout/sidebar/dialogs/trash-dialog.tsx | 116 ++ .../components/layout/sidebar/hooks/index.ts | 4 + .../sidebar/hooks/use-project-creation.ts | 175 +++ .../layout/sidebar/hooks/use-project-theme.ts | 25 + .../layout/sidebar/hooks/use-setup-dialog.ts | 147 ++ .../layout/sidebar/hooks/use-trash-dialog.ts | 40 + 12 files changed, 1371 insertions(+), 1160 deletions(-) create mode 100644 apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx create mode 100644 apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/index.ts create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx create mode 100644 apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts create mode 100644 apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts 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, + }; +}