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
This commit is contained in:
Stefan de Vogelaere
2026-01-20 22:54:13 +01:00
parent 733ca15e15
commit 02dfda108e
7 changed files with 1207 additions and 12 deletions

View File

@@ -1 +1,2 @@
export { Sidebar } from './sidebar';
export { UnifiedSidebar } from './unified-sidebar';

View File

@@ -0,0 +1,2 @@
export { SidebarHeader } from './sidebar-header';
export { SidebarFooter } from './sidebar-footer';

View File

@@ -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 (
<div
className={cn(
'shrink-0 border-t border-border/40',
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)}
>
<div className="flex flex-col items-center py-2 px-2 gap-1">
{/* Running Agents */}
{!hideRunningAgents && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => navigate({ to: '/running-agents' })}
className={cn(
'relative flex items-center justify-center w-10 h-10 rounded-xl',
'transition-all duration-200 ease-out titlebar-no-drag',
isActiveRoute('running-agents')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
]
)}
data-testid="running-agents-link"
>
<Activity
className={cn(
'w-[18px] h-[18px]',
isActiveRoute('running-agents') && 'text-brand-500'
)}
/>
{runningAgentsCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-brand-500 text-white shadow-sm'
)}
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Running Agents
{runningAgentsCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px]">
{runningAgentsCount}
</span>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Settings */}
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => navigate({ to: '/settings' })}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'transition-all duration-200 ease-out titlebar-no-drag',
isActiveRoute('settings')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
]
)}
data-testid="settings-button"
>
<Settings
className={cn(
'w-[18px] h-[18px]',
isActiveRoute('settings') && 'text-brand-500'
)}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* User Dropdown */}
<DropdownMenu>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out titlebar-no-drag'
)}
data-testid="user-dropdown-trigger"
>
<User className="w-[18px] h-[18px]" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
More options
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent side="right" align="end" sideOffset={8} className="w-48">
{!hideWiki && (
<DropdownMenuItem onClick={handleWikiClick} className="cursor-pointer">
<BookOpen className="w-4 h-4 mr-2" />
<span>Documentation</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleBugReportClick} className="cursor-pointer">
<Bug className="w-4 h-4 mr-2" />
<span>Report Bug</span>
<ExternalLink className="w-3 h-3 ml-auto text-muted-foreground" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
<span className="text-[10px] text-muted-foreground">
v{appVersion} {versionSuffix}
</span>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
// Expanded state
return (
<div
className={cn(
'shrink-0 border-t border-border/40',
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)}
>
{/* Running Agents Link */}
{!hideRunningAgents && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: '/running-agents' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('running-agents')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
'hover:scale-[1.02] active:scale-[0.97]'
)}
data-testid="running-agents-link"
>
<div className="relative">
<Activity
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('running-agents')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
</div>
<span className="ml-3 font-medium text-sm flex-1 text-left">Running Agents</span>
{runningAgentsCount > 0 && (
<span
className={cn(
'flex items-center justify-center',
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
'bg-brand-500 text-white shadow-sm',
isActiveRoute('running-agents') && 'bg-brand-600'
)}
data-testid="running-agents-count"
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
</button>
</div>
)}
{/* Settings Link */}
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: '/settings' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('settings')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
'hover:scale-[1.02] active:scale-[0.97]'
)}
data-testid="settings-button"
>
<Settings
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('settings')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:rotate-90 group-hover:scale-110'
)}
/>
<span className="ml-3 font-medium text-sm flex-1 text-left">Global Settings</span>
<span
className={cn(
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
)}
data-testid="shortcut-settings"
>
{formatShortcut(shortcuts.settings, true)}
</span>
</button>
</div>
{/* User area with dropdown */}
<div className="p-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl titlebar-no-drag',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.97]'
)}
data-testid="user-dropdown-trigger"
>
<div className="w-7 h-7 rounded-full bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<User className="w-4 h-4 text-brand-500" />
</div>
<div className="ml-3 flex flex-col items-start flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">User</span>
<span className="text-[10px] text-muted-foreground">
v{appVersion} {versionSuffix}
</span>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" sideOffset={8} className="w-56">
{!hideWiki && (
<DropdownMenuItem onClick={handleWikiClick} className="cursor-pointer">
<BookOpen className="w-4 h-4 mr-2" />
<span>Documentation</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleBugReportClick} className="cursor-pointer">
<Bug className="w-4 h-4 mr-2" />
<span>Report Bug / Feature Request</span>
<ExternalLink className="w-3 h-3 ml-auto text-muted-foreground" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -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<string, LucideIcon>)[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 (
<img
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
alt={project.name}
className={cn(sizeClasses, 'rounded-lg object-cover ring-1 ring-border/50')}
/>
);
}
return (
<div
className={cn(
sizeClasses,
'rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center'
)}
>
<IconComponent className={cn(iconSizeClasses, 'text-brand-500')} />
</div>
);
};
// Collapsed state - show logo only
if (!sidebarOpen) {
return (
<div
className={cn(
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
isMac && isElectron() && 'pt-[10px]'
)}
>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleLogoClick}
className="group flex flex-col items-center"
data-testid="logo-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-collapsed"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-collapsed)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Go to Dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Collapsed project icon */}
{currentProject && (
<>
<div className="w-full h-px bg-border/40 my-2" />
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setDropdownOpen(true)}
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
className="p-1 rounded-lg hover:bg-accent/50 transition-colors"
data-testid="collapsed-project-button"
>
{renderProjectIcon(currentProject)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{currentProject.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
);
}
// Expanded state - show logo + project dropdown
return (
<div
className={cn(
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
isMac && isElectron() && 'pt-[10px]'
)}
>
{/* Header with logo and project dropdown */}
<div className="flex items-center gap-3">
{/* Logo */}
<button
onClick={handleLogoClick}
className="group flex items-center shrink-0 titlebar-no-drag"
title="Go to Dashboard"
data-testid="logo-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="h-8 w-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-header"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-header)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</button>
{/* Project Dropdown */}
{currentProject ? (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex-1 flex items-center gap-2 px-2 py-1.5 rounded-lg min-w-0',
'hover:bg-accent/50 transition-colors titlebar-no-drag',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1'
)}
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
data-testid="project-dropdown-trigger"
>
{renderProjectIcon(currentProject, 'sm')}
<span className="flex-1 text-sm font-semibold text-foreground truncate text-left">
{currentProject.name}
</span>
<ChevronDown
className={cn(
'w-4 h-4 text-muted-foreground shrink-0 transition-transform',
dropdownOpen && 'rotate-180'
)}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={8}
className="w-64"
data-testid="project-dropdown-content"
>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Projects</span>
</div>
{projects.map((project, index) => {
const isActive = currentProject?.id === project.id;
const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined;
return (
<DropdownMenuItem
key={project.id}
onClick={() => 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')}
<span className="flex-1 truncate">{project.name}</span>
{hotkeyLabel && (
<span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{hotkeyLabel}
</span>
)}
{isActive && <Check className="w-4 h-4 text-brand-500" />}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
onNewProject();
}}
className="cursor-pointer"
data-testid="new-project-dropdown-item"
>
<Plus className="w-4 h-4 mr-2" />
<span>New Project</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
onOpenFolder();
}}
className="cursor-pointer"
data-testid="open-project-dropdown-item"
>
<FolderOpen className="w-4 h-4 mr-2" />
<span>Open Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex-1 flex items-center gap-2">
<button
onClick={onNewProject}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg',
'text-sm text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 transition-colors titlebar-no-drag'
)}
data-testid="new-project-button"
>
<Plus className="w-4 h-4" />
<span>New Project</span>
</button>
<button
onClick={onOpenFolder}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg',
'text-sm text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 transition-colors titlebar-no-drag'
)}
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4" />
<span>Open</span>
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { UnifiedSidebar } from './unified-sidebar';

View File

@@ -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<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;
};
// 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-72 lg:relative lg:w-72' : 'relative w-16')
)}
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}
/>
</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)}
/>
)}
</>
);
}

View File

@@ -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 (
<>
<main className="flex h-screen overflow-hidden" data-testid="app-container">
@@ -868,8 +860,7 @@ function RootLayoutContent() {
aria-hidden="true"
/>
)}
{showProjectSwitcher && <ProjectSwitcher />}
<Sidebar />
<UnifiedSidebar />
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}