mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
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
This commit is contained in:
@@ -1,2 +1 @@
|
|||||||
export { Sidebar } from './sidebar';
|
export { Sidebar } from './sidebar';
|
||||||
export { UnifiedSidebar } from './unified-sidebar';
|
|
||||||
|
|||||||
@@ -1,397 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
const logger = createLogger('Sidebar');
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { useNotificationsStore } from '@/store/notifications-store';
|
|
||||||
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
|
|
||||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
|
||||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
|
||||||
|
|
||||||
// Local imports from subfolder
|
|
||||||
import {
|
|
||||||
CollapseToggleButton,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarNavigation,
|
|
||||||
SidebarFooter,
|
|
||||||
MobileSidebarToggle,
|
|
||||||
} from './sidebar/components';
|
|
||||||
import { useIsCompact } from '@/hooks/use-media-query';
|
|
||||||
import { PanelLeftClose } from 'lucide-react';
|
|
||||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
|
||||||
import {
|
|
||||||
useSidebarAutoCollapse,
|
|
||||||
useRunningAgents,
|
|
||||||
useSpecRegeneration,
|
|
||||||
useNavigation,
|
|
||||||
useProjectCreation,
|
|
||||||
useSetupDialog,
|
|
||||||
useTrashOperations,
|
|
||||||
useUnviewedValidations,
|
|
||||||
} from './sidebar/hooks';
|
|
||||||
|
|
||||||
export function Sidebar() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const {
|
|
||||||
projects,
|
|
||||||
trashedProjects,
|
|
||||||
currentProject,
|
|
||||||
sidebarOpen,
|
|
||||||
mobileSidebarHidden,
|
|
||||||
projectHistory,
|
|
||||||
upsertAndSetCurrentProject,
|
|
||||||
toggleSidebar,
|
|
||||||
toggleMobileSidebarHidden,
|
|
||||||
restoreTrashedProject,
|
|
||||||
deleteTrashedProject,
|
|
||||||
emptyTrash,
|
|
||||||
cyclePrevProject,
|
|
||||||
cycleNextProject,
|
|
||||||
moveProjectToTrash,
|
|
||||||
specCreatingForProject,
|
|
||||||
setSpecCreatingForProject,
|
|
||||||
} = useAppStore();
|
|
||||||
|
|
||||||
const isCompact = useIsCompact();
|
|
||||||
|
|
||||||
// Environment variable flags for hiding sidebar items
|
|
||||||
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
|
|
||||||
|
|
||||||
// Get customizable keyboard shortcuts
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
|
||||||
|
|
||||||
// Get unread notifications count
|
|
||||||
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
|
|
||||||
|
|
||||||
// State for delete project confirmation dialog
|
|
||||||
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
|
||||||
|
|
||||||
// State for trash dialog
|
|
||||||
const [showTrashDialog, setShowTrashDialog] = useState(false);
|
|
||||||
|
|
||||||
// Project creation state and handlers
|
|
||||||
const {
|
|
||||||
showNewProjectModal,
|
|
||||||
setShowNewProjectModal,
|
|
||||||
isCreatingProject,
|
|
||||||
showOnboardingDialog,
|
|
||||||
setShowOnboardingDialog,
|
|
||||||
newProjectName,
|
|
||||||
setNewProjectName,
|
|
||||||
newProjectPath,
|
|
||||||
setNewProjectPath,
|
|
||||||
handleCreateBlankProject,
|
|
||||||
handleCreateFromTemplate,
|
|
||||||
handleCreateFromCustomUrl,
|
|
||||||
} = useProjectCreation({
|
|
||||||
upsertAndSetCurrentProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup dialog state and handlers
|
|
||||||
const {
|
|
||||||
showSetupDialog,
|
|
||||||
setShowSetupDialog,
|
|
||||||
setupProjectPath,
|
|
||||||
setSetupProjectPath,
|
|
||||||
projectOverview,
|
|
||||||
setProjectOverview,
|
|
||||||
generateFeatures,
|
|
||||||
setGenerateFeatures,
|
|
||||||
analyzeProject,
|
|
||||||
setAnalyzeProject,
|
|
||||||
featureCount,
|
|
||||||
setFeatureCount,
|
|
||||||
handleCreateInitialSpec,
|
|
||||||
handleSkipSetup,
|
|
||||||
handleOnboardingGenerateSpec,
|
|
||||||
handleOnboardingSkip,
|
|
||||||
} = useSetupDialog({
|
|
||||||
setSpecCreatingForProject,
|
|
||||||
newProjectPath,
|
|
||||||
setNewProjectName,
|
|
||||||
setNewProjectPath,
|
|
||||||
setShowOnboardingDialog,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derive isCreatingSpec from store state
|
|
||||||
const isCreatingSpec = specCreatingForProject !== null;
|
|
||||||
const creatingSpecProjectPath = specCreatingForProject;
|
|
||||||
// Check if the current project is specifically the one generating spec
|
|
||||||
const isCurrentProjectGeneratingSpec =
|
|
||||||
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
|
||||||
|
|
||||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
|
||||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
|
||||||
|
|
||||||
// Running agents count
|
|
||||||
const { runningAgentsCount } = useRunningAgents();
|
|
||||||
|
|
||||||
// Unviewed validations count
|
|
||||||
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
|
|
||||||
|
|
||||||
// Trash operations
|
|
||||||
const {
|
|
||||||
activeTrashId,
|
|
||||||
isEmptyingTrash,
|
|
||||||
handleRestoreProject,
|
|
||||||
handleDeleteProjectFromDisk,
|
|
||||||
handleEmptyTrash,
|
|
||||||
} = useTrashOperations({
|
|
||||||
restoreTrashedProject,
|
|
||||||
deleteTrashedProject,
|
|
||||||
emptyTrash,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spec regeneration events
|
|
||||||
useSpecRegeneration({
|
|
||||||
creatingSpecProjectPath,
|
|
||||||
setupProjectPath,
|
|
||||||
setSpecCreatingForProject,
|
|
||||||
setShowSetupDialog,
|
|
||||||
setProjectOverview,
|
|
||||||
setSetupProjectPath,
|
|
||||||
setNewProjectName,
|
|
||||||
setNewProjectPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the system folder selection dialog and initializes the selected project.
|
|
||||||
* Used by both the 'O' keyboard shortcut and the folder icon button.
|
|
||||||
*/
|
|
||||||
const handleOpenFolder = useCallback(async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.openDirectory();
|
|
||||||
|
|
||||||
if (!result.canceled && result.filePaths[0]) {
|
|
||||||
const path = result.filePaths[0];
|
|
||||||
// Extract folder name from path (works on both Windows and Mac/Linux)
|
|
||||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if this is a brand new project (no .automaker directory)
|
|
||||||
const hadAutomakerDir = await hasAutomakerDir(path);
|
|
||||||
|
|
||||||
// Initialize the .automaker directory structure
|
|
||||||
const initResult = await initializeProject(path);
|
|
||||||
|
|
||||||
if (!initResult.success) {
|
|
||||||
toast.error('Failed to initialize project', {
|
|
||||||
description: initResult.error || 'Unknown error occurred',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert project and set as current (handles both create and update cases)
|
|
||||||
// Theme handling (trashed project recovery or undefined for global) is done by the store
|
|
||||||
upsertAndSetCurrentProject(path, name);
|
|
||||||
|
|
||||||
// Check if app_spec.txt exists
|
|
||||||
const specExists = await hasAppSpec(path);
|
|
||||||
|
|
||||||
if (!hadAutomakerDir && !specExists) {
|
|
||||||
// This is a brand new project - show setup dialog
|
|
||||||
setSetupProjectPath(path);
|
|
||||||
setShowSetupDialog(true);
|
|
||||||
toast.success('Project opened', {
|
|
||||||
description: `Opened ${name}. Let's set up your app specification!`,
|
|
||||||
});
|
|
||||||
} else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
|
|
||||||
toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
|
|
||||||
description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.success('Project opened', {
|
|
||||||
description: `Opened ${name}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to open project:', error);
|
|
||||||
toast.error('Failed to open project', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [upsertAndSetCurrentProject]);
|
|
||||||
|
|
||||||
// Navigation sections and keyboard shortcuts (defined after handlers)
|
|
||||||
const { navSections, navigationShortcuts } = useNavigation({
|
|
||||||
shortcuts,
|
|
||||||
hideSpecEditor,
|
|
||||||
hideContext,
|
|
||||||
hideTerminal,
|
|
||||||
currentProject,
|
|
||||||
projects,
|
|
||||||
projectHistory,
|
|
||||||
navigate,
|
|
||||||
toggleSidebar,
|
|
||||||
handleOpenFolder,
|
|
||||||
cyclePrevProject,
|
|
||||||
cycleNextProject,
|
|
||||||
unviewedValidationsCount,
|
|
||||||
unreadNotificationsCount,
|
|
||||||
isSpecGenerating: isCurrentProjectGeneratingSpec,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register keyboard shortcuts
|
|
||||||
useKeyboardShortcuts(navigationShortcuts);
|
|
||||||
|
|
||||||
const isActiveRoute = (id: string) => {
|
|
||||||
// Map view IDs to route paths
|
|
||||||
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
|
||||||
return location.pathname === routePath;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if sidebar should be completely hidden on mobile
|
|
||||||
const shouldHideSidebar = isCompact && mobileSidebarHidden;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Floating toggle to show sidebar on mobile when hidden */}
|
|
||||||
<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',
|
|
||||||
// Mobile: overlay when open, collapsed when closed
|
|
||||||
!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}
|
|
||||||
onClose={toggleSidebar}
|
|
||||||
onExpand={toggleSidebar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SidebarNavigation
|
|
||||||
currentProject={currentProject}
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
navSections={navSections}
|
|
||||||
isActiveRoute={isActiveRoute}
|
|
||||||
navigate={navigate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SidebarFooter
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
isActiveRoute={isActiveRoute}
|
|
||||||
navigate={navigate}
|
|
||||||
hideRunningAgents={hideRunningAgents}
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,220 @@ 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(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Collapsed state
|
||||||
|
if (!sidebarOpen) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0',
|
'shrink-0 border-t border-border/40',
|
||||||
// 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'
|
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Running Agents Link */}
|
<div className="flex flex-col items-center py-2 px-2 gap-1">
|
||||||
|
{/* Running Agents */}
|
||||||
{!hideRunningAgents && (
|
{!hideRunningAgents && (
|
||||||
<div className="p-2 pb-0">
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<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',
|
'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 (
|
||||||
|
<div className="shrink-0">
|
||||||
|
{/* Running Agents Link */}
|
||||||
|
{!hideRunningAgents && (
|
||||||
|
<div className="px-3 py-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/running-agents' })}
|
||||||
|
className={cn(
|
||||||
|
'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(
|
className={cn(
|
||||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
isActiveRoute('running-agents')
|
isActiveRoute('running-agents')
|
||||||
? 'text-brand-500 drop-shadow-sm'
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
: 'group-hover:text-brand-400'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* Running agents count badge - shown in collapsed state */}
|
<span className="ml-3 text-sm flex-1 text-left">Running Agents</span>
|
||||||
{!sidebarOpen && runningAgentsCount > 0 && (
|
{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(
|
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
|
||||||
sidebarOpen ? 'block' : 'hidden'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Running Agents
|
|
||||||
</span>
|
|
||||||
{/* 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 +258,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,21 +289,13 @@ 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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Global Settings
|
|
||||||
</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')
|
isActiveRoute('settings')
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
@@ -182,26 +304,55 @@ export function SidebarFooter({
|
|||||||
>
|
>
|
||||||
{formatShortcut(shortcuts.settings, true)}
|
{formatShortcut(shortcuts.settings, true)}
|
||||||
</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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
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,406 @@
|
|||||||
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 { 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 (
|
return (
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 flex flex-col relative',
|
|
||||||
// Add padding on macOS Electron for traffic light buttons
|
|
||||||
isMac && isElectron() && 'pt-[10px]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Mobile close button - only visible on mobile when sidebar is open */}
|
|
||||||
{sidebarOpen && onClose && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={cn(
|
|
||||||
'lg:hidden absolute top-3 right-3 z-10',
|
|
||||||
'flex items-center justify-center w-8 h-8 rounded-lg',
|
|
||||||
'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" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
|
|
||||||
{!sidebarOpen && isCompact && onExpand && (
|
|
||||||
<button
|
|
||||||
onClick={onExpand}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
|
|
||||||
'bg-muted/50 hover:bg-muted',
|
|
||||||
'text-muted-foreground hover:text-foreground',
|
|
||||||
'transition-colors duration-200'
|
|
||||||
)}
|
|
||||||
aria-label="Expand navigation"
|
|
||||||
data-testid="sidebar-mobile-expand"
|
|
||||||
>
|
|
||||||
<Menu className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Project name and icon display - entire element clickable on mobile */}
|
|
||||||
{currentProject && (
|
|
||||||
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
|
|
||||||
'rounded-lg transition-colors duration-150',
|
|
||||||
!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 */}
|
|
||||||
<div className="shrink-0">
|
|
||||||
{hasCustomIcon ? (
|
|
||||||
<img
|
|
||||||
src={getAuthenticatedImageUrl(
|
|
||||||
currentProject.customIconPath!,
|
|
||||||
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 */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-sm font-semibold text-foreground truncate">
|
|
||||||
{currentProject.name}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
|
|
||||||
{projects.map((project) => {
|
|
||||||
const ProjectIcon =
|
|
||||||
project.icon && project.icon in LucideIcons
|
|
||||||
? (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon]
|
|
||||||
: Folder;
|
|
||||||
const isActive = currentProject?.id === project.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={project.id}
|
|
||||||
onClick={() => {
|
|
||||||
setCurrentProject(project);
|
|
||||||
setProjectListOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
|
|
||||||
'transition-colors duration-150',
|
|
||||||
isActive
|
|
||||||
? 'bg-brand-500/10 text-brand-500'
|
|
||||||
: 'hover:bg-accent text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{project.customIconPath ? (
|
|
||||||
<img
|
<img
|
||||||
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
|
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
|
||||||
alt={project.name}
|
alt={project.name}
|
||||||
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
|
className={cn(sizeClasses, 'rounded-lg object-cover ring-1 ring-border/50')}
|
||||||
/>
|
/>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-6 h-6 rounded flex items-center justify-center',
|
sizeClasses,
|
||||||
isActive ? 'bg-brand-500/20' : 'bg-muted'
|
'rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ProjectIcon
|
<IconComponent className={cn(iconSizeClasses, 'text-brand-500')} />
|
||||||
className={cn(
|
|
||||||
'w-4 h-4',
|
|
||||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</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]'
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 text-sm truncate">{project.name}</span>
|
>
|
||||||
{isActive && <Check className="w-4 h-4 text-brand-500" />}
|
<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>
|
</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">⌘{hotkeyLabel}</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
<DropdownMenuSeparator />
|
||||||
</PopoverContent>
|
<DropdownMenuItem
|
||||||
</Popover>
|
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>
|
</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>
|
||||||
|
<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 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={`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">⌘{hotkeyLabel}</span>
|
||||||
|
)}
|
||||||
|
</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,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,43 +35,166 @@ 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(
|
className={cn(
|
||||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
||||||
sidebarOpen ? 'mt-1' : 'mt-1'
|
sidebarOpen ? 'mt-1' : 'mt-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!currentProject && sidebarOpen ? (
|
{/* Navigation sections */}
|
||||||
// Placeholder when no project is selected (only in expanded state)
|
{visibleSections.map((section, sectionIdx) => {
|
||||||
<div className="flex items-center justify-center h-full px-4">
|
const isCollapsed = section.label ? collapsedSections[section.label] : false;
|
||||||
<p className="text-muted-foreground text-sm text-center">
|
const isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||||
<span className="block">Select or create a project above</span>
|
|
||||||
</p>
|
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||||
</div>
|
|
||||||
) : currentProject ? (
|
return (
|
||||||
// Navigation sections when project is selected
|
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-4' : ''}>
|
||||||
navSections.map((section, sectionIdx) => (
|
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||||
<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 && (
|
||||||
|
<div className="space-y-1">
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const isActive = isActiveRoute(item.id);
|
const isActive = isActiveRoute(item.id);
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@@ -68,7 +207,7 @@ export function SidebarNavigation({
|
|||||||
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
|
||||||
? [
|
? [
|
||||||
@@ -76,17 +215,15 @@ export function SidebarNavigation({
|
|||||||
'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}
|
title={!sidebarOpen ? item.label : undefined}
|
||||||
data-testid={`nav-${item.id}`}
|
data-testid={`nav-${item.id}`}
|
||||||
@@ -94,7 +231,7 @@ export function SidebarNavigation({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
{item.isLoading ? (
|
{item.isLoading ? (
|
||||||
<Spinner
|
<Spinner
|
||||||
size="md"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0',
|
'shrink-0',
|
||||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||||
@@ -106,7 +243,7 @@ export function SidebarNavigation({
|
|||||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'text-brand-500 drop-shadow-sm'
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
: 'group-hover:text-brand-400'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -115,7 +252,7 @@ export function SidebarNavigation({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
'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',
|
'min-w-4 h-4 px-0.5 text-[9px] 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'
|
||||||
)}
|
)}
|
||||||
@@ -126,7 +263,7 @@ export function SidebarNavigation({
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'ml-3 text-sm flex-1 text-left',
|
||||||
sidebarOpen ? 'block' : 'hidden'
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -149,7 +286,7 @@ export function SidebarNavigation({
|
|||||||
{item.shortcut && sidebarOpen && !item.count && (
|
{item.shortcut && sidebarOpen && !item.count && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||||
@@ -163,8 +300,8 @@ export function SidebarNavigation({
|
|||||||
{!sidebarOpen && (
|
{!sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
'absolute left-full ml-3 px-2.5 py-1.5 rounded-md',
|
||||||
'bg-popover text-popover-foreground text-xs font-medium',
|
'bg-popover text-popover-foreground text-sm',
|
||||||
'border border-border shadow-lg',
|
'border border-border shadow-lg',
|
||||||
'opacity-0 group-hover:opacity-100',
|
'opacity-0 group-hover:opacity-100',
|
||||||
'transition-all duration-200 whitespace-nowrap z-50',
|
'transition-all duration-200 whitespace-nowrap z-50',
|
||||||
@@ -184,9 +321,19 @@ export function SidebarNavigation({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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,7 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } 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 } from 'lucide-react';
|
import { PanelLeftClose, ChevronDown } from 'lucide-react';
|
||||||
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';
|
||||||
@@ -12,9 +12,15 @@ import { toast } from 'sonner';
|
|||||||
import { useIsCompact } from '@/hooks/use-media-query';
|
import { useIsCompact } from '@/hooks/use-media-query';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
// Reuse existing sidebar components
|
// Sidebar components
|
||||||
import { SidebarNavigation, CollapseToggleButton, MobileSidebarToggle } from '../sidebar/components';
|
import {
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from '../sidebar/constants';
|
SidebarNavigation,
|
||||||
|
CollapseToggleButton,
|
||||||
|
MobileSidebarToggle,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarFooter,
|
||||||
|
} from './components';
|
||||||
|
import { SIDEBAR_FEATURE_FLAGS } from './constants';
|
||||||
import {
|
import {
|
||||||
useSidebarAutoCollapse,
|
useSidebarAutoCollapse,
|
||||||
useRunningAgents,
|
useRunningAgents,
|
||||||
@@ -24,8 +30,8 @@ import {
|
|||||||
useSetupDialog,
|
useSetupDialog,
|
||||||
useTrashOperations,
|
useTrashOperations,
|
||||||
useUnviewedValidations,
|
useUnviewedValidations,
|
||||||
} from '../sidebar/hooks';
|
} from './hooks';
|
||||||
import { TrashDialog, OnboardingDialog } from '../sidebar/dialogs';
|
import { TrashDialog, OnboardingDialog } from './dialogs';
|
||||||
|
|
||||||
// Reuse dialogs from project-switcher
|
// Reuse dialogs from project-switcher
|
||||||
import { ProjectContextMenu } from '../project-switcher/components/project-context-menu';
|
import { ProjectContextMenu } from '../project-switcher/components/project-context-menu';
|
||||||
@@ -36,12 +42,9 @@ import { DeleteProjectDialog } from '@/components/views/settings-view/components
|
|||||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||||
|
|
||||||
// Local components
|
const logger = createLogger('Sidebar');
|
||||||
import { SidebarHeader, SidebarFooter } from './components';
|
|
||||||
|
|
||||||
const logger = createLogger('UnifiedSidebar');
|
export function Sidebar() {
|
||||||
|
|
||||||
export function UnifiedSidebar() {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -188,10 +191,13 @@ export function UnifiedSidebar() {
|
|||||||
setContextMenuPosition(null);
|
setContextMenuPosition(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditProject = useCallback((project: Project) => {
|
const handleEditProject = useCallback(
|
||||||
|
(project: Project) => {
|
||||||
setEditDialogProject(project);
|
setEditDialogProject(project);
|
||||||
handleCloseContextMenu();
|
handleCloseContextMenu();
|
||||||
}, [handleCloseContextMenu]);
|
},
|
||||||
|
[handleCloseContextMenu]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the system folder selection dialog and initializes the selected project.
|
* Opens the system folder selection dialog and initializes the selected project.
|
||||||
@@ -309,6 +315,9 @@ export function UnifiedSidebar() {
|
|||||||
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;
|
||||||
|
|
||||||
@@ -339,7 +348,9 @@ export function UnifiedSidebar() {
|
|||||||
shouldHideSidebar && 'hidden',
|
shouldHideSidebar && 'hidden',
|
||||||
// Width based on state
|
// 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"
|
||||||
>
|
>
|
||||||
@@ -384,9 +395,17 @@ export function UnifiedSidebar() {
|
|||||||
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}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export { SidebarHeader } from './sidebar-header';
|
|
||||||
export { SidebarFooter } from './sidebar-footer';
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import { ChevronDown, Folder, Plus, FolderOpen, Check } from 'lucide-react';
|
|
||||||
import * as LucideIcons from 'lucide-react';
|
|
||||||
import type { LucideIcon } from 'lucide-react';
|
|
||||||
import { cn, isMac } from '@/lib/utils';
|
|
||||||
import { isElectron, type Project } from '@/lib/electron';
|
|
||||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
|
||||||
sidebarOpen: boolean;
|
|
||||||
currentProject: Project | null;
|
|
||||||
onNewProject: () => void;
|
|
||||||
onOpenFolder: () => void;
|
|
||||||
onProjectContextMenu: (project: Project, event: React.MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarHeader({
|
|
||||||
sidebarOpen,
|
|
||||||
currentProject,
|
|
||||||
onNewProject,
|
|
||||||
onOpenFolder,
|
|
||||||
onProjectContextMenu,
|
|
||||||
}: SidebarHeaderProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { projects, setCurrentProject } = useAppStore();
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleLogoClick = useCallback(() => {
|
|
||||||
navigate({ to: '/dashboard' });
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const handleProjectSelect = useCallback(
|
|
||||||
(project: Project) => {
|
|
||||||
setCurrentProject(project);
|
|
||||||
setDropdownOpen(false);
|
|
||||||
navigate({ to: '/board' });
|
|
||||||
},
|
|
||||||
[setCurrentProject, navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getIconComponent = (project: Project): LucideIcon => {
|
|
||||||
if (project?.icon && project.icon in LucideIcons) {
|
|
||||||
return (LucideIcons as unknown as Record<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 +0,0 @@
|
|||||||
export { UnifiedSidebar } from './unified-sidebar';
|
|
||||||
@@ -3,7 +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 { UnifiedSidebar } from '@/components/layout/unified-sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
useFileBrowser,
|
useFileBrowser,
|
||||||
@@ -860,7 +860,7 @@ function RootLayoutContent() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<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