mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
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:
@@ -1 +1,2 @@
|
|||||||
export { Sidebar } from './sidebar';
|
export { Sidebar } from './sidebar';
|
||||||
|
export { UnifiedSidebar } from './unified-sidebar';
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { SidebarHeader } from './sidebar-header';
|
||||||
|
export { SidebarFooter } from './sidebar-footer';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/ui/src/components/layout/unified-sidebar/index.ts
Normal file
1
apps/ui/src/components/layout/unified-sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { UnifiedSidebar } from './unified-sidebar';
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,7 @@ import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'reac
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { UnifiedSidebar } from '@/components/layout/unified-sidebar';
|
||||||
import { ProjectSwitcher } from '@/components/layout/project-switcher';
|
|
||||||
import {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
useFileBrowser,
|
useFileBrowser,
|
||||||
@@ -171,8 +170,6 @@ function RootLayoutContent() {
|
|||||||
skipSandboxWarning,
|
skipSandboxWarning,
|
||||||
setSkipSandboxWarning,
|
setSkipSandboxWarning,
|
||||||
fetchCodexModels,
|
fetchCodexModels,
|
||||||
sidebarOpen,
|
|
||||||
toggleSidebar,
|
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const { setupComplete, codexCliStatus } = useSetupStore();
|
const { setupComplete, codexCliStatus } = useSetupStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -186,7 +183,7 @@ function RootLayoutContent() {
|
|||||||
// Load project settings when switching projects
|
// Load project settings when switching projects
|
||||||
useProjectSettingsLoader();
|
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 isCompact = useIsCompact();
|
||||||
|
|
||||||
const isSetupRoute = location.pathname === '/setup';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||||
@@ -868,8 +860,7 @@ function RootLayoutContent() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showProjectSwitcher && <ProjectSwitcher />}
|
<UnifiedSidebar />
|
||||||
<Sidebar />
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||||
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user