mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
feat(ui): unified sidebar with collapsible sections and enhanced UX (#659)
* 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 * 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 * fix(ui): address PR review comments and fix E2E tests for unified sidebar - Add try/catch for getElectronAPI() in sidebar-footer with window.open fallback - Use formatShortcut() for OS-aware hotkey display in sidebar-header - Remove unnecessary optional chaining on project.icon - Remove redundant ternary in sidebar-navigation className - Update E2E tests to use new project-dropdown-trigger data-testid Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
afb6e14811
commit
01859f3a9a
@@ -25,7 +25,7 @@ export function CollapseToggleButton({
|
|||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex absolute top-[68px] -right-3 z-9999',
|
'flex absolute top-[40px] -right-3.5 z-9999',
|
||||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||||
// Glass morphism button
|
// Glass morphism button
|
||||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import type { NavigateOptions } from '@tanstack/react-router';
|
import type { NavigateOptions } from '@tanstack/react-router';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut } from '@/store/app-store';
|
import { formatShortcut } from '@/store/app-store';
|
||||||
import { Activity, Settings } from 'lucide-react';
|
import { Activity, Settings, BookOpen, MessageSquare, ExternalLink } from 'lucide-react';
|
||||||
|
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
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 {
|
interface SidebarFooterProps {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
isActiveRoute: (id: string) => boolean;
|
isActiveRoute: (id: string) => boolean;
|
||||||
navigate: (opts: NavigateOptions) => void;
|
navigate: (opts: NavigateOptions) => void;
|
||||||
hideRunningAgents: boolean;
|
hideRunningAgents: boolean;
|
||||||
|
hideWiki: boolean;
|
||||||
runningAgentsCount: number;
|
runningAgentsCount: number;
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
settings: string;
|
settings: string;
|
||||||
@@ -19,86 +37,225 @@ export function SidebarFooter({
|
|||||||
isActiveRoute,
|
isActiveRoute,
|
||||||
navigate,
|
navigate,
|
||||||
hideRunningAgents,
|
hideRunningAgents,
|
||||||
|
hideWiki,
|
||||||
runningAgentsCount,
|
runningAgentsCount,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
}: SidebarFooterProps) {
|
}: 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 handleFeedbackClick = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
|
||||||
|
} catch {
|
||||||
|
// Fallback for non-Electron environments (SSR, web browser)
|
||||||
|
window.open('https://github.com/AutoMaker-Org/automaker/issues', '_blank');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Collapsed state
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Documentation */}
|
||||||
|
{!hideWiki && (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={handleWikiClick}
|
||||||
|
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="documentation-button"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
Documentation
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={handleFeedbackClick}
|
||||||
|
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="feedback-button"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
Feedback
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded state
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="shrink-0">
|
||||||
className={cn(
|
|
||||||
'shrink-0',
|
|
||||||
// Top border with gradient fade
|
|
||||||
'border-t border-border/40',
|
|
||||||
// Elevated background for visual separation
|
|
||||||
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Running Agents Link */}
|
{/* Running Agents Link */}
|
||||||
{!hideRunningAgents && (
|
{!hideRunningAgents && (
|
||||||
<div className="p-2 pb-0">
|
<div className="px-3 py-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/running-agents' })}
|
onClick={() => navigate({ to: '/running-agents' })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
isActiveRoute('running-agents')
|
isActiveRoute('running-agents')
|
||||||
? [
|
? [
|
||||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||||
'text-foreground font-medium',
|
'text-foreground font-medium',
|
||||||
'border border-brand-500/30',
|
'border border-brand-500/30',
|
||||||
'shadow-md shadow-brand-500/10',
|
'shadow-sm shadow-brand-500/10',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'hover:bg-accent/50',
|
'hover:bg-accent/50',
|
||||||
'border border-transparent hover:border-border/40',
|
'border border-transparent hover:border-border/40',
|
||||||
'hover:shadow-sm',
|
]
|
||||||
],
|
|
||||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
|
||||||
'hover:scale-[1.02] active:scale-[0.97]'
|
|
||||||
)}
|
)}
|
||||||
title={!sidebarOpen ? 'Running Agents' : undefined}
|
|
||||||
data-testid="running-agents-link"
|
data-testid="running-agents-link"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<Activity
|
||||||
<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'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{/* Running agents count badge - shown in collapsed state */}
|
|
||||||
{!sidebarOpen && runningAgentsCount > 0 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'absolute -top-1.5 -right-1.5 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',
|
|
||||||
'animate-in fade-in zoom-in duration-200'
|
|
||||||
)}
|
|
||||||
data-testid="running-agents-count-collapsed"
|
|
||||||
>
|
|
||||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
sidebarOpen ? 'block' : 'hidden'
|
isActiveRoute('running-agents')
|
||||||
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
|
: 'group-hover:text-brand-400'
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
Running Agents
|
<span className="ml-3 text-sm flex-1 text-left">Running Agents</span>
|
||||||
</span>
|
{runningAgentsCount > 0 && (
|
||||||
{/* Running agents count badge - shown in expanded state */}
|
|
||||||
{sidebarOpen && runningAgentsCount > 0 && (
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center',
|
'flex items-center justify-center',
|
||||||
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
|
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||||
'bg-brand-500 text-white shadow-sm',
|
'bg-brand-500 text-white shadow-sm',
|
||||||
'animate-in fade-in zoom-in duration-200',
|
|
||||||
isActiveRoute('running-agents') && 'bg-brand-600'
|
isActiveRoute('running-agents') && 'bg-brand-600'
|
||||||
)}
|
)}
|
||||||
data-testid="running-agents-count"
|
data-testid="running-agents-count"
|
||||||
@@ -106,52 +263,30 @@ export function SidebarFooter({
|
|||||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!sidebarOpen && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
|
||||||
'bg-popover text-popover-foreground text-xs font-medium',
|
|
||||||
'border border-border shadow-lg',
|
|
||||||
'opacity-0 group-hover:opacity-100',
|
|
||||||
'transition-all duration-200 whitespace-nowrap z-50',
|
|
||||||
'translate-x-1 group-hover:translate-x-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Running Agents
|
|
||||||
{runningAgentsCount > 0 && (
|
|
||||||
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px] font-semibold">
|
|
||||||
{runningAgentsCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings Link */}
|
{/* Settings Link */}
|
||||||
<div className="p-2">
|
<div className="px-3 py-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/settings' })}
|
onClick={() => navigate({ to: '/settings' })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
isActiveRoute('settings')
|
isActiveRoute('settings')
|
||||||
? [
|
? [
|
||||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||||
'text-foreground font-medium',
|
'text-foreground font-medium',
|
||||||
'border border-brand-500/30',
|
'border border-brand-500/30',
|
||||||
'shadow-md shadow-brand-500/10',
|
'shadow-sm shadow-brand-500/10',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'hover:bg-accent/50',
|
'hover:bg-accent/50',
|
||||||
'border border-transparent hover:border-border/40',
|
'border border-transparent hover:border-border/40',
|
||||||
'hover:shadow-sm',
|
]
|
||||||
],
|
|
||||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
|
||||||
'hover:scale-[1.02] active:scale-[0.97]'
|
|
||||||
)}
|
)}
|
||||||
title={!sidebarOpen ? 'Global Settings' : undefined}
|
|
||||||
data-testid="settings-button"
|
data-testid="settings-button"
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
@@ -159,49 +294,70 @@ export function SidebarFooter({
|
|||||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
isActiveRoute('settings')
|
isActiveRoute('settings')
|
||||||
? 'text-brand-500 drop-shadow-sm'
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
: 'group-hover:text-brand-400 group-hover:rotate-90 group-hover:scale-110'
|
: 'group-hover:text-brand-400'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<span className="ml-3 text-sm flex-1 text-left">Settings</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
|
||||||
sidebarOpen ? 'block' : 'hidden'
|
isActiveRoute('settings')
|
||||||
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
)}
|
)}
|
||||||
|
data-testid="shortcut-settings"
|
||||||
>
|
>
|
||||||
Global Settings
|
{formatShortcut(shortcuts.settings, true)}
|
||||||
</span>
|
</span>
|
||||||
{sidebarOpen && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{!sidebarOpen && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
|
||||||
'bg-popover text-popover-foreground text-xs font-medium',
|
|
||||||
'border border-border shadow-lg',
|
|
||||||
'opacity-0 group-hover:opacity-100',
|
|
||||||
'transition-all duration-200 whitespace-nowrap z-50',
|
|
||||||
'translate-x-1 group-hover:translate-x-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="h-px bg-border/40 mx-3 my-2" />
|
||||||
|
|
||||||
|
{/* Documentation Link */}
|
||||||
|
{!hideWiki && (
|
||||||
|
<div className="px-3 py-0.5">
|
||||||
|
<button
|
||||||
|
onClick={handleWikiClick}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center w-full px-3 py-1.5 rounded-md titlebar-no-drag',
|
||||||
|
'text-muted-foreground/70 hover:text-foreground',
|
||||||
|
'hover:bg-accent/30',
|
||||||
|
'transition-all duration-200 ease-out'
|
||||||
|
)}
|
||||||
|
data-testid="documentation-button"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="ml-2.5 text-xs">Documentation</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback Link */}
|
||||||
|
<div className="px-3 pt-0.5">
|
||||||
|
<button
|
||||||
|
onClick={handleFeedbackClick}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center w-full px-3 py-1.5 rounded-md titlebar-no-drag',
|
||||||
|
'text-muted-foreground/70 hover:text-foreground',
|
||||||
|
'hover:bg-accent/30',
|
||||||
|
'transition-all duration-200 ease-out'
|
||||||
|
)}
|
||||||
|
data-testid="feedback-button"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="ml-2.5 text-xs">Feedback</span>
|
||||||
|
<ExternalLink className="w-3 h-3 ml-auto text-muted-foreground/50" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<div className="px-6 py-1.5 text-center">
|
||||||
|
<span className="text-[9px] text-muted-foreground/40">
|
||||||
|
v{appVersion} {versionSuffix}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,179 +1,411 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { cn, isMac } from '@/lib/utils';
|
import { cn, isMac } from '@/lib/utils';
|
||||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
import { formatShortcut } from '@/store/app-store';
|
||||||
import { isElectron, type Project } from '@/lib/electron';
|
import { isElectron, type Project } from '@/lib/electron';
|
||||||
import { useIsCompact } from '@/hooks/use-media-query';
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
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 {
|
interface SidebarHeaderProps {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
onClose?: () => void;
|
onNewProject: () => void;
|
||||||
onExpand?: () => void;
|
onOpenFolder: () => void;
|
||||||
|
onProjectContextMenu: (project: Project, event: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarHeader({
|
export function SidebarHeader({
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
currentProject,
|
currentProject,
|
||||||
onClose,
|
onNewProject,
|
||||||
onExpand,
|
onOpenFolder,
|
||||||
|
onProjectContextMenu,
|
||||||
}: SidebarHeaderProps) {
|
}: SidebarHeaderProps) {
|
||||||
const isCompact = useIsCompact();
|
const navigate = useNavigate();
|
||||||
const [projectListOpen, setProjectListOpen] = useState(false);
|
|
||||||
const { projects, setCurrentProject } = useAppStore();
|
const { projects, setCurrentProject } = useAppStore();
|
||||||
// Get the icon component from lucide-react
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const getIconComponent = (): LucideIcon => {
|
|
||||||
if (currentProject?.icon && currentProject.icon in LucideIcons) {
|
const handleLogoClick = useCallback(() => {
|
||||||
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
|
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;
|
return Folder;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IconComponent = getIconComponent();
|
const renderProjectIcon = (project: Project, size: 'sm' | 'md' = 'md') => {
|
||||||
const hasCustomIcon = !!currentProject?.customIconPath;
|
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 with dropdown */}
|
||||||
|
{currentProject && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-px bg-border/40 my-2" />
|
||||||
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
|
||||||
|
className="p-1 rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
data-testid="collapsed-project-button"
|
||||||
|
>
|
||||||
|
{renderProjectIcon(currentProject)}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
{currentProject.name}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
side="right"
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-64"
|
||||||
|
data-testid="collapsed-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="flex items-center gap-3 cursor-pointer"
|
||||||
|
data-testid={`collapsed-project-item-${project.id}`}
|
||||||
|
>
|
||||||
|
{renderProjectIcon(project, 'sm')}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex-1 truncate',
|
||||||
|
isActive && 'font-semibold text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
{hotkeyLabel && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatShortcut(`Cmd+${hotkeyLabel}`, true)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
onNewProject();
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid="collapsed-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="collapsed-open-project-dropdown-item"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4 mr-2" />
|
||||||
|
<span>Open Project</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded state - show logo + project dropdown
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 flex flex-col relative',
|
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
|
||||||
// Add padding on macOS Electron for traffic light buttons
|
|
||||||
isMac && isElectron() && 'pt-[10px]'
|
isMac && isElectron() && 'pt-[10px]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Mobile close button - only visible on mobile when sidebar is open */}
|
{/* Header with logo and project dropdown */}
|
||||||
{sidebarOpen && onClose && (
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Logo */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={handleLogoClick}
|
||||||
className={cn(
|
className="group flex items-center shrink-0 titlebar-no-drag"
|
||||||
'lg:hidden absolute top-3 right-3 z-10',
|
title="Go to Dashboard"
|
||||||
'flex items-center justify-center w-8 h-8 rounded-lg',
|
data-testid="logo-button"
|
||||||
'bg-muted/50 hover:bg-muted',
|
|
||||||
'text-muted-foreground hover:text-foreground',
|
|
||||||
'transition-colors duration-200'
|
|
||||||
)}
|
|
||||||
aria-label="Close navigation"
|
|
||||||
data-testid="sidebar-mobile-close"
|
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<svg
|
||||||
</button>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
)}
|
viewBox="0 0 256 256"
|
||||||
{/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
|
role="img"
|
||||||
{!sidebarOpen && isCompact && onExpand && (
|
aria-label="Automaker Logo"
|
||||||
<button
|
className="h-8 w-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||||
onClick={onExpand}
|
>
|
||||||
className={cn(
|
<defs>
|
||||||
'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
|
<linearGradient
|
||||||
'bg-muted/50 hover:bg-muted',
|
id="bg-header"
|
||||||
'text-muted-foreground hover:text-foreground',
|
x1="0"
|
||||||
'transition-colors duration-200'
|
y1="0"
|
||||||
)}
|
x2="256"
|
||||||
aria-label="Expand navigation"
|
y2="256"
|
||||||
data-testid="sidebar-mobile-expand"
|
gradientUnits="userSpaceOnUse"
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||||
</button>
|
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||||
)}
|
</linearGradient>
|
||||||
{/* Project name and icon display - entire element clickable on mobile */}
|
</defs>
|
||||||
{currentProject && (
|
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-header)" />
|
||||||
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
|
<g
|
||||||
<PopoverTrigger asChild>
|
fill="none"
|
||||||
<button
|
stroke="#FFFFFF"
|
||||||
className={cn(
|
strokeWidth="20"
|
||||||
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
|
strokeLinecap="round"
|
||||||
'rounded-lg transition-colors duration-150',
|
strokeLinejoin="round"
|
||||||
!sidebarOpen && 'justify-center px-2',
|
|
||||||
// Only enable click behavior on compact screens
|
|
||||||
isCompact && 'hover:bg-accent/50 cursor-pointer',
|
|
||||||
!isCompact && 'pointer-events-none'
|
|
||||||
)}
|
|
||||||
title={isCompact ? 'Switch project' : undefined}
|
|
||||||
>
|
>
|
||||||
{/* Project Icon */}
|
<path d="M92 92 L52 128 L92 164" />
|
||||||
<div className="shrink-0">
|
<path d="M144 72 L116 184" />
|
||||||
{hasCustomIcon ? (
|
<path d="M164 92 L204 128 L164 164" />
|
||||||
<img
|
</g>
|
||||||
src={getAuthenticatedImageUrl(
|
</svg>
|
||||||
currentProject.customIconPath!,
|
</button>
|
||||||
currentProject.path
|
|
||||||
)}
|
|
||||||
alt={currentProject.name}
|
|
||||||
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
|
|
||||||
<IconComponent className="w-5 h-5 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Project Name - only show when sidebar is open */}
|
{/* Project Dropdown */}
|
||||||
{sidebarOpen && (
|
{currentProject ? (
|
||||||
<div className="flex-1 min-w-0">
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<h2 className="text-sm font-semibold text-foreground truncate">
|
<DropdownMenuTrigger asChild>
|
||||||
{currentProject.name}
|
<button
|
||||||
</h2>
|
className={cn(
|
||||||
</div>
|
'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',
|
||||||
</button>
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1'
|
||||||
</PopoverTrigger>
|
)}
|
||||||
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
|
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
|
||||||
<div className="space-y-1">
|
data-testid="project-dropdown-trigger"
|
||||||
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
|
>
|
||||||
{projects.map((project) => {
|
{renderProjectIcon(currentProject, 'sm')}
|
||||||
const ProjectIcon =
|
<span className="flex-1 text-sm font-semibold text-foreground truncate text-left">
|
||||||
project.icon && project.icon in LucideIcons
|
{currentProject.name}
|
||||||
? (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon]
|
</span>
|
||||||
: Folder;
|
<ChevronsUpDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
</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 isActive = currentProject?.id === project.id;
|
||||||
|
const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<DropdownMenuItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => {
|
onClick={() => handleProjectSelect(project)}
|
||||||
setCurrentProject(project);
|
onContextMenu={(e) => {
|
||||||
setProjectListOpen(false);
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDropdownOpen(false);
|
||||||
|
onProjectContextMenu(project, e);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="flex items-center gap-3 cursor-pointer"
|
||||||
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
|
data-testid={`project-item-${project.id}`}
|
||||||
'transition-colors duration-150',
|
|
||||||
isActive
|
|
||||||
? 'bg-brand-500/10 text-brand-500'
|
|
||||||
: 'hover:bg-accent text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{project.customIconPath ? (
|
{renderProjectIcon(project, 'sm')}
|
||||||
<img
|
<span
|
||||||
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
|
className={cn('flex-1 truncate', isActive && 'font-semibold text-foreground')}
|
||||||
alt={project.name}
|
>
|
||||||
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
|
{project.name}
|
||||||
/>
|
</span>
|
||||||
) : (
|
{hotkeyLabel && (
|
||||||
<div
|
<span className="text-xs text-muted-foreground">
|
||||||
className={cn(
|
{formatShortcut(`Cmd+${hotkeyLabel}`, true)}
|
||||||
'w-6 h-6 rounded flex items-center justify-center',
|
</span>
|
||||||
isActive ? 'bg-brand-500/20' : 'bg-muted'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ProjectIcon
|
|
||||||
className={cn(
|
|
||||||
'w-4 h-4',
|
|
||||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 text-sm truncate">{project.name}</span>
|
</DropdownMenuItem>
|
||||||
{isActive && <Check className="w-4 h-4 text-brand-500" />}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
<DropdownMenuSeparator />
|
||||||
</PopoverContent>
|
<DropdownMenuItem
|
||||||
</Popover>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import type { NavigateOptions } from '@tanstack/react-router';
|
import type { NavigateOptions } from '@tanstack/react-router';
|
||||||
|
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut } from '@/store/app-store';
|
import { formatShortcut } from '@/store/app-store';
|
||||||
import type { NavSection } from '../types';
|
import type { NavSection } from '../types';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
// Map section labels to icons
|
||||||
|
const sectionIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
Tools: Wrench,
|
||||||
|
GitHub: Github,
|
||||||
|
};
|
||||||
|
|
||||||
interface SidebarNavigationProps {
|
interface SidebarNavigationProps {
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
@@ -11,6 +26,7 @@ interface SidebarNavigationProps {
|
|||||||
navSections: NavSection[];
|
navSections: NavSection[];
|
||||||
isActiveRoute: (id: string) => boolean;
|
isActiveRoute: (id: string) => boolean;
|
||||||
navigate: (opts: NavigateOptions) => void;
|
navigate: (opts: NavigateOptions) => void;
|
||||||
|
onScrollStateChange?: (canScrollDown: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarNavigation({
|
export function SidebarNavigation({
|
||||||
@@ -19,174 +35,299 @@ export function SidebarNavigation({
|
|||||||
navSections,
|
navSections,
|
||||||
isActiveRoute,
|
isActiveRoute,
|
||||||
navigate,
|
navigate,
|
||||||
|
onScrollStateChange,
|
||||||
}: SidebarNavigationProps) {
|
}: SidebarNavigationProps) {
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
// Track collapsed state for each collapsible section
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
||||||
|
useEffect(() => {
|
||||||
|
setCollapsedSections((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
navSections.forEach((section) => {
|
||||||
|
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||||
|
updated[section.label] = section.defaultCollapsed ?? false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [navSections]);
|
||||||
|
|
||||||
|
// Check scroll state
|
||||||
|
const checkScrollState = useCallback(() => {
|
||||||
|
if (!navRef.current || !onScrollStateChange) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = navRef.current;
|
||||||
|
const canScrollDown = scrollTop + clientHeight < scrollHeight - 10;
|
||||||
|
onScrollStateChange(canScrollDown);
|
||||||
|
}, [onScrollStateChange]);
|
||||||
|
|
||||||
|
// Monitor scroll state
|
||||||
|
useEffect(() => {
|
||||||
|
checkScrollState();
|
||||||
|
const nav = navRef.current;
|
||||||
|
if (!nav) return;
|
||||||
|
|
||||||
|
nav.addEventListener('scroll', checkScrollState);
|
||||||
|
const resizeObserver = new ResizeObserver(checkScrollState);
|
||||||
|
resizeObserver.observe(nav);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
nav.removeEventListener('scroll', checkScrollState);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [checkScrollState, collapsedSections]);
|
||||||
|
|
||||||
|
const toggleSection = useCallback((label: string) => {
|
||||||
|
setCollapsedSections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[label]: !prev[label],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter sections: always show non-project sections, only show project sections when project exists
|
||||||
|
const visibleSections = navSections.filter((section) => {
|
||||||
|
// Always show Dashboard (first section with no label)
|
||||||
|
if (!section.label && section.items.some((item) => item.id === 'dashboard')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Show other sections only when project is selected
|
||||||
|
return !!currentProject;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
|
||||||
className={cn(
|
{/* Navigation sections */}
|
||||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
{visibleSections.map((section, sectionIdx) => {
|
||||||
sidebarOpen ? 'mt-1' : 'mt-1'
|
const isCollapsed = section.label ? collapsedSections[section.label] : false;
|
||||||
)}
|
const isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||||
>
|
|
||||||
{!currentProject && sidebarOpen ? (
|
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||||
// Placeholder when no project is selected (only in expanded state)
|
|
||||||
<div className="flex items-center justify-center h-full px-4">
|
return (
|
||||||
<p className="text-muted-foreground text-sm text-center">
|
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-4' : ''}>
|
||||||
<span className="block">Select or create a project above</span>
|
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : currentProject ? (
|
|
||||||
// Navigation sections when project is selected
|
|
||||||
navSections.map((section, sectionIdx) => (
|
|
||||||
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
|
|
||||||
{/* Section Label */}
|
|
||||||
{section.label && sidebarOpen && (
|
{section.label && sidebarOpen && (
|
||||||
<div className="px-3 mb-2">
|
<button
|
||||||
|
onClick={() => isCollapsible && toggleSection(section.label!)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center w-full px-3 mb-1.5',
|
||||||
|
isCollapsible && 'cursor-pointer hover:text-foreground'
|
||||||
|
)}
|
||||||
|
disabled={!isCollapsible}
|
||||||
|
>
|
||||||
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||||
{section.label}
|
{section.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{isCollapsible && (
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
|
||||||
|
isCollapsed && '-rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Section icon with dropdown (collapsed sidebar) */}
|
||||||
|
{section.label && !sidebarOpen && SectionIcon && section.collapsible && isCollapsed && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center justify-center w-full py-2 rounded-lg',
|
||||||
|
'text-muted-foreground hover:text-foreground',
|
||||||
|
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||||
|
'transition-all duration-200 ease-out'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SectionIcon className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
{section.label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-48">
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const ItemIcon = item.icon;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => navigate({ to: `/${item.id}` as unknown as '/' })}
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ItemIcon className="w-4 h-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.shortcut && (
|
||||||
|
<span className="ml-auto text-[10px] font-mono text-muted-foreground">
|
||||||
|
{formatShortcut(item.shortcut, true)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Separator for sections without label (visual separation) */}
|
{/* Separator for sections without label (visual separation) */}
|
||||||
{!section.label && sectionIdx > 0 && sidebarOpen && (
|
{!section.label && sectionIdx > 0 && sidebarOpen && (
|
||||||
<div className="h-px bg-border/40 mx-3 mb-4"></div>
|
<div className="h-px bg-border/40 mx-3 mb-3"></div>
|
||||||
)}
|
)}
|
||||||
{(section.label || sectionIdx > 0) && !sidebarOpen && (
|
{(section.label || sectionIdx > 0) && !sidebarOpen && (
|
||||||
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nav Items */}
|
{/* Nav Items - show when section is expanded, or when sidebar is collapsed and section doesn't use dropdown */}
|
||||||
<div className="space-y-1.5">
|
{!isCollapsed && (
|
||||||
{section.items.map((item) => {
|
<div className="space-y-1">
|
||||||
const isActive = isActiveRoute(item.id);
|
{section.items.map((item) => {
|
||||||
const Icon = item.icon;
|
const isActive = isActiveRoute(item.id);
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Cast to the router's path type; item.id is constrained to known routes
|
// Cast to the router's path type; item.id is constrained to known routes
|
||||||
navigate({ to: `/${item.id}` as unknown as '/' });
|
navigate({ to: `/${item.id}` as unknown as '/' });
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
isActive
|
isActive
|
||||||
? [
|
? [
|
||||||
// Active: Premium gradient with glow
|
// Active: Premium gradient with glow
|
||||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||||
'text-foreground font-medium',
|
'text-foreground font-medium',
|
||||||
'border border-brand-500/30',
|
'border border-brand-500/30',
|
||||||
'shadow-md shadow-brand-500/10',
|
'shadow-sm shadow-brand-500/10',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
// Inactive: Subtle hover state
|
// Inactive: Subtle hover state
|
||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'hover:bg-accent/50',
|
'hover:bg-accent/50',
|
||||||
'border border-transparent hover:border-border/40',
|
'border border-transparent hover:border-border/40',
|
||||||
'hover:shadow-sm',
|
],
|
||||||
],
|
sidebarOpen ? 'justify-start' : 'justify-center'
|
||||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
|
||||||
'hover:scale-[1.02] active:scale-[0.97]'
|
|
||||||
)}
|
|
||||||
title={!sidebarOpen ? item.label : undefined}
|
|
||||||
data-testid={`nav-${item.id}`}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
{item.isLoading ? (
|
|
||||||
<Spinner
|
|
||||||
size="md"
|
|
||||||
className={cn(
|
|
||||||
'shrink-0',
|
|
||||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Icon
|
|
||||||
className={cn(
|
|
||||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
|
||||||
isActive
|
|
||||||
? 'text-brand-500 drop-shadow-sm'
|
|
||||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{/* Count badge for collapsed state */}
|
title={!sidebarOpen ? item.label : undefined}
|
||||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
data-testid={`nav-${item.id}`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{item.isLoading ? (
|
||||||
|
<Spinner
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'shrink-0',
|
||||||
|
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
|
: 'group-hover:text-brand-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Count badge for collapsed state */}
|
||||||
|
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
||||||
|
'min-w-4 h-4 px-0.5 text-[9px] font-bold rounded-full',
|
||||||
|
'bg-primary text-primary-foreground shadow-sm',
|
||||||
|
'animate-in fade-in zoom-in duration-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.count > 99 ? '99' : item.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-3 text-sm flex-1 text-left',
|
||||||
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{/* Count badge */}
|
||||||
|
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
'flex items-center justify-center',
|
||||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||||
'bg-primary text-primary-foreground shadow-sm',
|
'bg-primary text-primary-foreground shadow-sm',
|
||||||
'animate-in fade-in zoom-in duration-200'
|
'animate-in fade-in zoom-in duration-200'
|
||||||
)}
|
)}
|
||||||
|
data-testid={`count-${item.id}`}
|
||||||
>
|
>
|
||||||
{item.count > 99 ? '99' : item.count}
|
{item.count > 99 ? '99+' : item.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
{item.shortcut && sidebarOpen && !item.count && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
|
||||||
sidebarOpen ? 'block' : 'hidden'
|
isActive
|
||||||
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid={`shortcut-${item.id}`}
|
||||||
|
>
|
||||||
|
{formatShortcut(item.shortcut, true)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
>
|
{/* Tooltip for collapsed state */}
|
||||||
{item.label}
|
{!sidebarOpen && (
|
||||||
</span>
|
<span
|
||||||
{/* Count badge */}
|
className={cn(
|
||||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
'absolute left-full ml-3 px-2.5 py-1.5 rounded-md',
|
||||||
<span
|
'bg-popover text-popover-foreground text-sm',
|
||||||
className={cn(
|
'border border-border shadow-lg',
|
||||||
'flex items-center justify-center',
|
'opacity-0 group-hover:opacity-100',
|
||||||
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
'transition-all duration-200 whitespace-nowrap z-50',
|
||||||
'bg-primary text-primary-foreground shadow-sm',
|
'translate-x-1 group-hover:translate-x-0'
|
||||||
'animate-in fade-in zoom-in duration-200'
|
)}
|
||||||
)}
|
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
|
||||||
data-testid={`count-${item.id}`}
|
>
|
||||||
>
|
{item.label}
|
||||||
{item.count > 99 ? '99+' : item.count}
|
{item.shortcut && (
|
||||||
</span>
|
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||||
)}
|
{formatShortcut(item.shortcut, true)}
|
||||||
{item.shortcut && sidebarOpen && !item.count && (
|
</span>
|
||||||
<span
|
)}
|
||||||
className={cn(
|
</span>
|
||||||
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
)}
|
||||||
isActive
|
</button>
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
);
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
})}
|
||||||
)}
|
</div>
|
||||||
data-testid={`shortcut-${item.id}`}
|
)}
|
||||||
>
|
|
||||||
{formatShortcut(item.shortcut, true)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Tooltip for collapsed state */}
|
|
||||||
{!sidebarOpen && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
|
||||||
'bg-popover text-popover-foreground text-xs font-medium',
|
|
||||||
'border border-border shadow-lg',
|
|
||||||
'opacity-0 group-hover:opacity-100',
|
|
||||||
'transition-all duration-200 whitespace-nowrap z-50',
|
|
||||||
'translate-x-1 group-hover:translate-x-0'
|
|
||||||
)}
|
|
||||||
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
{item.shortcut && (
|
|
||||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
|
||||||
{formatShortcut(item.shortcut, true)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
) : null}
|
})}
|
||||||
|
|
||||||
|
{/* Placeholder when no project is selected */}
|
||||||
|
{!currentProject && sidebarOpen && (
|
||||||
|
<div className="flex items-center justify-center px-4 py-8">
|
||||||
|
<p className="text-muted-foreground text-xs text-center">
|
||||||
|
Select or create a project to continue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Network,
|
Network,
|
||||||
Bell,
|
Bell,
|
||||||
Settings,
|
Settings,
|
||||||
|
Home,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NavSection, NavItem } from '../types';
|
import type { NavSection, NavItem } from '../types';
|
||||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||||
@@ -174,13 +175,30 @@ export function useNavigation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sections: NavSection[] = [
|
const sections: NavSection[] = [
|
||||||
|
// Dashboard - standalone at top
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Project section - expanded by default
|
||||||
{
|
{
|
||||||
label: 'Project',
|
label: 'Project',
|
||||||
items: projectItems,
|
items: projectItems,
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: false,
|
||||||
},
|
},
|
||||||
|
// Tools section - collapsed by default
|
||||||
{
|
{
|
||||||
label: 'Tools',
|
label: 'Tools',
|
||||||
items: visibleToolsItems,
|
items: visibleToolsItems,
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -203,6 +221,8 @@ export function useNavigation({
|
|||||||
shortcut: shortcuts.githubPrs,
|
shortcut: shortcuts.githubPrs,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
apps/ui/src/components/layout/sidebar/index.ts
Normal file
1
apps/ui/src/components/layout/sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Sidebar } from './sidebar';
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||||
|
import { PanelLeftClose, ChevronDown } from 'lucide-react';
|
||||||
const logger = createLogger('Sidebar');
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useNotificationsStore } from '@/store/notifications-store';
|
import { useNotificationsStore } from '@/store/notifications-store';
|
||||||
@@ -10,22 +9,18 @@ import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-ke
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
|
|
||||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
|
||||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
|
||||||
|
|
||||||
// Local imports from subfolder
|
|
||||||
import {
|
|
||||||
CollapseToggleButton,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarNavigation,
|
|
||||||
SidebarFooter,
|
|
||||||
MobileSidebarToggle,
|
|
||||||
} from './sidebar/components';
|
|
||||||
import { useIsCompact } from '@/hooks/use-media-query';
|
import { useIsCompact } from '@/hooks/use-media-query';
|
||||||
import { PanelLeftClose } from 'lucide-react';
|
import type { Project } from '@/lib/electron';
|
||||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
// Sidebar components
|
||||||
|
import {
|
||||||
|
SidebarNavigation,
|
||||||
|
CollapseToggleButton,
|
||||||
|
MobileSidebarToggle,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarFooter,
|
||||||
|
} from './components';
|
||||||
|
import { SIDEBAR_FEATURE_FLAGS } from './constants';
|
||||||
import {
|
import {
|
||||||
useSidebarAutoCollapse,
|
useSidebarAutoCollapse,
|
||||||
useRunningAgents,
|
useRunningAgents,
|
||||||
@@ -35,7 +30,19 @@ import {
|
|||||||
useSetupDialog,
|
useSetupDialog,
|
||||||
useTrashOperations,
|
useTrashOperations,
|
||||||
useUnviewedValidations,
|
useUnviewedValidations,
|
||||||
} from './sidebar/hooks';
|
} 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() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -59,12 +66,14 @@ export function Sidebar() {
|
|||||||
moveProjectToTrash,
|
moveProjectToTrash,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
|
setCurrentProject,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const isCompact = useIsCompact();
|
const isCompact = useIsCompact();
|
||||||
|
|
||||||
// Environment variable flags for hiding sidebar items
|
// Environment variable flags for hiding sidebar items
|
||||||
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
|
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor, hideWiki } =
|
||||||
|
SIDEBAR_FEATURE_FLAGS;
|
||||||
|
|
||||||
// Get customizable keyboard shortcuts
|
// Get customizable keyboard shortcuts
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
@@ -72,6 +81,13 @@ export function Sidebar() {
|
|||||||
// Get unread notifications count
|
// Get unread notifications count
|
||||||
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
|
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
|
// State for delete project confirmation dialog
|
||||||
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
||||||
|
|
||||||
@@ -129,7 +145,7 @@ export function Sidebar() {
|
|||||||
const isCurrentProjectGeneratingSpec =
|
const isCurrentProjectGeneratingSpec =
|
||||||
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
||||||
|
|
||||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
// Auto-collapse sidebar on small screens
|
||||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||||
|
|
||||||
// Running agents count
|
// Running agents count
|
||||||
@@ -163,9 +179,28 @@ export function Sidebar() {
|
|||||||
setNewProjectPath,
|
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.
|
* Opens the system folder selection dialog and initializes the selected project.
|
||||||
* Used by both the 'O' keyboard shortcut and the folder icon button.
|
|
||||||
*/
|
*/
|
||||||
const handleOpenFolder = useCallback(async () => {
|
const handleOpenFolder = useCallback(async () => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -173,14 +208,10 @@ export function Sidebar() {
|
|||||||
|
|
||||||
if (!result.canceled && result.filePaths[0]) {
|
if (!result.canceled && result.filePaths[0]) {
|
||||||
const path = result.filePaths[0];
|
const path = result.filePaths[0];
|
||||||
// Extract folder name from path (works on both Windows and Mac/Linux)
|
|
||||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if this is a brand new project (no .automaker directory)
|
|
||||||
const hadAutomakerDir = await hasAutomakerDir(path);
|
const hadAutomakerDir = await hasAutomakerDir(path);
|
||||||
|
|
||||||
// Initialize the .automaker directory structure
|
|
||||||
const initResult = await initializeProject(path);
|
const initResult = await initializeProject(path);
|
||||||
|
|
||||||
if (!initResult.success) {
|
if (!initResult.success) {
|
||||||
@@ -190,15 +221,10 @@ export function Sidebar() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert project and set as current (handles both create and update cases)
|
|
||||||
// Theme handling (trashed project recovery or undefined for global) is done by the store
|
|
||||||
upsertAndSetCurrentProject(path, name);
|
upsertAndSetCurrentProject(path, name);
|
||||||
|
|
||||||
// Check if app_spec.txt exists
|
|
||||||
const specExists = await hasAppSpec(path);
|
const specExists = await hasAppSpec(path);
|
||||||
|
|
||||||
if (!hadAutomakerDir && !specExists) {
|
if (!hadAutomakerDir && !specExists) {
|
||||||
// This is a brand new project - show setup dialog
|
|
||||||
setSetupProjectPath(path);
|
setSetupProjectPath(path);
|
||||||
setShowSetupDialog(true);
|
setShowSetupDialog(true);
|
||||||
toast.success('Project opened', {
|
toast.success('Project opened', {
|
||||||
@@ -213,6 +239,8 @@ export function Sidebar() {
|
|||||||
description: `Opened ${name}`,
|
description: `Opened ${name}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
navigate({ to: '/board' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to open project:', error);
|
logger.error('Failed to open project:', error);
|
||||||
toast.error('Failed to open project', {
|
toast.error('Failed to open project', {
|
||||||
@@ -220,9 +248,13 @@ export function Sidebar() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [upsertAndSetCurrentProject]);
|
}, [upsertAndSetCurrentProject, navigate, setSetupProjectPath, setShowSetupDialog]);
|
||||||
|
|
||||||
// Navigation sections and keyboard shortcuts (defined after handlers)
|
const handleNewProject = useCallback(() => {
|
||||||
|
setShowNewProjectModal(true);
|
||||||
|
}, [setShowNewProjectModal]);
|
||||||
|
|
||||||
|
// Navigation sections and keyboard shortcuts
|
||||||
const { navSections, navigationShortcuts } = useNavigation({
|
const { navSections, navigationShortcuts } = useNavigation({
|
||||||
shortcuts,
|
shortcuts,
|
||||||
hideSpecEditor,
|
hideSpecEditor,
|
||||||
@@ -244,12 +276,48 @@ export function Sidebar() {
|
|||||||
// Register keyboard shortcuts
|
// Register keyboard shortcuts
|
||||||
useKeyboardShortcuts(navigationShortcuts);
|
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 isActiveRoute = (id: string) => {
|
||||||
// Map view IDs to route paths
|
|
||||||
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
||||||
return location.pathname === routePath;
|
return location.pathname === routePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Track if nav can scroll down
|
||||||
|
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||||
|
|
||||||
// Check if sidebar should be completely hidden on mobile
|
// Check if sidebar should be completely hidden on mobile
|
||||||
const shouldHideSidebar = isCompact && mobileSidebarHidden;
|
const shouldHideSidebar = isCompact && mobileSidebarHidden;
|
||||||
|
|
||||||
@@ -266,6 +334,7 @@ export function Sidebar() {
|
|||||||
data-testid="sidebar-backdrop"
|
data-testid="sidebar-backdrop"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-shrink-0 flex flex-col z-30',
|
'flex-shrink-0 flex flex-col z-30',
|
||||||
@@ -277,9 +346,11 @@ export function Sidebar() {
|
|||||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
// Mobile: completely hidden when mobileSidebarHidden is true
|
// Mobile: completely hidden when mobileSidebarHidden is true
|
||||||
shouldHideSidebar && 'hidden',
|
shouldHideSidebar && 'hidden',
|
||||||
// Mobile: overlay when open, collapsed when closed
|
// Width based on state
|
||||||
!shouldHideSidebar &&
|
!shouldHideSidebar &&
|
||||||
(sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16')
|
(sidebarOpen
|
||||||
|
? 'fixed inset-y-0 left-0 w-[17rem] lg:relative lg:w-[17rem]'
|
||||||
|
: 'relative w-14')
|
||||||
)}
|
)}
|
||||||
data-testid="sidebar"
|
data-testid="sidebar"
|
||||||
>
|
>
|
||||||
@@ -313,8 +384,9 @@ export function Sidebar() {
|
|||||||
<SidebarHeader
|
<SidebarHeader
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
onClose={toggleSidebar}
|
onNewProject={handleNewProject}
|
||||||
onExpand={toggleSidebar}
|
onOpenFolder={handleOpenFolder}
|
||||||
|
onProjectContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SidebarNavigation
|
<SidebarNavigation
|
||||||
@@ -323,17 +395,27 @@ export function Sidebar() {
|
|||||||
navSections={navSections}
|
navSections={navSections}
|
||||||
isActiveRoute={isActiveRoute}
|
isActiveRoute={isActiveRoute}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
onScrollStateChange={setCanScrollDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<SidebarFooter
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
isActiveRoute={isActiveRoute}
|
isActiveRoute={isActiveRoute}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
hideRunningAgents={hideRunningAgents}
|
hideRunningAgents={hideRunningAgents}
|
||||||
|
hideWiki={hideWiki}
|
||||||
runningAgentsCount={runningAgentsCount}
|
runningAgentsCount={runningAgentsCount}
|
||||||
shortcuts={{ settings: shortcuts.settings }}
|
shortcuts={{ settings: shortcuts.settings }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TrashDialog
|
<TrashDialog
|
||||||
open={showTrashDialog}
|
open={showTrashDialog}
|
||||||
onOpenChange={setShowTrashDialog}
|
onOpenChange={setShowTrashDialog}
|
||||||
@@ -392,6 +474,25 @@ export function Sidebar() {
|
|||||||
isCreating={isCreatingProject}
|
isCreating={isCreatingProject}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,10 @@ import type React from 'react';
|
|||||||
export interface NavSection {
|
export interface NavSection {
|
||||||
label?: string;
|
label?: string;
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
|
/** Whether this section can be collapsed */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Whether this section should start collapsed */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 { Sidebar } from '@/components/layout/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,7 +860,6 @@ function RootLayoutContent() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showProjectSwitcher && <ProjectSwitcher />}
|
|
||||||
<Sidebar />
|
<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"
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
getKanbanColumn,
|
getKanbanColumn,
|
||||||
authenticateForTests,
|
authenticateForTests,
|
||||||
handleLoginScreenIfPresent,
|
handleLoginScreenIfPresent,
|
||||||
sanitizeForTestId,
|
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('manual-review-test');
|
const TEST_TEMP_DIR = createTempDirPath('manual-review-test');
|
||||||
@@ -131,10 +130,10 @@ test.describe('Feature Manual Review Flow', () => {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify we're on the correct project (project switcher button shows project name)
|
// Verify we're on the correct project (project dropdown trigger shows project name)
|
||||||
// Use ends-with selector since data-testid format is: project-switcher-{id}-{sanitizedName}
|
await expect(
|
||||||
const sanitizedProjectName = sanitizeForTestId(projectName);
|
page.locator('[data-testid="project-dropdown-trigger"]').getByText(projectName)
|
||||||
await expect(page.locator(`[data-testid$="-${sanitizedProjectName}"]`)).toBeVisible({
|
).toBeVisible({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
authenticateForTests,
|
authenticateForTests,
|
||||||
handleLoginScreenIfPresent,
|
handleLoginScreenIfPresent,
|
||||||
waitForNetworkIdle,
|
waitForNetworkIdle,
|
||||||
sanitizeForTestId,
|
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('project-creation-test');
|
const TEST_TEMP_DIR = createTempDirPath('project-creation-test');
|
||||||
@@ -78,10 +77,10 @@ test.describe('Project Creation', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for project to be set as current and visible on the page
|
// Wait for project to be set as current and visible on the page
|
||||||
// The project name appears in the project switcher button
|
// The project name appears in the project dropdown trigger
|
||||||
// Use ends-with selector since data-testid format is: project-switcher-{id}-{sanitizedName}
|
await expect(
|
||||||
const sanitizedProjectName = sanitizeForTestId(projectName);
|
page.locator('[data-testid="project-dropdown-trigger"]').getByText(projectName)
|
||||||
await expect(page.locator(`[data-testid$="-${sanitizedProjectName}"]`)).toBeVisible({
|
).toBeVisible({
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
authenticateForTests,
|
authenticateForTests,
|
||||||
handleLoginScreenIfPresent,
|
handleLoginScreenIfPresent,
|
||||||
waitForNetworkIdle,
|
waitForNetworkIdle,
|
||||||
sanitizeForTestId,
|
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
// Create unique temp dir for this test run
|
// Create unique temp dir for this test run
|
||||||
@@ -169,11 +168,11 @@ test.describe('Open Project', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for a project to be set as current and visible on the page
|
// Wait for a project to be set as current and visible on the page
|
||||||
// The project name appears in the project switcher button
|
// The project name appears in the project dropdown trigger
|
||||||
// Use ends-with selector since data-testid format is: project-switcher-{id}-{sanitizedName}
|
|
||||||
if (targetProjectName) {
|
if (targetProjectName) {
|
||||||
const sanitizedName = sanitizeForTestId(targetProjectName);
|
await expect(
|
||||||
await expect(page.locator(`[data-testid$="-${sanitizedName}"]`)).toBeVisible({
|
page.locator('[data-testid="project-dropdown-trigger"]').getByText(targetProjectName)
|
||||||
|
).toBeVisible({
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user