From 02dfda108ec70708c35f43e15b359acfa7296dd9 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Tue, 20 Jan 2026 22:54:13 +0100 Subject: [PATCH 1/3] feat(ui): add unified sidebar component Add new unified-sidebar component for layout improvements. - Export UnifiedSidebar from layout components - Update root route to use new sidebar structure --- apps/ui/src/components/layout/index.ts | 1 + .../unified-sidebar/components/index.ts | 2 + .../components/sidebar-footer.tsx | 372 ++++++++++++++ .../components/sidebar-header.tsx | 349 +++++++++++++ .../layout/unified-sidebar/index.ts | 1 + .../unified-sidebar/unified-sidebar.tsx | 479 ++++++++++++++++++ apps/ui/src/routes/__root.tsx | 15 +- 7 files changed, 1207 insertions(+), 12 deletions(-) create mode 100644 apps/ui/src/components/layout/unified-sidebar/components/index.ts create mode 100644 apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx create mode 100644 apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx create mode 100644 apps/ui/src/components/layout/unified-sidebar/index.ts create mode 100644 apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx diff --git a/apps/ui/src/components/layout/index.ts b/apps/ui/src/components/layout/index.ts index bfed62466..d702d78de 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 000000000..42f3195fc --- /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 000000000..1c8bcc8ef --- /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 000000000..4a5317187 --- /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 000000000..a88954e51 --- /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 000000000..eb8841ac3 --- /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 907d2b196..f8379c709 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 && } - +
Date: Thu, 22 Jan 2026 18:52:30 +0100 Subject: [PATCH 2/3] refactor(ui): consolidate unified-sidebar into sidebar folder Merge the unified-sidebar implementation into the standard sidebar folder structure. The unified sidebar becomes the canonical sidebar with improved features including collapsible sections, scroll indicators, and enhanced mobile support. - Delete old sidebar.tsx - Move unified-sidebar components to sidebar/components - Rename UnifiedSidebar to Sidebar - Update all imports in __root.tsx - Remove redundant unified-sidebar folder --- apps/ui/src/components/layout/index.ts | 1 - apps/ui/src/components/layout/sidebar.tsx | 397 -------------- .../components/collapse-toggle-button.tsx | 2 +- .../sidebar/components/sidebar-footer.tsx | 375 +++++++++---- .../sidebar/components/sidebar-header.tsx | 499 +++++++++++++----- .../sidebar/components/sidebar-navigation.tsx | 417 ++++++++++----- .../layout/sidebar/hooks/use-navigation.ts | 20 + .../ui/src/components/layout/sidebar/index.ts | 1 + .../sidebar.tsx} | 51 +- .../ui/src/components/layout/sidebar/types.ts | 4 + .../unified-sidebar/components/index.ts | 2 - .../components/sidebar-footer.tsx | 372 ------------- .../components/sidebar-header.tsx | 349 ------------ .../layout/unified-sidebar/index.ts | 1 - apps/ui/src/routes/__root.tsx | 4 +- 15 files changed, 971 insertions(+), 1524 deletions(-) delete mode 100644 apps/ui/src/components/layout/sidebar.tsx create mode 100644 apps/ui/src/components/layout/sidebar/index.ts rename apps/ui/src/components/layout/{unified-sidebar/unified-sidebar.tsx => sidebar/sidebar.tsx} (92%) delete mode 100644 apps/ui/src/components/layout/unified-sidebar/components/index.ts delete mode 100644 apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx delete mode 100644 apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx delete mode 100644 apps/ui/src/components/layout/unified-sidebar/index.ts diff --git a/apps/ui/src/components/layout/index.ts b/apps/ui/src/components/layout/index.ts index d702d78de..bfed62466 100644 --- a/apps/ui/src/components/layout/index.ts +++ b/apps/ui/src/components/layout/index.ts @@ -1,2 +1 @@ export { Sidebar } from './sidebar'; -export { UnifiedSidebar } from './unified-sidebar'; diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx deleted file mode 100644 index 05ff1328c..000000000 --- a/apps/ui/src/components/layout/sidebar.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import { useState, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { useNavigate, useLocation } from '@tanstack/react-router'; - -const logger = createLogger('Sidebar'); -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 { 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 imports from subfolder -import { - CollapseToggleButton, - SidebarHeader, - SidebarNavigation, - SidebarFooter, - MobileSidebarToggle, -} from './sidebar/components'; -import { useIsCompact } from '@/hooks/use-media-query'; -import { PanelLeftClose } from 'lucide-react'; -import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; -import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; -import { - useSidebarAutoCollapse, - useRunningAgents, - useSpecRegeneration, - useNavigation, - useProjectCreation, - useSetupDialog, - useTrashOperations, - useUnviewedValidations, -} from './sidebar/hooks'; - -export function Sidebar() { - const navigate = useNavigate(); - const location = useLocation(); - - const { - projects, - trashedProjects, - currentProject, - sidebarOpen, - mobileSidebarHidden, - projectHistory, - upsertAndSetCurrentProject, - toggleSidebar, - toggleMobileSidebarHidden, - restoreTrashedProject, - deleteTrashedProject, - emptyTrash, - cyclePrevProject, - cycleNextProject, - moveProjectToTrash, - specCreatingForProject, - setSpecCreatingForProject, - } = useAppStore(); - - const isCompact = useIsCompact(); - - // Environment variable flags for hiding sidebar items - const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS; - - // Get customizable keyboard shortcuts - const shortcuts = useKeyboardShortcutsConfig(); - - // Get unread notifications count - const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount); - - // 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 and update Electron window minWidth - 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, - }); - - /** - * Opens the system folder selection dialog and initializes the selected project. - * Used by both the 'O' keyboard shortcut and the folder icon button. - */ - const handleOpenFolder = useCallback(async () => { - const api = getElectronAPI(); - const result = await api.openDirectory(); - - if (!result.canceled && result.filePaths[0]) { - const path = result.filePaths[0]; - // Extract folder name from path (works on both Windows and Mac/Linux) - const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; - - try { - // Check if this is a brand new project (no .automaker directory) - const hadAutomakerDir = await hasAutomakerDir(path); - - // Initialize the .automaker directory structure - const initResult = await initializeProject(path); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Upsert project and set as current (handles both create and update cases) - // Theme handling (trashed project recovery or undefined for global) is done by the store - upsertAndSetCurrentProject(path, name); - - // Check if app_spec.txt exists - const specExists = await hasAppSpec(path); - - if (!hadAutomakerDir && !specExists) { - // This is a brand new project - show setup dialog - 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}`, - }); - } - } catch (error) { - logger.error('Failed to open project:', error); - toast.error('Failed to open project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - }, [upsertAndSetCurrentProject]); - - // Navigation sections and keyboard shortcuts (defined after handlers) - 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); - - const isActiveRoute = (id: string) => { - // Map view IDs to route paths - 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 && ( -
- )} - - - ); -} diff --git a/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx index 29a716444..2a503fc57 100644 --- a/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx +++ b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx @@ -25,7 +25,7 @@ export function CollapseToggleButton({ + + + Running Agents + {runningAgentsCount > 0 && ( + + {runningAgentsCount} + + )} + + + + )} + + {/* Settings */} + + + + + + + Global Settings + + {formatShortcut(shortcuts.settings, true)} + + + + + + {/* Documentation */} + {!hideWiki && ( + + + + + + + Documentation + + + + )} + + {/* Feedback */} + + + + + + + Feedback + + + +
+
+ ); + } + + // Expanded state return ( -
+
{/* Running Agents Link */} {!hideRunningAgents && ( -
+
)} + {/* Settings Link */} -
+
+
+ + {/* Separator */} +
+ + {/* Documentation Link */} + {!hideWiki && ( +
+ +
+ )} + + {/* Feedback Link */} +
+
+ + {/* Version */} +
+ + v{appVersion} {versionSuffix} + +
); } diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index 8f3d921e8..db4835ddd 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -1,179 +1,406 @@ -import { useState } from 'react'; -import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react'; +import { useState, useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import { cn, isMac } from '@/lib/utils'; -import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { isElectron, type Project } from '@/lib/electron'; -import { useIsCompact } from '@/hooks/use-media-query'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +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; - onClose?: () => void; - onExpand?: () => void; + onNewProject: () => void; + onOpenFolder: () => void; + onProjectContextMenu: (project: Project, event: React.MouseEvent) => void; } export function SidebarHeader({ sidebarOpen, currentProject, - onClose, - onExpand, + onNewProject, + onOpenFolder, + onProjectContextMenu, }: SidebarHeaderProps) { - const isCompact = useIsCompact(); - const [projectListOpen, setProjectListOpen] = useState(false); + const navigate = useNavigate(); const { projects, setCurrentProject } = useAppStore(); - // Get the icon component from lucide-react - const getIconComponent = (): LucideIcon => { - if (currentProject?.icon && currentProject.icon in LucideIcons) { - return (LucideIcons as unknown as Record)[currentProject.icon]; + 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 IconComponent = getIconComponent(); - const hasCustomIcon = !!currentProject?.customIconPath; + 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 with dropdown */} + {currentProject && ( + <> +
+ + + + + + + + + + {currentProject.name} + + + + +
+ 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="flex items-center gap-3 cursor-pointer" + data-testid={`collapsed-project-item-${project.id}`} + > + {renderProjectIcon(project, 'sm')} + + {project.name} + + {hotkeyLabel && ( + ⌘{hotkeyLabel} + )} + + ); + })} + + { + setDropdownOpen(false); + onNewProject(); + }} + className="cursor-pointer" + data-testid="collapsed-new-project-dropdown-item" + > + + New Project + + { + setDropdownOpen(false); + onOpenFolder(); + }} + className="cursor-pointer" + data-testid="collapsed-open-project-dropdown-item" + > + + Open Project + +
+
+ + )} +
+ ); + } + + // Expanded state - show logo + project dropdown return (
- {/* Mobile close button - only visible on mobile when sidebar is open */} - {sidebarOpen && onClose && ( + {/* Header with logo and project dropdown */} +
+ {/* Logo */} - )} - {/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */} - {!sidebarOpen && isCompact && onExpand && ( - - )} - {/* Project name and icon display - entire element clickable on mobile */} - {currentProject && ( - - - + + {/* Project Dropdown */} + {currentProject ? ( + + + + + +
+ Projects
- - {/* Project Name - only show when sidebar is open */} - {sidebarOpen && ( -
-

- {currentProject.name} -

-
- )} - -
- -
-

Switch Project

- {projects.map((project) => { - const ProjectIcon = - project.icon && project.icon in LucideIcons - ? (LucideIcons as unknown as Record)[project.icon] - : Folder; + {projects.map((project, index) => { const isActive = currentProject?.id === project.id; + const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined; return ( - + ); })} -
-
-
- )} + + { + 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/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index c4956159e..f303ad44c 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -1,9 +1,24 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; import type { NavigateOptions } from '@tanstack/react-router'; +import { ChevronDown, Wrench, Github } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatShortcut } from '@/store/app-store'; import type { NavSection } from '../types'; import type { Project } from '@/lib/electron'; import { Spinner } from '@/components/ui/spinner'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +// Map section labels to icons +const sectionIcons: Record> = { + Tools: Wrench, + GitHub: Github, +}; interface SidebarNavigationProps { currentProject: Project | null; @@ -11,6 +26,7 @@ interface SidebarNavigationProps { navSections: NavSection[]; isActiveRoute: (id: string) => boolean; navigate: (opts: NavigateOptions) => void; + onScrollStateChange?: (canScrollDown: boolean) => void; } export function SidebarNavigation({ @@ -19,174 +35,305 @@ export function SidebarNavigation({ navSections, isActiveRoute, navigate, + onScrollStateChange, }: SidebarNavigationProps) { + const navRef = useRef(null); + + // Track collapsed state for each collapsible section + const [collapsedSections, setCollapsedSections] = useState>({}); + + // Initialize collapsed state when sections change (e.g., GitHub section appears) + useEffect(() => { + setCollapsedSections((prev) => { + const updated = { ...prev }; + navSections.forEach((section) => { + if (section.collapsible && section.label && !(section.label in updated)) { + updated[section.label] = section.defaultCollapsed ?? false; + } + }); + return updated; + }); + }, [navSections]); + + // Check scroll state + const checkScrollState = useCallback(() => { + if (!navRef.current || !onScrollStateChange) return; + const { scrollTop, scrollHeight, clientHeight } = navRef.current; + const canScrollDown = scrollTop + clientHeight < scrollHeight - 10; + onScrollStateChange(canScrollDown); + }, [onScrollStateChange]); + + // Monitor scroll state + useEffect(() => { + checkScrollState(); + const nav = navRef.current; + if (!nav) return; + + nav.addEventListener('scroll', checkScrollState); + const resizeObserver = new ResizeObserver(checkScrollState); + resizeObserver.observe(nav); + + return () => { + nav.removeEventListener('scroll', checkScrollState); + resizeObserver.disconnect(); + }; + }, [checkScrollState, collapsedSections]); + + const toggleSection = useCallback((label: string) => { + setCollapsedSections((prev) => ({ + ...prev, + [label]: !prev[label], + })); + }, []); + + // Filter sections: always show non-project sections, only show project sections when project exists + const visibleSections = navSections.filter((section) => { + // Always show Dashboard (first section with no label) + if (!section.label && section.items.some((item) => item.id === 'dashboard')) { + return true; + } + // Show other sections only when project is selected + return !!currentProject; + }); + return (
- )) - ) : null} + ); + })} + + {/* Placeholder when no project is selected */} + {!currentProject && sidebarOpen && ( +
+

+ Select or create a project to continue +

+
+ )} ); } diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 91b40e4ac..df5d033f5 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -13,6 +13,7 @@ import { Network, Bell, Settings, + Home, } from 'lucide-react'; import type { NavSection, NavItem } from '../types'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; @@ -174,13 +175,30 @@ export function useNavigation({ } const sections: NavSection[] = [ + // Dashboard - standalone at top + { + label: '', + items: [ + { + id: 'dashboard', + label: 'Dashboard', + icon: Home, + }, + ], + }, + // Project section - expanded by default { label: 'Project', items: projectItems, + collapsible: true, + defaultCollapsed: false, }, + // Tools section - collapsed by default { label: 'Tools', items: visibleToolsItems, + collapsible: true, + defaultCollapsed: true, }, ]; @@ -203,6 +221,8 @@ export function useNavigation({ shortcut: shortcuts.githubPrs, }, ], + collapsible: true, + defaultCollapsed: true, }); } diff --git a/apps/ui/src/components/layout/sidebar/index.ts b/apps/ui/src/components/layout/sidebar/index.ts new file mode 100644 index 000000000..bfed62466 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/index.ts @@ -0,0 +1 @@ +export { Sidebar } from './sidebar'; diff --git a/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx b/apps/ui/src/components/layout/sidebar/sidebar.tsx similarity index 92% rename from apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx rename to apps/ui/src/components/layout/sidebar/sidebar.tsx index eb8841ac3..5b63921f7 100644 --- a/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar/sidebar.tsx @@ -1,7 +1,7 @@ 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 { PanelLeftClose, ChevronDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { useNotificationsStore } from '@/store/notifications-store'; @@ -12,9 +12,15 @@ 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'; +// Sidebar components +import { + SidebarNavigation, + CollapseToggleButton, + MobileSidebarToggle, + SidebarHeader, + SidebarFooter, +} from './components'; +import { SIDEBAR_FEATURE_FLAGS } from './constants'; import { useSidebarAutoCollapse, useRunningAgents, @@ -24,8 +30,8 @@ import { useSetupDialog, useTrashOperations, useUnviewedValidations, -} from '../sidebar/hooks'; -import { TrashDialog, OnboardingDialog } from '../sidebar/dialogs'; +} from './hooks'; +import { TrashDialog, OnboardingDialog } from './dialogs'; // Reuse dialogs from project-switcher import { ProjectContextMenu } from '../project-switcher/components/project-context-menu'; @@ -36,12 +42,9 @@ import { DeleteProjectDialog } from '@/components/views/settings-view/components 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'); +const logger = createLogger('Sidebar'); -export function UnifiedSidebar() { +export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); @@ -188,10 +191,13 @@ export function UnifiedSidebar() { setContextMenuPosition(null); }, []); - const handleEditProject = useCallback((project: Project) => { - setEditDialogProject(project); - handleCloseContextMenu(); - }, [handleCloseContextMenu]); + const handleEditProject = useCallback( + (project: Project) => { + setEditDialogProject(project); + handleCloseContextMenu(); + }, + [handleCloseContextMenu] + ); /** * Opens the system folder selection dialog and initializes the selected project. @@ -309,6 +315,9 @@ export function UnifiedSidebar() { return location.pathname === routePath; }; + // Track if nav can scroll down + const [canScrollDown, setCanScrollDown] = useState(false); + // Check if sidebar should be completely hidden on mobile const shouldHideSidebar = isCompact && mobileSidebarHidden; @@ -339,7 +348,9 @@ export function UnifiedSidebar() { shouldHideSidebar && 'hidden', // Width based on state !shouldHideSidebar && - (sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16') + (sidebarOpen + ? 'fixed inset-y-0 left-0 w-[17rem] lg:relative lg:w-[17rem]' + : 'relative w-14') )} data-testid="sidebar" > @@ -384,9 +395,17 @@ export function UnifiedSidebar() { navSections={navSections} isActiveRoute={isActiveRoute} navigate={navigate} + onScrollStateChange={setCanScrollDown} />
+ {/* Scroll indicator - shows there's more content below */} + {canScrollDown && sidebarOpen && ( +
+ +
+ )} + 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 deleted file mode 100644 index 4a5317187..000000000 --- a/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx +++ /dev/null @@ -1,349 +0,0 @@ -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 deleted file mode 100644 index a88954e51..000000000 --- a/apps/ui/src/components/layout/unified-sidebar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UnifiedSidebar } from './unified-sidebar'; diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index f8379c709..f374b7dd0 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -3,7 +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 { UnifiedSidebar } from '@/components/layout/unified-sidebar'; +import { Sidebar } from '@/components/layout/sidebar'; import { FileBrowserProvider, useFileBrowser, @@ -860,7 +860,7 @@ function RootLayoutContent() { aria-hidden="true" /> )} - +
Date: Fri, 23 Jan 2026 01:58:15 +0100 Subject: [PATCH 3/3] fix(ui): address PR review comments and fix E2E tests for unified sidebar - Add try/catch for getElectronAPI() in sidebar-footer with window.open fallback - Use formatShortcut() for OS-aware hotkey display in sidebar-header - Remove unnecessary optional chaining on project.icon - Remove redundant ternary in sidebar-navigation className - Update E2E tests to use new project-dropdown-trigger data-testid Co-Authored-By: Claude Opus 4.5 --- .../layout/sidebar/components/sidebar-footer.tsx | 9 +++++++-- .../layout/sidebar/components/sidebar-header.tsx | 11 ++++++++--- .../layout/sidebar/components/sidebar-navigation.tsx | 8 +------- .../tests/features/feature-manual-review-flow.spec.ts | 9 ++++----- apps/ui/tests/projects/new-project-creation.spec.ts | 9 ++++----- apps/ui/tests/projects/open-existing-project.spec.ts | 9 ++++----- 6 files changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx index b407e365b..49f4eccf5 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -51,8 +51,13 @@ export function SidebarFooter({ }, [navigate]); const handleFeedbackClick = useCallback(() => { - const api = getElectronAPI(); - api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + try { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + } catch { + // Fallback for non-Electron environments (SSR, web browser) + window.open('https://github.com/AutoMaker-Org/automaker/issues', '_blank'); + } }, []); // Collapsed state diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index db4835ddd..afca3e9c2 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -4,6 +4,7 @@ import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { cn, isMac } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; import { isElectron, type Project } from '@/lib/electron'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { useAppStore } from '@/store/app-store'; @@ -49,7 +50,7 @@ export function SidebarHeader({ ); const getIconComponent = (project: Project): LucideIcon => { - if (project?.icon && project.icon in LucideIcons) { + if (project.icon && project.icon in LucideIcons) { return (LucideIcons as unknown as Record)[project.icon]; } return Folder; @@ -200,7 +201,9 @@ export function SidebarHeader({ {project.name} {hotkeyLabel && ( - ⌘{hotkeyLabel} + + {formatShortcut(`Cmd+${hotkeyLabel}`, true)} + )} ); @@ -342,7 +345,9 @@ export function SidebarHeader({ {project.name} {hotkeyLabel && ( - ⌘{hotkeyLabel} + + {formatShortcut(`Cmd+${hotkeyLabel}`, true)} + )} ); diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index f303ad44c..4a1ab1fc0 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -97,13 +97,7 @@ export function SidebarNavigation({ }); return ( -