Files
automaker/apps/ui/src/components/layout/sidebar/sidebar.tsx
Stefan de Vogelaere 433e6016c3 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
2026-01-22 18:52:30 +01:00

499 lines
16 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useNavigate, useLocation } from '@tanstack/react-router';
import { PanelLeftClose, ChevronDown } 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';
// Sidebar components
import {
SidebarNavigation,
CollapseToggleButton,
MobileSidebarToggle,
SidebarHeader,
SidebarFooter,
} from './components';
import { SIDEBAR_FEATURE_FLAGS } from './constants';
import {
useSidebarAutoCollapse,
useRunningAgents,
useSpecRegeneration,
useNavigation,
useProjectCreation,
useSetupDialog,
useTrashOperations,
useUnviewedValidations,
} from './hooks';
import { TrashDialog, OnboardingDialog } from './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';
const logger = createLogger('Sidebar');
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,
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<Project | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
null
);
const [editDialogProject, setEditDialogProject] = useState<Project | null>(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;
};
// Track if nav can scroll down
const [canScrollDown, setCanScrollDown] = useState(false);
// Check if sidebar should be completely hidden on mobile
const shouldHideSidebar = isCompact && mobileSidebarHidden;
return (
<>
{/* Floating toggle to show sidebar on mobile when hidden */}
<MobileSidebarToggle />
{/* Mobile backdrop overlay */}
{sidebarOpen && !shouldHideSidebar && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar}
data-testid="sidebar-backdrop"
/>
)}
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: completely hidden when mobileSidebarHidden is true
shouldHideSidebar && 'hidden',
// Width based on state
!shouldHideSidebar &&
(sidebarOpen
? 'fixed inset-y-0 left-0 w-[17rem] lg:relative lg:w-[17rem]'
: 'relative w-14')
)}
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
{/* Floating hide button on right edge - only visible on compact screens when sidebar is collapsed */}
{!sidebarOpen && isCompact && (
<button
onClick={toggleMobileSidebarHidden}
className={cn(
'absolute -right-6 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center w-6 h-10 rounded-r-lg',
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'shadow-lg hover:shadow-xl hover:shadow-brand-500/10',
'transition-all duration-200',
'hover:w-8 active:scale-95'
)}
aria-label="Hide sidebar"
data-testid="sidebar-mobile-hide"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
)}
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
onScrollStateChange={setCanScrollDown}
/>
</div>
{/* Scroll indicator - shows there's more content below */}
{canScrollDown && sidebarOpen && (
<div className="flex justify-center py-1 border-t border-border/30">
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
</div>
)}
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideRunningAgents={hideRunningAgents}
hideWiki={hideWiki}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
{/* Context Menu */}
{contextMenuProject && contextMenuPosition && (
<ProjectContextMenu
project={contextMenuProject}
position={contextMenuPosition}
onClose={handleCloseContextMenu}
onEdit={handleEditProject}
/>
)}
{/* Edit Project Dialog */}
{editDialogProject && (
<EditProjectDialog
project={editDialogProject}
open={!!editDialogProject}
onOpenChange={(open) => !open && setEditDialogProject(null)}
/>
)}
</>
);
}