diff --git a/apps/ui/src/components/layout/index.ts b/apps/ui/src/components/layout/index.ts index bfed6246..d702d78d 100644 --- a/apps/ui/src/components/layout/index.ts +++ b/apps/ui/src/components/layout/index.ts @@ -1 +1,2 @@ export { Sidebar } from './sidebar'; +export { UnifiedSidebar } from './unified-sidebar'; diff --git a/apps/ui/src/components/layout/unified-sidebar/components/index.ts b/apps/ui/src/components/layout/unified-sidebar/components/index.ts new file mode 100644 index 00000000..42f3195f --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/components/index.ts @@ -0,0 +1,2 @@ +export { SidebarHeader } from './sidebar-header'; +export { SidebarFooter } from './sidebar-footer'; diff --git a/apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx new file mode 100644 index 00000000..1c8bcc8e --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx @@ -0,0 +1,372 @@ +import { useCallback } from 'react'; +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import { Activity, Settings, User, Bug, BookOpen, ExternalLink } from 'lucide-react'; +import { useOSDetection } from '@/hooks/use-os-detection'; +import { getElectronAPI } from '@/lib/electron'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +function getOSAbbreviation(os: string): string { + switch (os) { + case 'mac': + return 'M'; + case 'windows': + return 'W'; + case 'linux': + return 'L'; + default: + return '?'; + } +} + +interface SidebarFooterProps { + sidebarOpen: boolean; + isActiveRoute: (id: string) => boolean; + navigate: (opts: NavigateOptions) => void; + hideRunningAgents: boolean; + hideWiki: boolean; + runningAgentsCount: number; + shortcuts: { + settings: string; + }; +} + +export function SidebarFooter({ + sidebarOpen, + isActiveRoute, + navigate, + hideRunningAgents, + hideWiki, + runningAgentsCount, + shortcuts, +}: SidebarFooterProps) { + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + const { os } = useOSDetection(); + const appMode = import.meta.env.VITE_APP_MODE || '?'; + const versionSuffix = `${getOSAbbreviation(os)}${appMode}`; + + const handleWikiClick = useCallback(() => { + navigate({ to: '/wiki' }); + }, [navigate]); + + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); + + // Collapsed state + if (!sidebarOpen) { + return ( +
+
+ {/* Running Agents */} + {!hideRunningAgents && ( + + + + + + + Running Agents + {runningAgentsCount > 0 && ( + + {runningAgentsCount} + + )} + + + + )} + + {/* Settings */} + + + + + + + Global Settings + + {formatShortcut(shortcuts.settings, true)} + + + + + + {/* User Dropdown */} + + + + + + + + + + More options + + + + + {!hideWiki && ( + + + Documentation + + )} + + + Report Bug + + + +
+ + v{appVersion} {versionSuffix} + +
+
+
+
+
+ ); + } + + // Expanded state + return ( +
+ {/* Running Agents Link */} + {!hideRunningAgents && ( +
+ +
+ )} + + {/* Settings Link */} +
+ +
+ + {/* User area with dropdown */} +
+ + + + + + {!hideWiki && ( + + + Documentation + + )} + + + Report Bug / Feature Request + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx new file mode 100644 index 00000000..4a531718 --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx @@ -0,0 +1,349 @@ +import { useState, useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { ChevronDown, Folder, Plus, FolderOpen, Check } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { cn, isMac } from '@/lib/utils'; +import { isElectron, type Project } from '@/lib/electron'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; +import { useAppStore } from '@/store/app-store'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +interface SidebarHeaderProps { + sidebarOpen: boolean; + currentProject: Project | null; + onNewProject: () => void; + onOpenFolder: () => void; + onProjectContextMenu: (project: Project, event: React.MouseEvent) => void; +} + +export function SidebarHeader({ + sidebarOpen, + currentProject, + onNewProject, + onOpenFolder, + onProjectContextMenu, +}: SidebarHeaderProps) { + const navigate = useNavigate(); + const { projects, setCurrentProject } = useAppStore(); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const handleLogoClick = useCallback(() => { + navigate({ to: '/dashboard' }); + }, [navigate]); + + const handleProjectSelect = useCallback( + (project: Project) => { + setCurrentProject(project); + setDropdownOpen(false); + navigate({ to: '/board' }); + }, + [setCurrentProject, navigate] + ); + + const getIconComponent = (project: Project): LucideIcon => { + if (project?.icon && project.icon in LucideIcons) { + return (LucideIcons as unknown as Record)[project.icon]; + } + return Folder; + }; + + const renderProjectIcon = (project: Project, size: 'sm' | 'md' = 'md') => { + const IconComponent = getIconComponent(project); + const sizeClasses = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8'; + const iconSizeClasses = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'; + + if (project.customIconPath) { + return ( + {project.name} + ); + } + + return ( +
+ +
+ ); + }; + + // Collapsed state - show logo only + if (!sidebarOpen) { + return ( +
+ + + + + + + Go to Dashboard + + + + + {/* Collapsed project icon */} + {currentProject && ( + <> +
+ + + + + + + {currentProject.name} + + + + + )} +
+ ); + } + + // Expanded state - show logo + project dropdown + return ( +
+ {/* Header with logo and project dropdown */} +
+ {/* Logo */} + + + {/* Project Dropdown */} + {currentProject ? ( + + + + + +
+ Projects +
+ {projects.map((project, index) => { + const isActive = currentProject?.id === project.id; + const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined; + + return ( + handleProjectSelect(project)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setDropdownOpen(false); + onProjectContextMenu(project, e); + }} + className={cn( + 'flex items-center gap-3 cursor-pointer', + isActive && 'bg-brand-500/10' + )} + data-testid={`project-item-${project.id}`} + > + {renderProjectIcon(project, 'sm')} + {project.name} + {hotkeyLabel && ( + + {hotkeyLabel} + + )} + {isActive && } + + ); + })} + + { + setDropdownOpen(false); + onNewProject(); + }} + className="cursor-pointer" + data-testid="new-project-dropdown-item" + > + + New Project + + { + setDropdownOpen(false); + onOpenFolder(); + }} + className="cursor-pointer" + data-testid="open-project-dropdown-item" + > + + Open Project + +
+
+ ) : ( +
+ + +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/layout/unified-sidebar/index.ts b/apps/ui/src/components/layout/unified-sidebar/index.ts new file mode 100644 index 00000000..a88954e5 --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/index.ts @@ -0,0 +1 @@ +export { UnifiedSidebar } from './unified-sidebar'; diff --git a/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx b/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx new file mode 100644 index 00000000..eb8841ac --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx @@ -0,0 +1,479 @@ +import { useState, useCallback, useEffect } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { useNavigate, useLocation } from '@tanstack/react-router'; +import { PanelLeftClose } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { useNotificationsStore } from '@/store/notifications-store'; +import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; +import { getElectronAPI } from '@/lib/electron'; +import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; +import { toast } from 'sonner'; +import { useIsCompact } from '@/hooks/use-media-query'; +import type { Project } from '@/lib/electron'; + +// Reuse existing sidebar components +import { SidebarNavigation, CollapseToggleButton, MobileSidebarToggle } from '../sidebar/components'; +import { SIDEBAR_FEATURE_FLAGS } from '../sidebar/constants'; +import { + useSidebarAutoCollapse, + useRunningAgents, + useSpecRegeneration, + useNavigation, + useProjectCreation, + useSetupDialog, + useTrashOperations, + useUnviewedValidations, +} from '../sidebar/hooks'; +import { TrashDialog, OnboardingDialog } from '../sidebar/dialogs'; + +// Reuse dialogs from project-switcher +import { ProjectContextMenu } from '../project-switcher/components/project-context-menu'; +import { EditProjectDialog } from '../project-switcher/components/edit-project-dialog'; + +// Import shared dialogs +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'; + +// Local components +import { SidebarHeader, SidebarFooter } from './components'; + +const logger = createLogger('UnifiedSidebar'); + +export function UnifiedSidebar() { + const navigate = useNavigate(); + const location = useLocation(); + + const { + projects, + trashedProjects, + currentProject, + sidebarOpen, + mobileSidebarHidden, + projectHistory, + upsertAndSetCurrentProject, + toggleSidebar, + toggleMobileSidebarHidden, + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + cyclePrevProject, + cycleNextProject, + moveProjectToTrash, + specCreatingForProject, + setSpecCreatingForProject, + setCurrentProject, + } = useAppStore(); + + const isCompact = useIsCompact(); + + // Environment variable flags for hiding sidebar items + const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor, hideWiki } = + SIDEBAR_FEATURE_FLAGS; + + // Get customizable keyboard shortcuts + const shortcuts = useKeyboardShortcutsConfig(); + + // Get unread notifications count + const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount); + + // State for context menu + const [contextMenuProject, setContextMenuProject] = useState(null); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( + null + ); + const [editDialogProject, setEditDialogProject] = useState(null); + + // State for delete project confirmation dialog + const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); + + // State for trash dialog + const [showTrashDialog, setShowTrashDialog] = useState(false); + + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + upsertAndSetCurrentProject, + }); + + // 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; + const creatingSpecProjectPath = specCreatingForProject; + // Check if the current project is specifically the one generating spec + const isCurrentProjectGeneratingSpec = + specCreatingForProject !== null && specCreatingForProject === currentProject?.path; + + // Auto-collapse sidebar on small screens + useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); + + // Running agents count + const { runningAgentsCount } = useRunningAgents(); + + // Unviewed validations count + const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject); + + // Trash operations + const { + activeTrashId, + isEmptyingTrash, + handleRestoreProject, + handleDeleteProjectFromDisk, + handleEmptyTrash, + } = useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + }); + + // Spec regeneration events + useSpecRegeneration({ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, + }); + + // Context menu handlers + const handleContextMenu = useCallback((project: Project, event: React.MouseEvent) => { + event.preventDefault(); + setContextMenuProject(project); + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + }, []); + + const handleCloseContextMenu = useCallback(() => { + setContextMenuProject(null); + setContextMenuPosition(null); + }, []); + + const handleEditProject = useCallback((project: Project) => { + setEditDialogProject(project); + handleCloseContextMenu(); + }, [handleCloseContextMenu]); + + /** + * Opens the system folder selection dialog and initializes the selected project. + */ + const handleOpenFolder = useCallback(async () => { + 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'; + + try { + const hadAutomakerDir = await hasAutomakerDir(path); + const initResult = await initializeProject(path); + + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + upsertAndSetCurrentProject(path, name); + const specExists = await hasAppSpec(path); + + if (!hadAutomakerDir && !specExists) { + setSetupProjectPath(path); + setShowSetupDialog(true); + toast.success('Project opened', { + description: `Opened ${name}. Let's set up your app specification!`, + }); + } else if (initResult.createdFiles && initResult.createdFiles.length > 0) { + toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', { + description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`, + }); + } else { + toast.success('Project opened', { + description: `Opened ${name}`, + }); + } + + navigate({ to: '/board' }); + } catch (error) { + logger.error('Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + }, [upsertAndSetCurrentProject, navigate, setSetupProjectPath, setShowSetupDialog]); + + const handleNewProject = useCallback(() => { + setShowNewProjectModal(true); + }, [setShowNewProjectModal]); + + // Navigation sections and keyboard shortcuts + const { navSections, navigationShortcuts } = useNavigation({ + shortcuts, + hideSpecEditor, + hideContext, + hideTerminal, + currentProject, + projects, + projectHistory, + navigate, + toggleSidebar, + handleOpenFolder, + cyclePrevProject, + cycleNextProject, + unviewedValidationsCount, + unreadNotificationsCount, + isSpecGenerating: isCurrentProjectGeneratingSpec, + }); + + // Register keyboard shortcuts + useKeyboardShortcuts(navigationShortcuts); + + // Keyboard shortcuts for project switching (1-9, 0) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + const key = event.key; + let projectIndex: number | null = null; + + if (key >= '1' && key <= '9') { + projectIndex = parseInt(key, 10) - 1; + } else if (key === '0') { + projectIndex = 9; + } + + if (projectIndex !== null && projectIndex < projects.length) { + const targetProject = projects[projectIndex]; + if (targetProject && targetProject.id !== currentProject?.id) { + setCurrentProject(targetProject); + navigate({ to: '/board' }); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [projects, currentProject, setCurrentProject, navigate]); + + const isActiveRoute = (id: string) => { + const routePath = id === 'welcome' ? '/' : `/${id}`; + return location.pathname === routePath; + }; + + // Check if sidebar should be completely hidden on mobile + const shouldHideSidebar = isCompact && mobileSidebarHidden; + + return ( + <> + {/* Floating toggle to show sidebar on mobile when hidden */} + + + {/* Mobile backdrop overlay */} + {sidebarOpen && !shouldHideSidebar && ( +
+ )} + + + + {/* Context Menu */} + {contextMenuProject && contextMenuPosition && ( + + )} + + {/* Edit Project Dialog */} + {editDialogProject && ( + !open && setEditDialogProject(null)} + /> + )} + + ); +} diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 907d2b19..f8379c70 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -3,8 +3,7 @@ import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'reac import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createLogger } from '@automaker/utils/logger'; -import { Sidebar } from '@/components/layout/sidebar'; -import { ProjectSwitcher } from '@/components/layout/project-switcher'; +import { UnifiedSidebar } from '@/components/layout/unified-sidebar'; import { FileBrowserProvider, useFileBrowser, @@ -171,8 +170,6 @@ function RootLayoutContent() { skipSandboxWarning, setSkipSandboxWarning, fetchCodexModels, - sidebarOpen, - toggleSidebar, } = useAppStore(); const { setupComplete, codexCliStatus } = useSetupStore(); const navigate = useNavigate(); @@ -186,7 +183,7 @@ function RootLayoutContent() { // Load project settings when switching projects useProjectSettingsLoader(); - // Check if we're in compact mode (< 1240px) to hide project switcher + // Check if we're in compact mode (< 1240px) const isCompact = useIsCompact(); const isSetupRoute = location.pathname === '/setup'; @@ -853,11 +850,6 @@ function RootLayoutContent() { ); } - // Show project switcher on all app pages (not on dashboard, setup, or login) - // Also hide on compact screens (< 1240px) - the sidebar will show a logo instead - const showProjectSwitcher = - !isDashboardRoute && !isSetupRoute && !isLoginRoute && !isLoggedOutRoute && !isCompact; - return ( <>
@@ -868,8 +860,7 @@ function RootLayoutContent() { aria-hidden="true" /> )} - {showProjectSwitcher && } - +