mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge main into kanban-scaling
Resolves merge conflicts while preserving: - Kanban scaling improvements (window sizing, bounce prevention, debouncing) - Main's sidebar refactoring into hooks - Main's openInEditor functionality for VS Code integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
apps/ui/src/components/layout/index.ts
Normal file
1
apps/ui/src/components/layout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Sidebar } from './sidebar';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AutomakerLogoProps {
|
||||
sidebarOpen: boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
}
|
||||
|
||||
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
|
||||
!sidebarOpen && 'flex-col gap-1'
|
||||
)}
|
||||
onClick={() => navigate({ to: '/' })}
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{!sidebarOpen ? (
|
||||
<div className="relative flex items-center justify-center rounded-lg">
|
||||
<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>
|
||||
<filter id="iconShadow-collapsed" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</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"
|
||||
filter="url(#iconShadow-collapsed)"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex items-center gap-1', 'hidden lg:flex')}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
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>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Bug } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
interface BugReportButtonProps {
|
||||
sidebarExpanded: boolean;
|
||||
}
|
||||
|
||||
export function BugReportButton({ sidebarExpanded }: BugReportButtonProps) {
|
||||
const handleBugReportClick = useCallback(() => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleBugReportClick}
|
||||
className={cn(
|
||||
'titlebar-no-drag px-3 py-2.5 rounded-xl',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent/80',
|
||||
'border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.97]',
|
||||
sidebarExpanded && 'absolute right-3'
|
||||
)}
|
||||
title="Report Bug / Feature Request"
|
||||
data-testid={sidebarExpanded ? 'bug-report-link' : 'bug-report-link-collapsed'}
|
||||
>
|
||||
<Bug className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { PanelLeft, PanelLeftClose } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
|
||||
interface CollapseToggleButtonProps {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
shortcut: string;
|
||||
}
|
||||
|
||||
export function CollapseToggleButton({
|
||||
sidebarOpen,
|
||||
toggleSidebar,
|
||||
shortcut,
|
||||
}: CollapseToggleButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'hidden lg:flex absolute top-[68px] -right-3 z-9999',
|
||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||
// Glass morphism button
|
||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||
// Premium shadow with glow on hover
|
||||
'shadow-lg shadow-black/5 hover:shadow-xl hover:shadow-brand-500/10',
|
||||
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
|
||||
'hover:border-brand-500/30',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
'hover:scale-110 active:scale-90'
|
||||
)}
|
||||
data-testid="sidebar-collapse-button"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<PanelLeftClose className="w-3.5 h-3.5 pointer-events-none transition-transform duration-200" />
|
||||
) : (
|
||||
<PanelLeft className="w-3.5 h-3.5 pointer-events-none transition-transform duration-200" />
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
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/toggle:opacity-100 transition-all duration-200',
|
||||
'whitespace-nowrap z-50 pointer-events-none',
|
||||
'translate-x-1 group-hover/toggle:translate-x-0'
|
||||
)}
|
||||
data-testid="sidebar-toggle-tooltip"
|
||||
>
|
||||
{sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}{' '}
|
||||
<span
|
||||
className="ml-1.5 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground"
|
||||
data-testid="sidebar-toggle-shortcut"
|
||||
>
|
||||
{formatShortcut(shortcut, true)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
10
apps/ui/src/components/layout/sidebar/components/index.ts
Normal file
10
apps/ui/src/components/layout/sidebar/components/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { SortableProjectItem } from './sortable-project-item';
|
||||
export { ThemeMenuItem } from './theme-menu-item';
|
||||
export { BugReportButton } from './bug-report-button';
|
||||
export { CollapseToggleButton } from './collapse-toggle-button';
|
||||
export { AutomakerLogo } from './automaker-logo';
|
||||
export { SidebarHeader } from './sidebar-header';
|
||||
export { ProjectActions } from './project-actions';
|
||||
export { SidebarNavigation } from './sidebar-navigation';
|
||||
export { ProjectSelectorWithOptions } from './project-selector-with-options';
|
||||
export { SidebarFooter } from './sidebar-footer';
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Plus, FolderOpen, Recycle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import type { TrashedProject } from '@/lib/electron';
|
||||
|
||||
interface ProjectActionsProps {
|
||||
setShowNewProjectModal: (show: boolean) => void;
|
||||
handleOpenFolder: () => void;
|
||||
setShowTrashDialog: (show: boolean) => void;
|
||||
trashedProjects: TrashedProject[];
|
||||
shortcuts: {
|
||||
openProject: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ProjectActions({
|
||||
setShowNewProjectModal,
|
||||
handleOpenFolder,
|
||||
setShowTrashDialog,
|
||||
trashedProjects,
|
||||
shortcuts,
|
||||
}: ProjectActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 titlebar-no-drag px-3 mt-5">
|
||||
<button
|
||||
onClick={() => setShowNewProjectModal(true)}
|
||||
className={cn(
|
||||
'group flex items-center justify-center flex-1 px-3 py-2.5 rounded-xl',
|
||||
'relative overflow-hidden',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
// Glass background with gradient on hover
|
||||
'bg-accent/20 hover:bg-gradient-to-br hover:from-brand-500/15 hover:to-brand-600/10',
|
||||
'border border-border/40 hover:border-brand-500/30',
|
||||
// Premium shadow
|
||||
'shadow-sm hover:shadow-md hover:shadow-brand-500/5',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title="New Project"
|
||||
data-testid="new-project-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
|
||||
<span className="ml-2 text-sm font-medium hidden lg:block whitespace-nowrap">New</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenFolder}
|
||||
className={cn(
|
||||
'group flex items-center justify-center flex-1 px-3 py-2.5 rounded-xl',
|
||||
'relative overflow-hidden',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
// Glass background
|
||||
'bg-accent/20 hover:bg-accent/40',
|
||||
'border border-border/40 hover:border-border/60',
|
||||
'shadow-sm hover:shadow-md',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={`Open Folder (${shortcuts.openProject})`}
|
||||
data-testid="open-project-button"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
|
||||
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
|
||||
{formatShortcut(shortcuts.openProject, true)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTrashDialog(true)}
|
||||
className={cn(
|
||||
'group flex items-center justify-center px-3 h-[42px] rounded-xl',
|
||||
'relative',
|
||||
'text-muted-foreground hover:text-destructive',
|
||||
// Subtle background that turns red on hover
|
||||
'bg-accent/20 hover:bg-destructive/15',
|
||||
'border border-border/40 hover:border-destructive/40',
|
||||
'shadow-sm hover:shadow-md hover:shadow-destructive/10',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title="Recycle Bin"
|
||||
data-testid="trash-button"
|
||||
>
|
||||
<Recycle className="size-4 shrink-0 transition-transform duration-200 group-hover:rotate-12" />
|
||||
{trashedProjects.length > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 z-10 flex items-center justify-center min-w-4 h-4 px-1 text-[9px] font-bold rounded-full bg-red-500 text-white shadow-md ring-1 ring-red-600/50">
|
||||
{trashedProjects.length > 9 ? '9+' : trashedProjects.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import {
|
||||
Folder,
|
||||
ChevronDown,
|
||||
MoreVertical,
|
||||
Palette,
|
||||
Monitor,
|
||||
Moon,
|
||||
Sun,
|
||||
Undo2,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { SortableProjectItem, ThemeMenuItem } from './';
|
||||
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants';
|
||||
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
|
||||
interface ProjectSelectorWithOptionsProps {
|
||||
sidebarOpen: boolean;
|
||||
isProjectPickerOpen: boolean;
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setShowDeleteProjectDialog: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProjectSelectorWithOptions({
|
||||
sidebarOpen,
|
||||
isProjectPickerOpen,
|
||||
setIsProjectPickerOpen,
|
||||
setShowDeleteProjectDialog,
|
||||
}: ProjectSelectorWithOptionsProps) {
|
||||
// Get data from store
|
||||
const {
|
||||
projects,
|
||||
currentProject,
|
||||
projectHistory,
|
||||
setCurrentProject,
|
||||
reorderProjects,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
clearProjectHistory,
|
||||
} = useAppStore();
|
||||
|
||||
// Get keyboard shortcuts
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const {
|
||||
projectSearchQuery,
|
||||
setProjectSearchQuery,
|
||||
selectedProjectIndex,
|
||||
projectSearchInputRef,
|
||||
filteredProjects,
|
||||
} = useProjectPicker({
|
||||
projects,
|
||||
isProjectPickerOpen,
|
||||
setIsProjectPickerOpen,
|
||||
setCurrentProject,
|
||||
});
|
||||
|
||||
// Drag-and-drop handlers
|
||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||
|
||||
// Theme management
|
||||
const {
|
||||
globalTheme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
setPreviewTheme,
|
||||
handlePreviewEnter,
|
||||
handlePreviewLeave,
|
||||
} = useProjectTheme();
|
||||
|
||||
if (!sidebarOpen || projects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 mt-3 flex items-center gap-2.5">
|
||||
<DropdownMenu open={isProjectPickerOpen} onOpenChange={setIsProjectPickerOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-between px-3.5 py-3 rounded-xl',
|
||||
// Premium glass background
|
||||
'bg-gradient-to-br from-accent/40 to-accent/20',
|
||||
'hover:from-accent/50 hover:to-accent/30',
|
||||
'border border-border/50 hover:border-border/70',
|
||||
// Subtle inner shadow
|
||||
'shadow-sm shadow-black/5',
|
||||
'text-foreground titlebar-no-drag min-w-0',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.01] active:scale-[0.99]',
|
||||
isProjectPickerOpen &&
|
||||
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
|
||||
)}
|
||||
data-testid="project-selector"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<Folder className="h-4 w-4 text-brand-500 shrink-0" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{currentProject?.name || 'Select Project'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
|
||||
data-testid="project-picker-shortcut"
|
||||
>
|
||||
{formatShortcut(shortcuts.projectPicker, true)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground shrink-0 transition-transform duration-200',
|
||||
isProjectPickerOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-72 bg-popover/95 backdrop-blur-xl border-border shadow-xl p-1.5"
|
||||
align="start"
|
||||
data-testid="project-picker-dropdown"
|
||||
>
|
||||
{/* Search input for type-ahead filtering */}
|
||||
<div className="px-1 pb-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
ref={projectSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={projectSearchQuery}
|
||||
onChange={(e) => setProjectSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
'w-full h-9 pl-8 pr-3 text-sm rounded-lg',
|
||||
'border border-border bg-background/50',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
data-testid="project-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
No projects found
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredProjects.map((p) => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-0.5 max-h-64 overflow-y-auto">
|
||||
{filteredProjects.map((project, index) => (
|
||||
<SortableProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
currentProjectId={currentProject?.id}
|
||||
isHighlighted={index === selectedProjectIndex}
|
||||
onSelect={(p) => {
|
||||
setCurrentProject(p);
|
||||
setIsProjectPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
|
||||
<span className="text-foreground/60">arrow</span> navigate{' '}
|
||||
<span className="mx-1 text-foreground/30">|</span>{' '}
|
||||
<span className="text-foreground/60">enter</span> select{' '}
|
||||
<span className="mx-1 text-foreground/30">|</span>{' '}
|
||||
<span className="text-foreground/60">esc</span> close
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Project Options Menu - theme and history */}
|
||||
{currentProject && (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
// Clear preview theme when the menu closes
|
||||
if (!open) {
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center w-[42px] h-[42px] rounded-lg',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'bg-transparent hover:bg-accent/60',
|
||||
'border border-border/50 hover:border-border',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
title="Project options"
|
||||
data-testid="project-options-menu"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-popover/95 backdrop-blur-xl">
|
||||
{/* Project Theme Submenu */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger data-testid="project-theme-trigger">
|
||||
<Palette className="w-4 h-4 mr-2" />
|
||||
<span className="flex-1">Project Theme</span>
|
||||
{currentProject.theme && (
|
||||
<span className="text-[10px] text-muted-foreground ml-2 capitalize">
|
||||
{currentProject.theme}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
className="w-[420px] bg-popover/95 backdrop-blur-xl"
|
||||
data-testid="project-theme-menu"
|
||||
onPointerLeave={() => {
|
||||
// Clear preview theme when leaving the dropdown
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
>
|
||||
{/* Use Global Option */}
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentProject.theme || ''}
|
||||
onValueChange={(value) => {
|
||||
if (currentProject) {
|
||||
setPreviewTheme(null);
|
||||
if (value !== '') {
|
||||
setTheme(value as ThemeMode);
|
||||
} else {
|
||||
setTheme(globalTheme);
|
||||
}
|
||||
setProjectTheme(
|
||||
currentProject.id,
|
||||
value === '' ? null : (value as ThemeMode)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onPointerEnter={() => handlePreviewEnter(globalTheme)}
|
||||
onPointerLeave={() => setPreviewTheme(null)}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value=""
|
||||
data-testid="project-theme-global"
|
||||
className="mx-2"
|
||||
>
|
||||
<Monitor className="w-4 h-4 mr-2" />
|
||||
<span>Use Global</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
|
||||
({globalTheme})
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Two Column Layout */}
|
||||
<div className="flex gap-2 p-2">
|
||||
{/* Dark Themes Column */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Moon className="w-3 h-3" />
|
||||
Dark
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{PROJECT_DARK_THEMES.map((option) => (
|
||||
<ThemeMenuItem
|
||||
key={option.value}
|
||||
option={option}
|
||||
onPreviewEnter={handlePreviewEnter}
|
||||
onPreviewLeave={handlePreviewLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Light Themes Column */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Sun className="w-3 h-3" />
|
||||
Light
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{PROJECT_LIGHT_THEMES.map((option) => (
|
||||
<ThemeMenuItem
|
||||
key={option.value}
|
||||
option={option}
|
||||
onPreviewEnter={handlePreviewEnter}
|
||||
onPreviewLeave={handlePreviewLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Project History Section - only show when there's history */}
|
||||
{projectHistory.length > 1 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Project History
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={cyclePrevProject} data-testid="cycle-prev-project">
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
<span className="flex-1">Previous</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||
{formatShortcut(shortcuts.cyclePrevProject, true)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
|
||||
<Redo2 className="w-4 h-4 mr-2" />
|
||||
<span className="flex-1">Next</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||
{formatShortcut(shortcuts.cycleNextProject, true)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
<span>Clear history</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Move to Trash Section */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowDeleteProjectDialog(true)}
|
||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
data-testid="move-project-to-trash"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<span>Move to Trash</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { BookOpen, Activity, Settings } from 'lucide-react';
|
||||
|
||||
interface SidebarFooterProps {
|
||||
sidebarOpen: boolean;
|
||||
isActiveRoute: (id: string) => boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
hideWiki: boolean;
|
||||
hideRunningAgents: boolean;
|
||||
runningAgentsCount: number;
|
||||
shortcuts: {
|
||||
settings: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function SidebarFooter({
|
||||
sidebarOpen,
|
||||
isActiveRoute,
|
||||
navigate,
|
||||
hideWiki,
|
||||
hideRunningAgents,
|
||||
runningAgentsCount,
|
||||
shortcuts,
|
||||
}: SidebarFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
// Top border with gradient fade
|
||||
'border-t border-border/40',
|
||||
// Elevated background for visual separation
|
||||
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Wiki Link */}
|
||||
{!hideWiki && (
|
||||
<div className="p-2 pb-0">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/wiki' })}
|
||||
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('wiki')
|
||||
? [
|
||||
'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',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={!sidebarOpen ? 'Wiki' : undefined}
|
||||
data-testid="wiki-link"
|
||||
>
|
||||
<BookOpen
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActiveRoute('wiki')
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Wiki
|
||||
</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'
|
||||
)}
|
||||
>
|
||||
Wiki
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 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',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={!sidebarOpen ? 'Running Agents' : undefined}
|
||||
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'
|
||||
)}
|
||||
/>
|
||||
{/* Running agents count badge - shown in collapsed state */}
|
||||
{!sidebarOpen && runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
||||
'bg-brand-500 text-white shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200'
|
||||
)}
|
||||
data-testid="running-agents-count-collapsed"
|
||||
>
|
||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Running Agents
|
||||
</span>
|
||||
{/* Running agents count badge - shown in expanded state */}
|
||||
{sidebarOpen && runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg: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',
|
||||
'animate-in fade-in zoom-in duration-200',
|
||||
isActiveRoute('running-agents') && 'bg-brand-600'
|
||||
)}
|
||||
data-testid="running-agents-count"
|
||||
>
|
||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
{/* Settings Link */}
|
||||
<div className="p-2">
|
||||
<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',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={!sidebarOpen ? 'Settings' : undefined}
|
||||
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={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
</span>
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
isActiveRoute('settings')
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
)}
|
||||
data-testid="shortcut-settings"
|
||||
>
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
)}
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AutomakerLogo } from './automaker-logo';
|
||||
import { BugReportButton } from './bug-report-button';
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
sidebarOpen: boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
}
|
||||
|
||||
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={cn(
|
||||
'h-20 shrink-0 titlebar-drag-region',
|
||||
// Subtle bottom border with gradient fade
|
||||
'border-b border-border/40',
|
||||
// Background gradient for depth
|
||||
'bg-gradient-to-b from-transparent to-background/5',
|
||||
'flex items-center',
|
||||
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center'
|
||||
)}
|
||||
>
|
||||
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
|
||||
{/* Bug Report Button - Inside logo container when expanded */}
|
||||
{sidebarOpen && <BugReportButton sidebarExpanded />}
|
||||
</div>
|
||||
|
||||
{/* Bug Report Button - Collapsed sidebar version */}
|
||||
{!sidebarOpen && (
|
||||
<div className="px-3 mt-1.5 flex justify-center">
|
||||
<BugReportButton sidebarExpanded={false} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import type { NavSection } from '../types';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface SidebarNavigationProps {
|
||||
currentProject: Project | null;
|
||||
sidebarOpen: boolean;
|
||||
navSections: NavSection[];
|
||||
isActiveRoute: (id: string) => boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
}
|
||||
|
||||
export function SidebarNavigation({
|
||||
currentProject,
|
||||
sidebarOpen,
|
||||
navSections,
|
||||
isActiveRoute,
|
||||
navigate,
|
||||
}: SidebarNavigationProps) {
|
||||
return (
|
||||
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-5' : 'mt-1')}>
|
||||
{!currentProject && sidebarOpen ? (
|
||||
// Placeholder when no project is selected (only in expanded state)
|
||||
<div className="flex items-center justify-center h-full px-4">
|
||||
<p className="text-muted-foreground text-sm text-center">
|
||||
<span className="hidden lg:block">Select or create a project above</span>
|
||||
</p>
|
||||
</div>
|
||||
) : currentProject ? (
|
||||
// Navigation sections when project is selected
|
||||
navSections.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
|
||||
{/* Section Label */}
|
||||
{section.label && sidebarOpen && (
|
||||
<div className="hidden lg:block px-3 mb-2">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||
{section.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>}
|
||||
|
||||
{/* Nav Items */}
|
||||
<div className="space-y-1.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = isActiveRoute(item.id);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigate({ to: `/${item.id}` as const })}
|
||||
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',
|
||||
isActive
|
||||
? [
|
||||
// Active: Premium gradient with glow
|
||||
'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',
|
||||
]
|
||||
: [
|
||||
// Inactive: Subtle hover state
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'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 ? item.label : undefined}
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'hidden lg:block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.shortcut && sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
)}
|
||||
data-testid={`shortcut-${item.id}`}
|
||||
>
|
||||
{formatShortcut(item.shortcut, true)}
|
||||
</span>
|
||||
)}
|
||||
{/* Tooltip for collapsed state */}
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
|
||||
>
|
||||
{item.label}
|
||||
{item.shortcut && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(item.shortcut, true)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Folder, Check, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SortableProjectItemProps } from '../types';
|
||||
|
||||
export function SortableProjectItem({
|
||||
project,
|
||||
currentProjectId,
|
||||
isHighlighted,
|
||||
onSelect,
|
||||
}: SortableProjectItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: project.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-all duration-200',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent/80',
|
||||
isDragging && 'bg-accent shadow-lg scale-[1.02]',
|
||||
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
|
||||
)}
|
||||
data-testid={`project-option-${project.id}`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="p-0.5 rounded-md hover:bg-accent/50 cursor-grab active:cursor-grabbing transition-colors"
|
||||
data-testid={`project-drag-handle-${project.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</button>
|
||||
|
||||
{/* Project content - clickable area */}
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0" onClick={() => onSelect(project)}>
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-sm font-medium">{project.name}</span>
|
||||
{currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { memo } from 'react';
|
||||
import { DropdownMenuRadioItem } from '@/components/ui/dropdown-menu';
|
||||
import type { ThemeMenuItemProps } from '../types';
|
||||
|
||||
export const ThemeMenuItem = memo(function ThemeMenuItem({
|
||||
option,
|
||||
onPreviewEnter,
|
||||
onPreviewLeave,
|
||||
}: ThemeMenuItemProps) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onPointerEnter={() => onPreviewEnter(option.value)}
|
||||
onPointerLeave={onPreviewLeave}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value={option.value}
|
||||
data-testid={`project-theme-${option.value}`}
|
||||
className="text-xs py-1.5"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
|
||||
<span>{option.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
24
apps/ui/src/components/layout/sidebar/constants.ts
Normal file
24
apps/ui/src/components/layout/sidebar/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
|
||||
export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
icon: opt.Icon,
|
||||
color: opt.color,
|
||||
}));
|
||||
|
||||
export const PROJECT_LIGHT_THEMES = lightThemes.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
icon: opt.Icon,
|
||||
color: opt.color,
|
||||
}));
|
||||
|
||||
export const SIDEBAR_FEATURE_FLAGS = {
|
||||
hideTerminal: import.meta.env.VITE_HIDE_TERMINAL === 'true',
|
||||
hideWiki: import.meta.env.VITE_HIDE_WIKI === 'true',
|
||||
hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true',
|
||||
hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true',
|
||||
hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true',
|
||||
hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true',
|
||||
} as const;
|
||||
2
apps/ui/src/components/layout/sidebar/dialogs/index.ts
Normal file
2
apps/ui/src/components/layout/sidebar/dialogs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TrashDialog } from './trash-dialog';
|
||||
export { OnboardingDialog } from './onboarding-dialog';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface OnboardingDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
newProjectName: string;
|
||||
onSkip: () => void;
|
||||
onGenerateSpec: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
newProjectName,
|
||||
onSkip,
|
||||
onGenerateSpec,
|
||||
}: OnboardingDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
onSkip();
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
|
||||
<Rocket className="w-6 h-6 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">Welcome to {newProjectName}!</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground mt-1">
|
||||
Your new project is ready. Let's get you started.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-6">
|
||||
{/* Main explanation */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-foreground leading-relaxed">
|
||||
Would you like to auto-generate your <strong>app_spec.txt</strong>? This file helps
|
||||
describe your project and is used to pre-populate your backlog with features to work
|
||||
on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Benefits list */}
|
||||
<div className="space-y-3 rounded-xl bg-muted/30 border border-border/50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Pre-populate your backlog</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Automatically generate features based on your project specification
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Zap className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Better AI assistance</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Help AI agents understand your project structure and tech stack
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Project documentation</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Keep a clear record of your project's capabilities and features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="rounded-xl bg-brand-500/5 border border-brand-500/10 p-3">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<strong className="text-foreground">Tip:</strong> You can always generate or edit your
|
||||
app_spec.txt later from the Spec Editor in the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onGenerateSpec}
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Generate App Spec
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
116
apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx
Normal file
116
apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { X, Trash2, Undo2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { TrashedProject } from '@/lib/electron';
|
||||
|
||||
interface TrashDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
trashedProjects: TrashedProject[];
|
||||
activeTrashId: string | null;
|
||||
handleRestoreProject: (id: string) => void;
|
||||
handleDeleteProjectFromDisk: (project: TrashedProject) => void;
|
||||
deleteTrashedProject: (id: string) => void;
|
||||
handleEmptyTrash: () => void;
|
||||
isEmptyingTrash: boolean;
|
||||
}
|
||||
|
||||
export function TrashDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
trashedProjects,
|
||||
activeTrashId,
|
||||
handleRestoreProject,
|
||||
handleDeleteProjectFromDisk,
|
||||
deleteTrashedProject,
|
||||
handleEmptyTrash,
|
||||
isEmptyingTrash,
|
||||
}: TrashDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Recycle Bin</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Restore projects to the sidebar or delete their folders using your system Trash.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{trashedProjects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Recycle bin is empty.</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
|
||||
{trashedProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-border bg-card/50 p-4"
|
||||
>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground break-all">{project.path}</p>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
Trashed {new Date(project.trashedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRestoreProject(project.id)}
|
||||
data-testid={`restore-project-${project.id}`}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Restore
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteProjectFromDisk(project)}
|
||||
disabled={activeTrashId === project.id}
|
||||
data-testid={`delete-project-disk-${project.id}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
{activeTrashId === project.id ? 'Deleting...' : 'Delete from disk'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => deleteTrashedProject(project.id)}
|
||||
data-testid={`remove-project-${project.id}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove from list
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{trashedProjects.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleEmptyTrash}
|
||||
disabled={isEmptyingTrash}
|
||||
data-testid="empty-trash"
|
||||
>
|
||||
{isEmptyingTrash ? 'Clearing...' : 'Empty Recycle Bin'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
12
apps/ui/src/components/layout/sidebar/hooks/index.ts
Normal file
12
apps/ui/src/components/layout/sidebar/hooks/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { useThemePreview } from './use-theme-preview';
|
||||
export { useSidebarAutoCollapse } from './use-sidebar-auto-collapse';
|
||||
export { useDragAndDrop } from './use-drag-and-drop';
|
||||
export { useRunningAgents } from './use-running-agents';
|
||||
export { useTrashOperations } from './use-trash-operations';
|
||||
export { useProjectPicker } from './use-project-picker';
|
||||
export { useSpecRegeneration } from './use-spec-regeneration';
|
||||
export { useNavigation } from './use-navigation';
|
||||
export { useProjectCreation } from './use-project-creation';
|
||||
export { useSetupDialog } from './use-setup-dialog';
|
||||
export { useTrashDialog } from './use-trash-dialog';
|
||||
export { useProjectTheme } from './use-project-theme';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSensors, useSensor, PointerSensor, type DragEndEvent } from '@dnd-kit/core';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface UseDragAndDropProps {
|
||||
projects: Project[];
|
||||
reorderProjects: (oldIndex: number, newIndex: number) => void;
|
||||
}
|
||||
|
||||
export function useDragAndDrop({ projects, reorderProjects }: UseDragAndDropProps) {
|
||||
// Sensors for drag-and-drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5, // Small distance to start drag
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Handle drag end for reordering projects
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = projects.findIndex((p) => p.id === active.id);
|
||||
const newIndex = projects.findIndex((p) => p.id === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
reorderProjects(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projects, reorderProjects]
|
||||
);
|
||||
|
||||
return {
|
||||
sensors,
|
||||
handleDragEnd,
|
||||
};
|
||||
}
|
||||
211
apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
Normal file
211
apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { FileText, LayoutGrid, Bot, BookOpen, UserCircle, Terminal } from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface UseNavigationProps {
|
||||
shortcuts: {
|
||||
toggleSidebar: string;
|
||||
openProject: string;
|
||||
projectPicker: string;
|
||||
cyclePrevProject: string;
|
||||
cycleNextProject: string;
|
||||
spec: string;
|
||||
context: string;
|
||||
profiles: string;
|
||||
board: string;
|
||||
agent: string;
|
||||
terminal: string;
|
||||
settings: string;
|
||||
};
|
||||
hideSpecEditor: boolean;
|
||||
hideContext: boolean;
|
||||
hideTerminal: boolean;
|
||||
hideAiProfiles: boolean;
|
||||
currentProject: Project | null;
|
||||
projects: Project[];
|
||||
projectHistory: string[];
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
toggleSidebar: () => void;
|
||||
handleOpenFolder: () => void;
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
cyclePrevProject: () => void;
|
||||
cycleNextProject: () => void;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
shortcuts,
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
currentProject,
|
||||
projects,
|
||||
projectHistory,
|
||||
navigate,
|
||||
toggleSidebar,
|
||||
handleOpenFolder,
|
||||
setIsProjectPickerOpen,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
}: UseNavigationProps) {
|
||||
// Build navigation sections
|
||||
const navSections: NavSection[] = useMemo(() => {
|
||||
const allToolsItems: NavItem[] = [
|
||||
{
|
||||
id: 'spec',
|
||||
label: 'Spec Editor',
|
||||
icon: FileText,
|
||||
shortcut: shortcuts.spec,
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
label: 'Context',
|
||||
icon: BookOpen,
|
||||
shortcut: shortcuts.context,
|
||||
},
|
||||
{
|
||||
id: 'profiles',
|
||||
label: 'AI Profiles',
|
||||
icon: UserCircle,
|
||||
shortcut: shortcuts.profiles,
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out hidden items
|
||||
const visibleToolsItems = allToolsItems.filter((item) => {
|
||||
if (item.id === 'spec' && hideSpecEditor) {
|
||||
return false;
|
||||
}
|
||||
if (item.id === 'context' && hideContext) {
|
||||
return false;
|
||||
}
|
||||
if (item.id === 'profiles' && hideAiProfiles) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build project items - Terminal is conditionally included
|
||||
const projectItems: NavItem[] = [
|
||||
{
|
||||
id: 'board',
|
||||
label: 'Kanban Board',
|
||||
icon: LayoutGrid,
|
||||
shortcut: shortcuts.board,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
label: 'Agent Runner',
|
||||
icon: Bot,
|
||||
shortcut: shortcuts.agent,
|
||||
},
|
||||
];
|
||||
|
||||
// Add Terminal to Project section if not hidden
|
||||
if (!hideTerminal) {
|
||||
projectItems.push({
|
||||
id: 'terminal',
|
||||
label: 'Terminal',
|
||||
icon: Terminal,
|
||||
shortcut: shortcuts.terminal,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Project',
|
||||
items: projectItems,
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
items: visibleToolsItems,
|
||||
},
|
||||
];
|
||||
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
|
||||
|
||||
// Build keyboard shortcuts for navigation
|
||||
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcutsList: KeyboardShortcut[] = [];
|
||||
|
||||
// Sidebar toggle shortcut - always available
|
||||
shortcutsList.push({
|
||||
key: shortcuts.toggleSidebar,
|
||||
action: () => toggleSidebar(),
|
||||
description: 'Toggle sidebar',
|
||||
});
|
||||
|
||||
// Open project shortcut - opens the folder selection dialog directly
|
||||
shortcutsList.push({
|
||||
key: shortcuts.openProject,
|
||||
action: () => handleOpenFolder(),
|
||||
description: 'Open folder selection dialog',
|
||||
});
|
||||
|
||||
// Project picker shortcut - only when we have projects
|
||||
if (projects.length > 0) {
|
||||
shortcutsList.push({
|
||||
key: shortcuts.projectPicker,
|
||||
action: () => setIsProjectPickerOpen((prev) => !prev),
|
||||
description: 'Toggle project picker',
|
||||
});
|
||||
}
|
||||
|
||||
// Project cycling shortcuts - only when we have project history
|
||||
if (projectHistory.length > 1) {
|
||||
shortcutsList.push({
|
||||
key: shortcuts.cyclePrevProject,
|
||||
action: () => cyclePrevProject(),
|
||||
description: 'Cycle to previous project (MRU)',
|
||||
});
|
||||
shortcutsList.push({
|
||||
key: shortcuts.cycleNextProject,
|
||||
action: () => cycleNextProject(),
|
||||
description: 'Cycle to next project (LRU)',
|
||||
});
|
||||
}
|
||||
|
||||
// Only enable nav shortcuts if there's a current project
|
||||
if (currentProject) {
|
||||
navSections.forEach((section) => {
|
||||
section.items.forEach((item) => {
|
||||
if (item.shortcut) {
|
||||
shortcutsList.push({
|
||||
key: item.shortcut,
|
||||
action: () => navigate({ to: `/${item.id}` as const }),
|
||||
description: `Navigate to ${item.label}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add settings shortcut
|
||||
shortcutsList.push({
|
||||
key: shortcuts.settings,
|
||||
action: () => navigate({ to: '/settings' }),
|
||||
description: 'Navigate to Settings',
|
||||
});
|
||||
}
|
||||
|
||||
return shortcutsList;
|
||||
}, [
|
||||
shortcuts,
|
||||
currentProject,
|
||||
navigate,
|
||||
toggleSidebar,
|
||||
projects.length,
|
||||
handleOpenFolder,
|
||||
projectHistory.length,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
navSections,
|
||||
setIsProjectPickerOpen,
|
||||
]);
|
||||
|
||||
return {
|
||||
navSections,
|
||||
navigationShortcuts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import type { StarterTemplate } from '@/lib/templates';
|
||||
import type { ThemeMode } from '@/store/app-store';
|
||||
import type { TrashedProject, Project } from '@/lib/electron';
|
||||
|
||||
interface UseProjectCreationProps {
|
||||
trashedProjects: TrashedProject[];
|
||||
currentProject: Project | null;
|
||||
globalTheme: ThemeMode;
|
||||
upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project;
|
||||
}
|
||||
|
||||
export function useProjectCreation({
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
globalTheme,
|
||||
upsertAndSetCurrentProject,
|
||||
}: UseProjectCreationProps) {
|
||||
// Modal state
|
||||
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
|
||||
// Onboarding state
|
||||
const [showOnboardingDialog, setShowOnboardingDialog] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectPath, setNewProjectPath] = useState('');
|
||||
|
||||
/**
|
||||
* Common logic for all project creation flows
|
||||
*/
|
||||
const finalizeProjectCreation = useCallback(
|
||||
async (projectPath: string, projectName: string) => {
|
||||
try {
|
||||
// Initialize .automaker directory structure
|
||||
await initializeProject(projectPath);
|
||||
|
||||
// Write initial app_spec.txt with proper XML structure
|
||||
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
|
||||
const api = getElectronAPI();
|
||||
await api.fs.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
|
||||
<overview>
|
||||
Describe your project here. This file will be analyzed by an AI agent
|
||||
to understand your project structure and tech stack.
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
<!-- The AI agent will fill this in after analyzing your project -->
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
<!-- List core features and capabilities -->
|
||||
</core_capabilities>
|
||||
|
||||
<implemented_features>
|
||||
<!-- The AI agent will populate this based on code analysis -->
|
||||
</implemented_features>
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
// Determine theme: try trashed project theme, then current project theme, then global
|
||||
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
|
||||
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
// Show onboarding dialog for new project
|
||||
setNewProjectName(projectName);
|
||||
setNewProjectPath(projectPath);
|
||||
setShowOnboardingDialog(true);
|
||||
|
||||
toast.success('Project created successfully');
|
||||
} catch (error) {
|
||||
console.error('[ProjectCreation] Failed to finalize project:', error);
|
||||
toast.error('Failed to initialize project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a blank project with .automaker structure
|
||||
*/
|
||||
const handleCreateBlankProject = useCallback(
|
||||
async (projectName: string, parentDir: string) => {
|
||||
setIsCreatingProject(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Create project directory
|
||||
await api.fs.createFolder(projectPath);
|
||||
|
||||
// Finalize project setup
|
||||
await finalizeProjectCreation(projectPath, projectName);
|
||||
} catch (error) {
|
||||
console.error('[ProjectCreation] Failed to create blank project:', error);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
},
|
||||
[finalizeProjectCreation]
|
||||
);
|
||||
|
||||
/**
|
||||
* Create project from a starter template
|
||||
*/
|
||||
const handleCreateFromTemplate = useCallback(
|
||||
async (template: StarterTemplate, projectName: string, parentDir: string) => {
|
||||
setIsCreatingProject(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Clone template repository
|
||||
await api.git.clone(template.githubUrl, projectPath);
|
||||
|
||||
// Initialize .automaker directory structure
|
||||
await initializeProject(projectPath);
|
||||
|
||||
// Write app_spec.txt with template-specific info
|
||||
await api.fs.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
|
||||
<overview>
|
||||
This project was created from the "${template.name}" starter template.
|
||||
${template.description}
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')}
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
${template.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')}
|
||||
</core_capabilities>
|
||||
|
||||
<implemented_features>
|
||||
<!-- The AI agent will populate this based on code analysis -->
|
||||
</implemented_features>
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
// Determine theme
|
||||
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
|
||||
setShowNewProjectModal(false);
|
||||
setNewProjectName(projectName);
|
||||
setNewProjectPath(projectPath);
|
||||
setShowOnboardingDialog(true);
|
||||
|
||||
toast.success('Project created from template', {
|
||||
description: `Created ${projectName} from ${template.name}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ProjectCreation] Failed to create from template:', error);
|
||||
toast.error('Failed to create project from template', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
},
|
||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
/**
|
||||
* Create project from a custom GitHub URL
|
||||
*/
|
||||
const handleCreateFromCustomUrl = useCallback(
|
||||
async (repoUrl: string, projectName: string, parentDir: string) => {
|
||||
setIsCreatingProject(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
// Clone custom repository
|
||||
await api.git.clone(repoUrl, projectPath);
|
||||
|
||||
// Initialize .automaker directory structure
|
||||
await initializeProject(projectPath);
|
||||
|
||||
// Write app_spec.txt with custom URL info
|
||||
await api.fs.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
|
||||
<overview>
|
||||
This project was cloned from ${repoUrl}.
|
||||
The AI agent will analyze the project structure.
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
<!-- The AI agent will fill this in after analyzing your project -->
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
<!-- List core features and capabilities -->
|
||||
</core_capabilities>
|
||||
|
||||
<implemented_features>
|
||||
<!-- The AI agent will populate this based on code analysis -->
|
||||
</implemented_features>
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
// Determine theme
|
||||
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
|
||||
setShowNewProjectModal(false);
|
||||
setNewProjectName(projectName);
|
||||
setNewProjectPath(projectPath);
|
||||
setShowOnboardingDialog(true);
|
||||
|
||||
toast.success('Project created from repository', {
|
||||
description: `Created ${projectName} from ${repoUrl}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ProjectCreation] Failed to create from custom URL:', error);
|
||||
toast.error('Failed to create project from URL', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
},
|
||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
return {
|
||||
// Modal state
|
||||
showNewProjectModal,
|
||||
setShowNewProjectModal,
|
||||
isCreatingProject,
|
||||
|
||||
// Onboarding state
|
||||
showOnboardingDialog,
|
||||
setShowOnboardingDialog,
|
||||
newProjectName,
|
||||
setNewProjectName,
|
||||
newProjectPath,
|
||||
setNewProjectPath,
|
||||
|
||||
// Handlers
|
||||
handleCreateBlankProject,
|
||||
handleCreateFromTemplate,
|
||||
handleCreateFromCustomUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface UseProjectPickerProps {
|
||||
projects: Project[];
|
||||
isProjectPickerOpen: boolean;
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setCurrentProject: (project: Project) => void;
|
||||
}
|
||||
|
||||
export function useProjectPicker({
|
||||
projects,
|
||||
isProjectPickerOpen,
|
||||
setIsProjectPickerOpen,
|
||||
setCurrentProject,
|
||||
}: UseProjectPickerProps) {
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||||
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
|
||||
const projectSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Filtered projects based on search query
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!projectSearchQuery.trim()) {
|
||||
return projects;
|
||||
}
|
||||
const query = projectSearchQuery.toLowerCase();
|
||||
return projects.filter((project) => project.name.toLowerCase().includes(query));
|
||||
}, [projects, projectSearchQuery]);
|
||||
|
||||
// Reset selection when filtered results change
|
||||
useEffect(() => {
|
||||
setSelectedProjectIndex(0);
|
||||
}, [filteredProjects.length, projectSearchQuery]);
|
||||
|
||||
// Reset search query when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!isProjectPickerOpen) {
|
||||
setProjectSearchQuery('');
|
||||
setSelectedProjectIndex(0);
|
||||
}
|
||||
}, [isProjectPickerOpen]);
|
||||
|
||||
// Focus the search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isProjectPickerOpen) {
|
||||
// Small delay to ensure the dropdown is rendered
|
||||
setTimeout(() => {
|
||||
projectSearchInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [isProjectPickerOpen]);
|
||||
|
||||
// Handle selecting the currently highlighted project
|
||||
const selectHighlightedProject = useCallback(() => {
|
||||
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
||||
setCurrentProject(filteredProjects[selectedProjectIndex]);
|
||||
setIsProjectPickerOpen(false);
|
||||
}
|
||||
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
|
||||
|
||||
// Handle keyboard events when project picker is open
|
||||
useEffect(() => {
|
||||
if (!isProjectPickerOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsProjectPickerOpen(false);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
selectHighlightedProject();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev));
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (event.key.toLowerCase() === 'p' && !event.metaKey && !event.ctrlKey) {
|
||||
// Toggle off when P is pressed (not with modifiers) while dropdown is open
|
||||
// Only if not typing in the search input
|
||||
if (document.activeElement !== projectSearchInputRef.current) {
|
||||
event.preventDefault();
|
||||
setIsProjectPickerOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [
|
||||
isProjectPickerOpen,
|
||||
selectHighlightedProject,
|
||||
filteredProjects.length,
|
||||
setIsProjectPickerOpen,
|
||||
]);
|
||||
|
||||
return {
|
||||
projectSearchQuery,
|
||||
setProjectSearchQuery,
|
||||
selectedProjectIndex,
|
||||
setSelectedProjectIndex,
|
||||
projectSearchInputRef,
|
||||
filteredProjects,
|
||||
selectHighlightedProject,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useThemePreview } from './use-theme-preview';
|
||||
|
||||
/**
|
||||
* Hook that manages project theme state and preview handlers
|
||||
*/
|
||||
export function useProjectTheme() {
|
||||
// Get theme-related values from store
|
||||
const { theme: globalTheme, setTheme, setProjectTheme, setPreviewTheme } = useAppStore();
|
||||
|
||||
// Get debounced preview handlers
|
||||
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
|
||||
|
||||
return {
|
||||
// Theme state
|
||||
globalTheme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
setPreviewTheme,
|
||||
|
||||
// Preview handlers
|
||||
handlePreviewEnter,
|
||||
handlePreviewLeave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
export function useRunningAgents() {
|
||||
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
|
||||
|
||||
// Fetch running agents count function - used for initial load and event-driven updates
|
||||
const fetchRunningAgentsCount = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.runningAgents) {
|
||||
const result = await api.runningAgents.getAll();
|
||||
if (result.success && result.runningAgents) {
|
||||
setRunningAgentsCount(result.runningAgents.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Error fetching running agents count:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Subscribe to auto-mode events to update running agents count in real-time
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.autoMode) {
|
||||
// If autoMode is not available, still fetch initial count
|
||||
fetchRunningAgentsCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial fetch on mount
|
||||
fetchRunningAgentsCount();
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// When a feature starts, completes, or errors, refresh the count
|
||||
if (
|
||||
event.type === 'auto_mode_feature_complete' ||
|
||||
event.type === 'auto_mode_error' ||
|
||||
event.type === 'auto_mode_feature_start'
|
||||
) {
|
||||
fetchRunningAgentsCount();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [fetchRunningAgentsCount]);
|
||||
|
||||
return {
|
||||
runningAgentsCount,
|
||||
};
|
||||
}
|
||||
147
apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts
Normal file
147
apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import type { FeatureCount } from '@/components/views/spec-view/types';
|
||||
|
||||
interface UseSetupDialogProps {
|
||||
setSpecCreatingForProject: (path: string | null) => void;
|
||||
newProjectPath: string;
|
||||
setNewProjectName: (name: string) => void;
|
||||
setNewProjectPath: (path: string) => void;
|
||||
setShowOnboardingDialog: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function useSetupDialog({
|
||||
setSpecCreatingForProject,
|
||||
newProjectPath,
|
||||
setNewProjectName,
|
||||
setNewProjectPath,
|
||||
setShowOnboardingDialog,
|
||||
}: UseSetupDialogProps) {
|
||||
// Setup dialog state
|
||||
const [showSetupDialog, setShowSetupDialog] = useState(false);
|
||||
const [setupProjectPath, setSetupProjectPath] = useState('');
|
||||
const [projectOverview, setProjectOverview] = useState('');
|
||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||
const [analyzeProject, setAnalyzeProject] = useState(true);
|
||||
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
|
||||
|
||||
/**
|
||||
* Handle creating initial spec for new project
|
||||
*/
|
||||
const handleCreateInitialSpec = useCallback(async () => {
|
||||
if (!setupProjectPath || !projectOverview.trim()) return;
|
||||
|
||||
// Set store state immediately so the loader shows up right away
|
||||
setSpecCreatingForProject(setupProjectPath);
|
||||
setShowSetupDialog(false);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
toast.error('Spec regeneration not available');
|
||||
setSpecCreatingForProject(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.specRegeneration.create(
|
||||
setupProjectPath,
|
||||
projectOverview.trim(),
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('[SetupDialog] Failed to start spec creation:', result.error);
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error('Failed to create specification', {
|
||||
description: result.error,
|
||||
});
|
||||
} else {
|
||||
// Show processing toast to inform user
|
||||
toast.info('Generating app specification...', {
|
||||
description: "This may take a minute. You'll be notified when complete.",
|
||||
});
|
||||
}
|
||||
// If successful, we'll wait for the events to update the state
|
||||
} catch (error) {
|
||||
console.error('[SetupDialog] Failed to create spec:', error);
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error('Failed to create specification', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
setupProjectPath,
|
||||
projectOverview,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
featureCount,
|
||||
setSpecCreatingForProject,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Handle skipping setup
|
||||
*/
|
||||
const handleSkipSetup = useCallback(() => {
|
||||
setShowSetupDialog(false);
|
||||
setProjectOverview('');
|
||||
setSetupProjectPath('');
|
||||
|
||||
// Clear onboarding state if we came from onboarding
|
||||
if (newProjectPath) {
|
||||
setNewProjectName('');
|
||||
setNewProjectPath('');
|
||||
}
|
||||
|
||||
toast.info('Setup skipped', {
|
||||
description: 'You can set up your app_spec.txt later from the Spec view.',
|
||||
});
|
||||
}, [newProjectPath, setNewProjectName, setNewProjectPath]);
|
||||
|
||||
/**
|
||||
* Handle onboarding dialog - generate spec
|
||||
*/
|
||||
const handleOnboardingGenerateSpec = useCallback(() => {
|
||||
setShowOnboardingDialog(false);
|
||||
// Navigate to the setup dialog flow
|
||||
setSetupProjectPath(newProjectPath);
|
||||
setProjectOverview('');
|
||||
setShowSetupDialog(true);
|
||||
}, [newProjectPath, setShowOnboardingDialog]);
|
||||
|
||||
/**
|
||||
* Handle onboarding dialog - skip
|
||||
*/
|
||||
const handleOnboardingSkip = useCallback(() => {
|
||||
setShowOnboardingDialog(false);
|
||||
setNewProjectName('');
|
||||
setNewProjectPath('');
|
||||
toast.info('You can generate your app_spec.txt anytime from the Spec view', {
|
||||
description: 'Your project is ready to use!',
|
||||
});
|
||||
}, [setShowOnboardingDialog, setNewProjectName, setNewProjectPath]);
|
||||
|
||||
return {
|
||||
// State
|
||||
showSetupDialog,
|
||||
setShowSetupDialog,
|
||||
setupProjectPath,
|
||||
setSetupProjectPath,
|
||||
projectOverview,
|
||||
setProjectOverview,
|
||||
generateFeatures,
|
||||
setGenerateFeatures,
|
||||
analyzeProject,
|
||||
setAnalyzeProject,
|
||||
featureCount,
|
||||
setFeatureCount,
|
||||
|
||||
// Handlers
|
||||
handleCreateInitialSpec,
|
||||
handleSkipSetup,
|
||||
handleOnboardingGenerateSpec,
|
||||
handleOnboardingSkip,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface UseSidebarAutoCollapseProps {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export function useSidebarAutoCollapse({
|
||||
sidebarOpen,
|
||||
toggleSidebar,
|
||||
}: UseSidebarAutoCollapseProps) {
|
||||
const isMountedRef = useRef(false);
|
||||
|
||||
// Auto-collapse sidebar on small screens
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint
|
||||
|
||||
const handleResize = () => {
|
||||
if (mediaQuery.matches && sidebarOpen) {
|
||||
// Auto-collapse on small screens
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
// Check on mount only
|
||||
if (!isMountedRef.current) {
|
||||
isMountedRef.current = true;
|
||||
handleResize();
|
||||
}
|
||||
|
||||
// Listen for changes
|
||||
mediaQuery.addEventListener('change', handleResize);
|
||||
return () => mediaQuery.removeEventListener('change', handleResize);
|
||||
}, [sidebarOpen, toggleSidebar]);
|
||||
|
||||
// Update Electron window minWidth when sidebar state changes
|
||||
// This ensures the window can't be resized smaller than what the kanban board needs
|
||||
useEffect(() => {
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: { updateMinWidth?: (expanded: boolean) => Promise<void> };
|
||||
}
|
||||
).electronAPI;
|
||||
if (electronAPI?.updateMinWidth) {
|
||||
electronAPI.updateMinWidth(sidebarOpen);
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { SpecRegenerationEvent } from '@/types/electron';
|
||||
|
||||
interface UseSpecRegenerationProps {
|
||||
creatingSpecProjectPath: string | null;
|
||||
setupProjectPath: string;
|
||||
setSpecCreatingForProject: (path: string | null) => void;
|
||||
setShowSetupDialog: (show: boolean) => void;
|
||||
setProjectOverview: (overview: string) => void;
|
||||
setSetupProjectPath: (path: string) => void;
|
||||
setNewProjectName: (name: string) => void;
|
||||
setNewProjectPath: (path: string) => void;
|
||||
}
|
||||
|
||||
export function useSpecRegeneration({
|
||||
creatingSpecProjectPath,
|
||||
setupProjectPath,
|
||||
setSpecCreatingForProject,
|
||||
setShowSetupDialog,
|
||||
setProjectOverview,
|
||||
setSetupProjectPath,
|
||||
setNewProjectName,
|
||||
setNewProjectPath,
|
||||
}: UseSpecRegenerationProps) {
|
||||
// Subscribe to spec regeneration events
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
|
||||
console.log(
|
||||
'[Sidebar] Spec regeneration event:',
|
||||
event.type,
|
||||
'for project:',
|
||||
event.projectPath
|
||||
);
|
||||
|
||||
// Only handle events for the project we're currently setting up
|
||||
if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) {
|
||||
console.log('[Sidebar] Ignoring event - not for project being set up');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'spec_regeneration_complete') {
|
||||
setSpecCreatingForProject(null);
|
||||
setShowSetupDialog(false);
|
||||
setProjectOverview('');
|
||||
setSetupProjectPath('');
|
||||
// Clear onboarding state if we came from onboarding
|
||||
setNewProjectName('');
|
||||
setNewProjectPath('');
|
||||
toast.success('App specification created', {
|
||||
description: 'Your project is now set up and ready to go!',
|
||||
});
|
||||
} else if (event.type === 'spec_regeneration_error') {
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error('Failed to create specification', {
|
||||
description: event.error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [
|
||||
creatingSpecProjectPath,
|
||||
setupProjectPath,
|
||||
setSpecCreatingForProject,
|
||||
setShowSetupDialog,
|
||||
setProjectOverview,
|
||||
setSetupProjectPath,
|
||||
setNewProjectName,
|
||||
setNewProjectPath,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import type { ThemeMode } from '@/store/app-store';
|
||||
|
||||
interface UseThemePreviewProps {
|
||||
setPreviewTheme: (theme: ThemeMode | null) => void;
|
||||
}
|
||||
|
||||
export function useThemePreview({ setPreviewTheme }: UseThemePreviewProps) {
|
||||
// Debounced preview theme handlers to prevent excessive re-renders
|
||||
const previewTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handlePreviewEnter = useCallback(
|
||||
(value: string) => {
|
||||
// Clear any pending timeout
|
||||
if (previewTimeoutRef.current) {
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
}
|
||||
// Small delay to debounce rapid hover changes
|
||||
previewTimeoutRef.current = setTimeout(() => {
|
||||
setPreviewTheme(value as ThemeMode);
|
||||
}, 16); // ~1 frame delay
|
||||
},
|
||||
[setPreviewTheme]
|
||||
);
|
||||
|
||||
const handlePreviewLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) {
|
||||
// Clear any pending timeout
|
||||
if (previewTimeoutRef.current) {
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
}
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
},
|
||||
[setPreviewTheme]
|
||||
);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimeoutRef.current) {
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handlePreviewEnter,
|
||||
handlePreviewLeave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useTrashOperations } from './use-trash-operations';
|
||||
import type { TrashedProject } from '@/lib/electron';
|
||||
|
||||
interface UseTrashDialogProps {
|
||||
restoreTrashedProject: (projectId: string) => void;
|
||||
deleteTrashedProject: (projectId: string) => void;
|
||||
emptyTrash: () => void;
|
||||
trashedProjects: TrashedProject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that combines trash operations with dialog state management
|
||||
*/
|
||||
export function useTrashDialog({
|
||||
restoreTrashedProject,
|
||||
deleteTrashedProject,
|
||||
emptyTrash,
|
||||
trashedProjects,
|
||||
}: UseTrashDialogProps) {
|
||||
// Dialog state
|
||||
const [showTrashDialog, setShowTrashDialog] = useState(false);
|
||||
|
||||
// Reuse existing trash operations logic
|
||||
const trashOperations = useTrashOperations({
|
||||
restoreTrashedProject,
|
||||
deleteTrashedProject,
|
||||
emptyTrash,
|
||||
trashedProjects,
|
||||
});
|
||||
|
||||
return {
|
||||
// Dialog state
|
||||
showTrashDialog,
|
||||
setShowTrashDialog,
|
||||
|
||||
// Trash operations (spread from existing hook)
|
||||
...trashOperations,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI, type TrashedProject } from '@/lib/electron';
|
||||
|
||||
interface UseTrashOperationsProps {
|
||||
restoreTrashedProject: (projectId: string) => void;
|
||||
deleteTrashedProject: (projectId: string) => void;
|
||||
emptyTrash: () => void;
|
||||
trashedProjects: TrashedProject[];
|
||||
}
|
||||
|
||||
export function useTrashOperations({
|
||||
restoreTrashedProject,
|
||||
deleteTrashedProject,
|
||||
emptyTrash,
|
||||
trashedProjects,
|
||||
}: UseTrashOperationsProps) {
|
||||
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
|
||||
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
|
||||
|
||||
const handleRestoreProject = useCallback(
|
||||
(projectId: string) => {
|
||||
restoreTrashedProject(projectId);
|
||||
toast.success('Project restored', {
|
||||
description: 'Added back to your project list.',
|
||||
});
|
||||
},
|
||||
[restoreTrashedProject]
|
||||
);
|
||||
|
||||
const handleDeleteProjectFromDisk = useCallback(
|
||||
async (trashedProject: TrashedProject) => {
|
||||
const confirmed = window.confirm(
|
||||
`Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setActiveTrashId(trashedProject.id);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.trashItem) {
|
||||
throw new Error('System Trash is not available in this build.');
|
||||
}
|
||||
|
||||
const result = await api.trashItem(trashedProject.path);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete project folder');
|
||||
}
|
||||
|
||||
deleteTrashedProject(trashedProject.id);
|
||||
toast.success('Project folder sent to system Trash', {
|
||||
description: trashedProject.path,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to delete project from disk:', error);
|
||||
toast.error('Failed to delete project folder', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setActiveTrashId(null);
|
||||
}
|
||||
},
|
||||
[deleteTrashedProject]
|
||||
);
|
||||
|
||||
const handleEmptyTrash = useCallback(() => {
|
||||
if (trashedProjects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Clear all projects from recycle bin? This does not delete folders from disk.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsEmptyingTrash(true);
|
||||
try {
|
||||
emptyTrash();
|
||||
toast.success('Recycle bin cleared');
|
||||
} finally {
|
||||
setIsEmptyingTrash(false);
|
||||
}
|
||||
}, [emptyTrash, trashedProjects.length]);
|
||||
|
||||
return {
|
||||
activeTrashId,
|
||||
isEmptyingTrash,
|
||||
handleRestoreProject,
|
||||
handleDeleteProjectFromDisk,
|
||||
handleEmptyTrash,
|
||||
};
|
||||
}
|
||||
32
apps/ui/src/components/layout/sidebar/types.ts
Normal file
32
apps/ui/src/components/layout/sidebar/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type React from 'react';
|
||||
|
||||
export interface NavSection {
|
||||
label?: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
project: Project;
|
||||
currentProjectId: string | undefined;
|
||||
isHighlighted: boolean;
|
||||
onSelect: (project: Project) => void;
|
||||
}
|
||||
|
||||
export interface ThemeMenuItemProps {
|
||||
option: {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
color: string;
|
||||
};
|
||||
onPreviewEnter: (value: string) => void;
|
||||
onPreviewLeave: (e: React.PointerEvent) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user