-
- {template.name}
-
- {selectedTemplate?.id === template.id &&
- !useCustomUrl && (
-
- )}
+ {template.name}
+ {selectedTemplate?.id === template.id && !useCustomUrl && (
+
+ )}
{template.description}
@@ -388,11 +346,7 @@ export function NewProjectModal({
{/* Tech Stack */}
{template.techStack.slice(0, 6).map((tech) => (
-
+
{tech}
))}
@@ -406,7 +360,7 @@ export function NewProjectModal({
{/* Key Features */}
Features:
- {template.features.slice(0, 3).join(" · ")}
+ {template.features.slice(0, 3).join(' · ')}
{template.features.length > 3 &&
` · +${template.features.length - 3} more`}
@@ -431,47 +385,38 @@ export function NewProjectModal({
{/* Custom URL Option */}
-
- Custom GitHub URL
-
- {useCustomUrl && (
-
- )}
+ Custom GitHub URL
+ {useCustomUrl && }
Clone any public GitHub repository as a starting point.
{useCustomUrl && (
-
e.stopPropagation()}
- className="space-y-1"
- >
+
e.stopPropagation()} className="space-y-1">
setCustomUrl(e.target.value)}
className={cn(
- "bg-input text-foreground placeholder:text-muted-foreground",
+ 'bg-input text-foreground placeholder:text-muted-foreground',
errors.customUrl
- ? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
- : "border-border"
+ ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
+ : 'border-border'
)}
data-testid="custom-url-input"
/>
{errors.customUrl && (
-
- GitHub URL is required
-
+
GitHub URL is required
)}
)}
@@ -494,14 +439,14 @@ export function NewProjectModal({
onClick={validateAndCreate}
disabled={isCreating}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
- hotkey={{ key: "Enter", cmdCtrl: true }}
+ hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-create-project"
>
{isCreating ? (
<>
- {activeTab === "template" ? "Cloning..." : "Creating..."}
+ {activeTab === 'template' ? 'Cloning...' : 'Creating...'}
>
) : (
<>Create Project>
diff --git a/apps/ui/src/components/workspace-picker-modal.tsx b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx
similarity index 85%
rename from apps/ui/src/components/workspace-picker-modal.tsx
rename to apps/ui/src/components/dialogs/workspace-picker-modal.tsx
index 2f3303a2..4f287465 100644
--- a/apps/ui/src/components/workspace-picker-modal.tsx
+++ b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx
@@ -1,5 +1,4 @@
-
-import { useState, useEffect, useCallback } from "react";
+import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -7,10 +6,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Folder, Loader2, FolderOpen, AlertCircle } from "lucide-react";
-import { getHttpApiClient } from "@/lib/http-api-client";
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
+import { getHttpApiClient } from '@/lib/http-api-client';
interface WorkspaceDirectory {
name: string;
@@ -23,11 +22,7 @@ interface WorkspacePickerModalProps {
onSelect: (path: string, name: string) => void;
}
-export function WorkspacePickerModal({
- open,
- onOpenChange,
- onSelect,
-}: WorkspacePickerModalProps) {
+export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [directories, setDirectories] = useState
([]);
const [error, setError] = useState(null);
@@ -43,10 +38,10 @@ export function WorkspacePickerModal({
if (result.success && result.directories) {
setDirectories(result.directories);
} else {
- setError(result.error || "Failed to load directories");
+ setError(result.error || 'Failed to load directories');
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load directories");
+ setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setIsLoading(false);
}
@@ -90,12 +85,7 @@ export function WorkspacePickerModal({
{error}
-
+
Try Again
@@ -128,9 +118,7 @@ export function WorkspacePickerModal({
{dir.name}
-
- {dir.path}
-
+ {dir.path}
))}
diff --git a/apps/ui/src/components/layout/index.ts b/apps/ui/src/components/layout/index.ts
new file mode 100644
index 00000000..bfed6246
--- /dev/null
+++ b/apps/ui/src/components/layout/index.ts
@@ -0,0 +1 @@
+export { Sidebar } from './sidebar';
diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx
index 4dc52c72..16b1e5cb 100644
--- a/apps/ui/src/components/layout/sidebar.tsx
+++ b/apps/ui/src/components/layout/sidebar.tsx
@@ -1,222 +1,36 @@
-import { useState, useMemo, useEffect, useCallback, useRef } from "react";
-import { useNavigate, useLocation } from "@tanstack/react-router";
-import { cn } from "@/lib/utils";
-import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store";
+import { useState, useCallback } from 'react';
+import { useNavigate, useLocation } from '@tanstack/react-router';
+import { cn } from '@/lib/utils';
+import { useAppStore, type ThemeMode } from '@/store/app-store';
+import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
+import { getElectronAPI } from '@/lib/electron';
+import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
+import { toast } from 'sonner';
+import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
+import { NewProjectModal } from '@/components/dialogs/new-project-modal';
+import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
+
+// Local imports from subfolder
import {
- FolderOpen,
- Plus,
- Settings,
- FileText,
- LayoutGrid,
- Bot,
- Folder,
- X,
- PanelLeft,
- PanelLeftClose,
- ChevronDown,
- Redo2,
- Check,
- BookOpen,
- GripVertical,
- RotateCcw,
- Trash2,
- Undo2,
- UserCircle,
- MoreVertical,
- Palette,
- Monitor,
- Search,
- Bug,
- Activity,
- Recycle,
- Sparkles,
- Loader2,
- Terminal,
- Rocket,
- Zap,
- CheckCircle2,
- ArrowRight,
-} from "lucide-react";
+ CollapseToggleButton,
+ SidebarHeader,
+ ProjectActions,
+ SidebarNavigation,
+ ProjectSelectorWithOptions,
+ SidebarFooter,
+} from './sidebar/components';
+import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
+import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuTrigger,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuSub,
- DropdownMenuSubTrigger,
- DropdownMenuSubContent,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuLabel,
-} from "@/components/ui/dropdown-menu";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import {
- useKeyboardShortcuts,
- useKeyboardShortcutsConfig,
- KeyboardShortcut,
-} from "@/hooks/use-keyboard-shortcuts";
-import {
- getElectronAPI,
- Project,
- TrashedProject,
- RunningAgent,
-} from "@/lib/electron";
-import {
- initializeProject,
- hasAppSpec,
- hasAutomakerDir,
-} from "@/lib/project-init";
-import { toast } from "sonner";
-import { themeOptions } from "@/config/theme-options";
-import type { SpecRegenerationEvent } from "@/types/electron";
-import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
-import { NewProjectModal } from "@/components/new-project-modal";
-import { CreateSpecDialog } from "@/components/views/spec-view/dialogs";
-import type { FeatureCount } from "@/components/views/spec-view/types";
-import {
- DndContext,
- DragEndEvent,
- PointerSensor,
- useSensor,
- useSensors,
- closestCenter,
-} from "@dnd-kit/core";
-import {
- SortableContext,
- useSortable,
- verticalListSortingStrategy,
-} from "@dnd-kit/sortable";
-import { CSS } from "@dnd-kit/utilities";
-import { getHttpApiClient } from "@/lib/http-api-client";
-import type { StarterTemplate } from "@/lib/templates";
-
-interface NavSection {
- label?: string;
- items: NavItem[];
-}
-
-interface NavItem {
- id: string;
- label: string;
- icon: any;
- shortcut?: string;
-}
-
-// Sortable Project Item Component
-interface SortableProjectItemProps {
- project: Project;
- currentProjectId: string | undefined;
- isHighlighted: boolean;
- onSelect: (project: Project) => void;
-}
-
-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 (
-
- {/* Drag Handle */}
-
e.stopPropagation()}
- >
-
-
-
- {/* Project content - clickable area */}
-
onSelect(project)}
- >
-
-
- {project.name}
-
- {currentProjectId === project.id && (
-
- )}
-
-
- );
-}
-
-// Theme options for project theme selector - derived from the shared config
-const PROJECT_THEME_OPTIONS = [
- { value: "", label: "Use Global", icon: Monitor },
- ...themeOptions.map((opt) => ({
- value: opt.value,
- label: opt.label,
- icon: opt.Icon,
- })),
-] as const;
-
-// Reusable Bug Report Button Component
-const BugReportButton = ({
- sidebarExpanded,
- onClick
-}: {
- sidebarExpanded: boolean;
- onClick: () => void;
-}) => {
- return (
-
-
-
- );
-};
+ useSidebarAutoCollapse,
+ useRunningAgents,
+ useSpecRegeneration,
+ useNavigation,
+ useProjectCreation,
+ useSetupDialog,
+ useTrashDialog,
+ useProjectTheme,
+} from './sidebar/hooks';
export function Sidebar() {
const navigate = useNavigate();
@@ -229,642 +43,117 @@ export function Sidebar() {
sidebarOpen,
projectHistory,
upsertAndSetCurrentProject,
- setCurrentProject,
toggleSidebar,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
- reorderProjects,
cyclePrevProject,
cycleNextProject,
- clearProjectHistory,
- setProjectTheme,
- setTheme,
- setPreviewTheme,
- theme: globalTheme,
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
// Environment variable flags for hiding sidebar items
- const hideTerminal = import.meta.env.VITE_HIDE_TERMINAL === "true";
- const hideWiki = import.meta.env.VITE_HIDE_WIKI === "true";
- const hideRunningAgents =
- import.meta.env.VITE_HIDE_RUNNING_AGENTS === "true";
- const hideContext = import.meta.env.VITE_HIDE_CONTEXT === "true";
- const hideSpecEditor = import.meta.env.VITE_HIDE_SPEC_EDITOR === "true";
- const hideAiProfiles = import.meta.env.VITE_HIDE_AI_PROFILES === "true";
+ const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
+ SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
- // State for project picker dropdown
+ // State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
- const [projectSearchQuery, setProjectSearchQuery] = useState("");
- const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
- const [showTrashDialog, setShowTrashDialog] = useState(false);
- const [activeTrashId, setActiveTrashId] = useState
(null);
- const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
- // State for running agents count
- const [runningAgentsCount, setRunningAgentsCount] = useState(0);
+ // Project theme management (must come before useProjectCreation which uses globalTheme)
+ const { globalTheme } = useProjectTheme();
- // State for new project modal
- const [showNewProjectModal, setShowNewProjectModal] = useState(false);
- const [isCreatingProject, setIsCreatingProject] = useState(false);
+ // Project creation state and handlers
+ const {
+ showNewProjectModal,
+ setShowNewProjectModal,
+ isCreatingProject,
+ showOnboardingDialog,
+ setShowOnboardingDialog,
+ newProjectName,
+ setNewProjectName,
+ newProjectPath,
+ setNewProjectPath,
+ handleCreateBlankProject,
+ handleCreateFromTemplate,
+ handleCreateFromCustomUrl,
+ } = useProjectCreation({
+ trashedProjects,
+ currentProject,
+ globalTheme,
+ upsertAndSetCurrentProject,
+ });
- // State for new project onboarding dialog
- const [showOnboardingDialog, setShowOnboardingDialog] = useState(false);
- const [newProjectName, setNewProjectName] = useState("");
- const [newProjectPath, setNewProjectPath] = useState("");
-
- // State for new project setup dialog
- 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(50);
- const [showSpecIndicator, setShowSpecIndicator] = useState(true);
+ // Setup dialog state and handlers
+ const {
+ showSetupDialog,
+ setShowSetupDialog,
+ setupProjectPath,
+ setSetupProjectPath,
+ projectOverview,
+ setProjectOverview,
+ generateFeatures,
+ setGenerateFeatures,
+ analyzeProject,
+ setAnalyzeProject,
+ featureCount,
+ setFeatureCount,
+ handleCreateInitialSpec,
+ handleSkipSetup,
+ handleOnboardingGenerateSpec,
+ handleOnboardingSkip,
+ } = useSetupDialog({
+ setSpecCreatingForProject,
+ newProjectPath,
+ setNewProjectName,
+ setNewProjectPath,
+ setShowOnboardingDialog,
+ });
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
- // Ref for project search input
- const projectSearchInputRef = useRef(null);
-
// Auto-collapse sidebar on small screens
- useEffect(() => {
- const mediaQuery = window.matchMedia("(max-width: 1024px)"); // lg breakpoint
+ useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
- const handleResize = () => {
- if (mediaQuery.matches && sidebarOpen) {
- // Auto-collapse on small screens
- toggleSidebar();
- }
- };
+ // Running agents count
+ const { runningAgentsCount } = useRunningAgents();
- // Check on mount
- handleResize();
+ // Trash dialog and operations
+ const {
+ showTrashDialog,
+ setShowTrashDialog,
+ activeTrashId,
+ isEmptyingTrash,
+ handleRestoreProject,
+ handleDeleteProjectFromDisk,
+ handleEmptyTrash,
+ } = useTrashDialog({
+ restoreTrashedProject,
+ deleteTrashedProject,
+ emptyTrash,
+ trashedProjects,
+ });
- // Listen for changes
- mediaQuery.addEventListener("change", handleResize);
- return () => mediaQuery.removeEventListener("change", handleResize);
- }, [sidebarOpen, toggleSidebar]);
-
- // 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]);
-
- // 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]
- );
-
- // 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();
- };
- }, [
+ // Spec regeneration events
+ useSpecRegeneration({
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
- ]);
-
- // 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]);
-
- // 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);
- setShowSpecIndicator(true);
- 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("[Sidebar] 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("[Sidebar] 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]);
-
- // Handle onboarding dialog - generate spec
- const handleOnboardingGenerateSpec = useCallback(() => {
- setShowOnboardingDialog(false);
- // Navigate to the setup dialog flow
- setSetupProjectPath(newProjectPath);
- setProjectOverview("");
- setShowSetupDialog(true);
- }, [newProjectPath]);
-
- // 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!",
- }
- );
- }, []);
-
- /**
- * Create a blank project with just .automaker directory structure
- */
- const handleCreateBlankProject = useCallback(
- async (projectName: string, parentDir: string) => {
- setIsCreatingProject(true);
- try {
- const api = getElectronAPI();
- const projectPath = `${parentDir}/${projectName}`;
-
- // Create project directory
- const mkdirResult = await api.mkdir(projectPath);
- if (!mkdirResult.success) {
- toast.error("Failed to create project directory", {
- description: mkdirResult.error || "Unknown error occurred",
- });
- return;
- }
-
- // Initialize .automaker directory with all necessary files
- const initResult = await initializeProject(projectPath);
-
- if (!initResult.success) {
- toast.error("Failed to initialize project", {
- description: initResult.error || "Unknown error occurred",
- });
- return;
- }
-
- // Update the app_spec.txt with the project name
- // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
- await api.writeFile(
- `${projectPath}/.automaker/app_spec.txt`,
- `
- ${projectName}
-
-
- Describe your project here. This file will be analyzed by an AI agent
- to understand your project structure and tech stack.
-
-
-
-
-
-
-
-
-
-
-
-
-
- `
- );
-
- const trashedProject = trashedProjects.find(
- (p) => p.path === projectPath
- );
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
- const project = upsertAndSetCurrentProject(
- projectPath,
- projectName,
- effectiveTheme
- );
-
- setShowNewProjectModal(false);
-
- // Show onboarding dialog for new project
- setNewProjectName(projectName);
- setNewProjectPath(projectPath);
- setShowOnboardingDialog(true);
-
- toast.success("Project created", {
- description: `Created ${projectName} with .automaker directory`,
- });
- } catch (error) {
- console.error("[Sidebar] Failed to create project:", error);
- toast.error("Failed to create project", {
- description: error instanceof Error ? error.message : "Unknown error",
- });
- } finally {
- setIsCreatingProject(false);
- }
- },
- [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
- );
-
- /**
- * Create a project from a GitHub starter template
- */
- const handleCreateFromTemplate = useCallback(
- async (
- template: StarterTemplate,
- projectName: string,
- parentDir: string
- ) => {
- setIsCreatingProject(true);
- try {
- const httpClient = getHttpApiClient();
- const api = getElectronAPI();
-
- // Clone the template repository
- const cloneResult = await httpClient.templates.clone(
- template.repoUrl,
- projectName,
- parentDir
- );
-
- if (!cloneResult.success || !cloneResult.projectPath) {
- toast.error("Failed to clone template", {
- description: cloneResult.error || "Unknown error occurred",
- });
- return;
- }
-
- const projectPath = cloneResult.projectPath;
-
- // Initialize .automaker directory with all necessary files
- const initResult = await initializeProject(projectPath);
-
- if (!initResult.success) {
- toast.error("Failed to initialize project", {
- description: initResult.error || "Unknown error occurred",
- });
- return;
- }
-
- // Update the app_spec.txt with template-specific info
- // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
- await api.writeFile(
- `${projectPath}/.automaker/app_spec.txt`,
- `
- ${projectName}
-
-
- This project was created from the "${template.name}" starter template.
- ${template.description}
-
-
-
- ${template.techStack
- .map((tech) => `${tech} `)
- .join("\n ")}
-
-
-
- ${template.features
- .map((feature) => `${feature} `)
- .join("\n ")}
-
-
-
-
-
- `
- );
-
- const trashedProject = trashedProjects.find(
- (p) => p.path === projectPath
- );
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
- const project = upsertAndSetCurrentProject(
- projectPath,
- projectName,
- effectiveTheme
- );
-
- setShowNewProjectModal(false);
-
- // Show onboarding dialog for new project
- setNewProjectName(projectName);
- setNewProjectPath(projectPath);
- setShowOnboardingDialog(true);
-
- toast.success("Project created from template", {
- description: `Created ${projectName} from ${template.name}`,
- });
- } catch (error) {
- console.error(
- "[Sidebar] Failed to create project from template:",
- error
- );
- toast.error("Failed to create project", {
- description: error instanceof Error ? error.message : "Unknown error",
- });
- } finally {
- setIsCreatingProject(false);
- }
- },
- [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
- );
-
- /**
- * Create a project from a custom GitHub URL
- */
- const handleCreateFromCustomUrl = useCallback(
- async (repoUrl: string, projectName: string, parentDir: string) => {
- setIsCreatingProject(true);
- try {
- const httpClient = getHttpApiClient();
- const api = getElectronAPI();
-
- // Clone the repository
- const cloneResult = await httpClient.templates.clone(
- repoUrl,
- projectName,
- parentDir
- );
-
- if (!cloneResult.success || !cloneResult.projectPath) {
- toast.error("Failed to clone repository", {
- description: cloneResult.error || "Unknown error occurred",
- });
- return;
- }
-
- const projectPath = cloneResult.projectPath;
-
- // Initialize .automaker directory with all necessary files
- const initResult = await initializeProject(projectPath);
-
- if (!initResult.success) {
- toast.error("Failed to initialize project", {
- description: initResult.error || "Unknown error occurred",
- });
- return;
- }
-
- // Update the app_spec.txt with basic info
- // Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
- await api.writeFile(
- `${projectPath}/.automaker/app_spec.txt`,
- `
- ${projectName}
-
-
- This project was cloned from ${repoUrl}.
- The AI agent will analyze the project structure.
-
-
-
-
-
-
-
-
-
-
-
-
-
- `
- );
-
- const trashedProject = trashedProjects.find(
- (p) => p.path === projectPath
- );
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
- const project = upsertAndSetCurrentProject(
- projectPath,
- projectName,
- effectiveTheme
- );
-
- setShowNewProjectModal(false);
-
- // Show onboarding dialog for new project
- setNewProjectName(projectName);
- setNewProjectPath(projectPath);
- setShowOnboardingDialog(true);
-
- toast.success("Project created from repository", {
- description: `Created ${projectName} from ${repoUrl}`,
- });
- } catch (error) {
- console.error("[Sidebar] Failed to create project from URL:", error);
- toast.error("Failed to create project", {
- description: error instanceof Error ? error.message : "Unknown error",
- });
- } finally {
- setIsCreatingProject(false);
- }
- },
- [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
- );
-
- // Handle bug report button click
- const handleBugReportClick = useCallback(() => {
- const api = getElectronAPI();
- api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues");
- }, []);
+ setShowSetupDialog,
+ setProjectOverview,
+ setSetupProjectPath,
+ setNewProjectName,
+ setNewProjectPath,
+ });
/**
* Opens the system folder selection dialog and initializes the selected project.
@@ -877,8 +166,7 @@ export function Sidebar() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
- const name =
- path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
+ const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
// Check if this is a brand new project (no .automaker directory)
@@ -888,8 +176,8 @@ export function Sidebar() {
const initResult = await initializeProject(path);
if (!initResult.success) {
- toast.error("Failed to initialize project", {
- description: initResult.error || "Unknown error occurred",
+ toast.error('Failed to initialize project', {
+ description: initResult.error || 'Unknown error occurred',
});
return;
}
@@ -901,7 +189,7 @@ export function Sidebar() {
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
- const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
+ upsertAndSetCurrentProject(path, name, effectiveTheme);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -910,1359 +198,124 @@ export function Sidebar() {
// This is a brand new project - show setup dialog
setSetupProjectPath(path);
setShowSetupDialog(true);
- toast.success("Project opened", {
+ toast.success('Project opened', {
description: `Opened ${name}. Let's set up your app specification!`,
});
- } else if (
- initResult.createdFiles &&
- initResult.createdFiles.length > 0
- ) {
- toast.success(
- initResult.isNewProject ? "Project initialized" : "Project updated",
- {
- description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
- }
- );
+ } else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
+ toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
+ description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
+ });
} else {
- toast.success("Project opened", {
+ toast.success('Project opened', {
description: `Opened ${name}`,
});
}
} catch (error) {
- console.error("[Sidebar] Failed to open project:", error);
- toast.error("Failed to open project", {
- description: error instanceof Error ? error.message : "Unknown error",
+ console.error('[Sidebar] Failed to open project:', error);
+ toast.error('Failed to open project', {
+ description: error instanceof Error ? error.message : 'Unknown error',
});
}
}
- }, [
- trashedProjects,
- upsertAndSetCurrentProject,
- currentProject,
- globalTheme,
- ]);
+ }, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
- const handleRestoreProject = useCallback(
- (projectId: string) => {
- restoreTrashedProject(projectId);
- toast.success("Project restored", {
- description: "Added back to your project list.",
- });
- setShowTrashDialog(false);
- },
- [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) {
- setShowTrashDialog(false);
- 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");
- setShowTrashDialog(false);
- } finally {
- setIsEmptyingTrash(false);
- }
- }, [emptyTrash, trashedProjects.length]);
-
- 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]);
-
- // Handle selecting the currently highlighted project
- const selectHighlightedProject = useCallback(() => {
- if (
- filteredProjects.length > 0 &&
- selectedProjectIndex < filteredProjects.length
- ) {
- setCurrentProject(filteredProjects[selectedProjectIndex]);
- setIsProjectPickerOpen(false);
- }
- }, [filteredProjects, selectedProjectIndex, setCurrentProject]);
-
- // 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]);
-
- // 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;
- }, [
+ // Navigation sections and keyboard shortcuts (defined after handlers)
+ const { navSections, navigationShortcuts } = useNavigation({
shortcuts,
+ hideSpecEditor,
+ hideContext,
+ hideTerminal,
+ hideAiProfiles,
currentProject,
+ projects,
+ projectHistory,
navigate,
toggleSidebar,
- projects.length,
handleOpenFolder,
- projectHistory.length,
+ setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
- navSections,
- ]);
+ });
// Register keyboard shortcuts
useKeyboardShortcuts(navigationShortcuts);
const isActiveRoute = (id: string) => {
// Map view IDs to route paths
- const routePath = id === "welcome" ? "/" : `/${id}`;
+ const routePath = id === 'welcome' ? '/' : `/${id}`;
return location.pathname === routePath;
};
return (
- {/* Floating Collapse Toggle Button - Desktop only - At border intersection */}
-
- {sidebarOpen ? (
-
- ) : (
-
- )}
- {/* Tooltip */}
-
- {sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}{" "}
-
- {formatShortcut(shortcuts.toggleSidebar, true)}
-
-
-
+
- {/* Logo */}
-
-
navigate({ to: "/" })}
- data-testid="logo-button"
- >
- {!sidebarOpen ? (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- automaker.
-
-
- )}
-
- {/* Bug Report Button - Inside logo container when expanded */}
- {sidebarOpen &&
}
-
-
- {/* Bug Report Button - Collapsed sidebar version */}
- {!sidebarOpen && (
-
-
-
- )}
+
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
-
-
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"
- >
-
-
- New
-
-
-
-
-
- {formatShortcut(shortcuts.openProject, true)}
-
-
-
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"
- >
-
- {trashedProjects.length > 0 && (
-
- {trashedProjects.length > 9 ? "9+" : trashedProjects.length}
-
- )}
-
-
+
)}
- {/* Project Selector with Cycle Buttons */}
- {sidebarOpen && projects.length > 0 && (
-
-
-
-
-
-
-
- {currentProject?.name || "Select Project"}
-
-
-
-
- {formatShortcut(shortcuts.projectPicker, true)}
-
-
-
-
-
-
- {/* Search input for type-ahead filtering */}
-
-
-
- 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"
- />
-
-
+
- {filteredProjects.length === 0 ? (
-
- No projects found
-
- ) : (
-
- p.id)}
- strategy={verticalListSortingStrategy}
- >
-
- {filteredProjects.map((project, index) => (
- {
- setCurrentProject(p);
- setIsProjectPickerOpen(false);
- }}
- />
- ))}
-
-
-
- )}
-
- {/* Keyboard hint */}
-
-
- arrow navigate{" "}
- | {" "}
- enter select{" "}
- | {" "}
- esc close
-
-
-
-
-
- {/* Project Options Menu - theme and history */}
- {currentProject && (
-
{
- // Clear preview theme when the menu closes
- if (!open) {
- setPreviewTheme(null);
- }
- }}
- >
-
-
-
-
-
-
- {/* Project Theme Submenu */}
-
-
-
- Project Theme
- {currentProject.theme && (
-
- {currentProject.theme}
-
- )}
-
- {
- // Clear preview theme when leaving the dropdown
- setPreviewTheme(null);
- }}
- >
-
- Select theme for this project
-
-
- {
- if (currentProject) {
- // Clear preview theme when a theme is selected
- setPreviewTheme(null);
- // If selecting an actual theme (not "Use Global"), also update global
- if (value !== "") {
- setTheme(value as any);
- } else {
- // Restore to global theme when "Use Global" is selected
- setTheme(globalTheme);
- }
- setProjectTheme(
- currentProject.id,
- value === "" ? null : (value as any)
- );
- }
- }}
- >
- {PROJECT_THEME_OPTIONS.map((option) => {
- const Icon = option.icon;
- const themeValue =
- option.value === "" ? globalTheme : option.value;
- return (
- {
- // Preview the theme on hover
- setPreviewTheme(themeValue as any);
- }}
- onPointerLeave={(e) => {
- // Clear preview theme when leaving this item
- // Only clear if we're not moving to another theme item
- const relatedTarget =
- e.relatedTarget as HTMLElement;
- if (
- !relatedTarget ||
- !relatedTarget.closest(
- '[data-testid^="project-theme-"]'
- )
- ) {
- setPreviewTheme(null);
- }
- }}
- >
- {
- // Preview the theme on keyboard navigation
- setPreviewTheme(themeValue as any);
- }}
- onBlur={() => {
- // Clear preview theme when losing focus
- // If moving to another item, its onFocus will set it again
- setPreviewTheme(null);
- }}
- >
-
- {option.label}
- {option.value === "" && (
-
- ({globalTheme})
-
- )}
-
-
- );
- })}
-
-
-
-
- {/* Project History Section - only show when there's history */}
- {projectHistory.length > 1 && (
- <>
-
-
- Project History
-
-
-
- Previous
-
- {formatShortcut(shortcuts.cyclePrevProject, true)}
-
-
-
-
- Next
-
- {formatShortcut(shortcuts.cycleNextProject, true)}
-
-
-
-
- Clear history
-
- >
- )}
-
- {/* Move to Trash Section */}
-
- setShowDeleteProjectDialog(true)}
- className="text-destructive focus:text-destructive focus:bg-destructive/10"
- data-testid="move-project-to-trash"
- >
-
- Move to Trash
-
-
-
- )}
-
- )}
-
- {/* Nav Items - Scrollable */}
-
- {!currentProject && sidebarOpen ? (
- // Placeholder when no project is selected (only in expanded state)
-
-
-
- Select or create a project above
-
-
-
- ) : currentProject ? (
- // Navigation sections when project is selected
- navSections.map((section, sectionIdx) => (
- 0 && sidebarOpen ? "mt-6" : ""}>
- {/* Section Label */}
- {section.label && sidebarOpen && (
-
-
- {section.label}
-
-
- )}
- {section.label && !sidebarOpen && (
-
- )}
-
- {/* Nav Items */}
-
- {section.items.map((item) => {
- const isActive = isActiveRoute(item.id);
- const Icon = item.icon;
-
- return (
- 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}`}
- >
-
-
- {item.label}
-
- {item.shortcut && sidebarOpen && (
-
- {formatShortcut(item.shortcut, true)}
-
- )}
- {/* Tooltip for collapsed state */}
- {!sidebarOpen && (
-
- {item.label}
- {item.shortcut && (
-
- {formatShortcut(item.shortcut, true)}
-
- )}
-
- )}
-
- );
- })}
-
-
- ))
- ) : null}
-
+
- {/* Bottom Section - Running Agents / Bug Report / Settings */}
-
- {/* Wiki Link */}
- {!hideWiki && (
-
- 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"
- >
-
-
- Wiki
-
- {!sidebarOpen && (
-
- Wiki
-
- )}
-
-
- )}
- {/* Running Agents Link */}
- {!hideRunningAgents && (
-
-
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"
- >
-
-
- {/* Running agents count badge - shown in collapsed state */}
- {!sidebarOpen && runningAgentsCount > 0 && (
-
- {runningAgentsCount > 99 ? "99" : runningAgentsCount}
-
- )}
-
-
- Running Agents
-
- {/* Running agents count badge - shown in expanded state */}
- {sidebarOpen && runningAgentsCount > 0 && (
-
- {runningAgentsCount > 99 ? "99" : runningAgentsCount}
-
- )}
- {!sidebarOpen && (
-
- Running Agents
- {runningAgentsCount > 0 && (
-
- {runningAgentsCount}
-
- )}
-
- )}
-
-
- )}
- {/* Settings Link */}
-
- 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
-
- {sidebarOpen && (
-
- {formatShortcut(shortcuts.settings, true)}
-
- )}
- {!sidebarOpen && (
-
- Settings
-
- {formatShortcut(shortcuts.settings, true)}
-
-
- )}
-
-
-
-
-
-
- Recycle Bin
-
- Restore projects to the sidebar or delete their folders using your
- system Trash.
-
-
-
- {trashedProjects.length === 0 ? (
-
- Recycle bin is empty.
-
- ) : (
-
- {trashedProjects.map((project) => (
-
-
-
- {project.name}
-
-
- {project.path}
-
-
- Trashed {new Date(project.trashedAt).toLocaleString()}
-
-
-
- handleRestoreProject(project.id)}
- data-testid={`restore-project-${project.id}`}
- >
-
- Restore
-
- handleDeleteProjectFromDisk(project)}
- disabled={activeTrashId === project.id}
- data-testid={`delete-project-disk-${project.id}`}
- >
-
- {activeTrashId === project.id
- ? "Deleting..."
- : "Delete from disk"}
-
- deleteTrashedProject(project.id)}
- data-testid={`remove-project-${project.id}`}
- >
-
- Remove from list
-
-
-
- ))}
-
- )}
-
-
- setShowTrashDialog(false)}>
- Close
-
- {trashedProjects.length > 0 && (
-
- {isEmptyingTrash ? "Clearing..." : "Empty Recycle Bin"}
-
- )}
-
-
-
+
+
{/* New Project Setup Dialog */}
- {/* New Project Onboarding Dialog */}
- {
- if (!open) {
- handleOnboardingSkip();
- }
- }}
- >
-
-
-
-
-
-
-
-
- Welcome to {newProjectName}!
-
-
- Your new project is ready. Let's get you started.
-
-
-
-
-
-
- {/* Main explanation */}
-
-
- Would you like to auto-generate your{" "}
- app_spec.txt ? This file helps describe your
- project and is used to pre-populate your backlog with features
- to work on.
-
-
-
- {/* Benefits list */}
-
-
-
-
-
- Pre-populate your backlog
-
-
- Automatically generate features based on your project
- specification
-
-
-
-
-
-
-
- Better AI assistance
-
-
- Help AI agents understand your project structure and tech
- stack
-
-
-
-
-
-
-
- Project documentation
-
-
- Keep a clear record of your project's capabilities and
- features
-
-
-
-
-
- {/* Info box */}
-
-
- Tip: You can always
- generate or edit your app_spec.txt later from the Spec Editor in
- the sidebar.
-
-
-
-
-
-
- Skip for now
-
-
-
- Generate App Spec
-
-
-
-
-
+ onOpenChange={setShowOnboardingDialog}
+ newProjectName={newProjectName}
+ onSkip={handleOnboardingSkip}
+ onGenerateSpec={handleOnboardingGenerateSpec}
+ />
{/* Delete Project Confirmation Dialog */}
void;
+}
+
+export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
+ return (
+ navigate({ to: '/' })}
+ data-testid="logo-button"
+ >
+ {!sidebarOpen ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ automaker.
+
+
+ )}
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx
new file mode 100644
index 00000000..8139dc55
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/bug-report-button.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx
new file mode 100644
index 00000000..4c09056b
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx
@@ -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 (
+
+ {sidebarOpen ? (
+
+ ) : (
+
+ )}
+ {/* Tooltip */}
+
+ {sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}{' '}
+
+ {formatShortcut(shortcut, true)}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/index.ts b/apps/ui/src/components/layout/sidebar/components/index.ts
new file mode 100644
index 00000000..f559795c
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/index.ts
@@ -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';
diff --git a/apps/ui/src/components/layout/sidebar/components/project-actions.tsx b/apps/ui/src/components/layout/sidebar/components/project-actions.tsx
new file mode 100644
index 00000000..3730afe7
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/project-actions.tsx
@@ -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 (
+
+
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"
+ >
+
+ New
+
+
+
+
+ {formatShortcut(shortcuts.openProject, true)}
+
+
+
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"
+ >
+
+ {trashedProjects.length > 0 && (
+
+ {trashedProjects.length > 9 ? '9+' : trashedProjects.length}
+
+ )}
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx
new file mode 100644
index 00000000..d5b24899
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {currentProject?.name || 'Select Project'}
+
+
+
+
+ {formatShortcut(shortcuts.projectPicker, true)}
+
+
+
+
+
+
+ {/* Search input for type-ahead filtering */}
+
+
+
+ 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"
+ />
+
+
+
+ {filteredProjects.length === 0 ? (
+
+ No projects found
+
+ ) : (
+
+ p.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {filteredProjects.map((project, index) => (
+ {
+ setCurrentProject(p);
+ setIsProjectPickerOpen(false);
+ }}
+ />
+ ))}
+
+
+
+ )}
+
+ {/* Keyboard hint */}
+
+
+ arrow navigate{' '}
+ | {' '}
+ enter select{' '}
+ | {' '}
+ esc close
+
+
+
+
+
+ {/* Project Options Menu - theme and history */}
+ {currentProject && (
+
{
+ // Clear preview theme when the menu closes
+ if (!open) {
+ setPreviewTheme(null);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {/* Project Theme Submenu */}
+
+
+
+ Project Theme
+ {currentProject.theme && (
+
+ {currentProject.theme}
+
+ )}
+
+ {
+ // Clear preview theme when leaving the dropdown
+ setPreviewTheme(null);
+ }}
+ >
+ {/* Use Global Option */}
+ {
+ if (currentProject) {
+ setPreviewTheme(null);
+ if (value !== '') {
+ setTheme(value as ThemeMode);
+ } else {
+ setTheme(globalTheme);
+ }
+ setProjectTheme(
+ currentProject.id,
+ value === '' ? null : (value as ThemeMode)
+ );
+ }
+ }}
+ >
+ handlePreviewEnter(globalTheme)}
+ onPointerLeave={() => setPreviewTheme(null)}
+ >
+
+
+ Use Global
+
+ ({globalTheme})
+
+
+
+
+ {/* Two Column Layout */}
+
+ {/* Dark Themes Column */}
+
+
+
+ Dark
+
+
+ {PROJECT_DARK_THEMES.map((option) => (
+
+ ))}
+
+
+ {/* Light Themes Column */}
+
+
+
+ Light
+
+
+ {PROJECT_LIGHT_THEMES.map((option) => (
+
+ ))}
+
+
+
+
+
+
+
+ {/* Project History Section - only show when there's history */}
+ {projectHistory.length > 1 && (
+ <>
+
+
+ Project History
+
+
+
+ Previous
+
+ {formatShortcut(shortcuts.cyclePrevProject, true)}
+
+
+
+
+ Next
+
+ {formatShortcut(shortcuts.cycleNextProject, true)}
+
+
+
+
+ Clear history
+
+ >
+ )}
+
+ {/* Move to Trash Section */}
+
+ setShowDeleteProjectDialog(true)}
+ className="text-destructive focus:text-destructive focus:bg-destructive/10"
+ data-testid="move-project-to-trash"
+ >
+
+ Move to Trash
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx
new file mode 100644
index 00000000..664797b6
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx
@@ -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 (
+
+ {/* Wiki Link */}
+ {!hideWiki && (
+
+ 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"
+ >
+
+
+ Wiki
+
+ {!sidebarOpen && (
+
+ Wiki
+
+ )}
+
+
+ )}
+ {/* Running Agents Link */}
+ {!hideRunningAgents && (
+
+
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"
+ >
+
+
+ {/* Running agents count badge - shown in collapsed state */}
+ {!sidebarOpen && runningAgentsCount > 0 && (
+
+ {runningAgentsCount > 99 ? '99' : runningAgentsCount}
+
+ )}
+
+
+ Running Agents
+
+ {/* Running agents count badge - shown in expanded state */}
+ {sidebarOpen && runningAgentsCount > 0 && (
+
+ {runningAgentsCount > 99 ? '99' : runningAgentsCount}
+
+ )}
+ {!sidebarOpen && (
+
+ Running Agents
+ {runningAgentsCount > 0 && (
+
+ {runningAgentsCount}
+
+ )}
+
+ )}
+
+
+ )}
+ {/* Settings Link */}
+
+ 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
+
+ {sidebarOpen && (
+
+ {formatShortcut(shortcuts.settings, true)}
+
+ )}
+ {!sidebarOpen && (
+
+ Settings
+
+ {formatShortcut(shortcuts.settings, true)}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx
new file mode 100644
index 00000000..093474c0
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx
@@ -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 */}
+
+
+ {/* Bug Report Button - Inside logo container when expanded */}
+ {sidebarOpen &&
}
+
+
+ {/* Bug Report Button - Collapsed sidebar version */}
+ {!sidebarOpen && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx
new file mode 100644
index 00000000..5178affa
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx
@@ -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 (
+
+ {!currentProject && sidebarOpen ? (
+ // Placeholder when no project is selected (only in expanded state)
+
+
+ Select or create a project above
+
+
+ ) : currentProject ? (
+ // Navigation sections when project is selected
+ navSections.map((section, sectionIdx) => (
+ 0 && sidebarOpen ? 'mt-6' : ''}>
+ {/* Section Label */}
+ {section.label && sidebarOpen && (
+
+
+ {section.label}
+
+
+ )}
+ {section.label && !sidebarOpen &&
}
+
+ {/* Nav Items */}
+
+ {section.items.map((item) => {
+ const isActive = isActiveRoute(item.id);
+ const Icon = item.icon;
+
+ return (
+ 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}`}
+ >
+
+
+ {item.label}
+
+ {item.shortcut && sidebarOpen && (
+
+ {formatShortcut(item.shortcut, true)}
+
+ )}
+ {/* Tooltip for collapsed state */}
+ {!sidebarOpen && (
+
+ {item.label}
+ {item.shortcut && (
+
+ {formatShortcut(item.shortcut, true)}
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ ))
+ ) : null}
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx b/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx
new file mode 100644
index 00000000..9d1e567e
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/sortable-project-item.tsx
@@ -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 (
+
+ {/* Drag Handle */}
+
e.stopPropagation()}
+ >
+
+
+
+ {/* Project content - clickable area */}
+
onSelect(project)}>
+
+ {project.name}
+ {currentProjectId === project.id && }
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx b/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx
new file mode 100644
index 00000000..5d9749b2
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/components/theme-menu-item.tsx
@@ -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 (
+ onPreviewEnter(option.value)}
+ onPointerLeave={onPreviewLeave}
+ >
+
+
+ {option.label}
+
+
+ );
+});
diff --git a/apps/ui/src/components/layout/sidebar/constants.ts b/apps/ui/src/components/layout/sidebar/constants.ts
new file mode 100644
index 00000000..4beca953
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/constants.ts
@@ -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;
diff --git a/apps/ui/src/components/layout/sidebar/dialogs/index.ts b/apps/ui/src/components/layout/sidebar/dialogs/index.ts
new file mode 100644
index 00000000..9b9235df
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/dialogs/index.ts
@@ -0,0 +1,2 @@
+export { TrashDialog } from './trash-dialog';
+export { OnboardingDialog } from './onboarding-dialog';
diff --git a/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx b/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx
new file mode 100644
index 00000000..4a9e3558
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/dialogs/onboarding-dialog.tsx
@@ -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 (
+ {
+ if (!isOpen) {
+ onSkip();
+ }
+ onOpenChange(isOpen);
+ }}
+ >
+
+
+
+
+
+
+
+ Welcome to {newProjectName}!
+
+ Your new project is ready. Let's get you started.
+
+
+
+
+
+
+ {/* Main explanation */}
+
+
+ Would you like to auto-generate your app_spec.txt ? This file helps
+ describe your project and is used to pre-populate your backlog with features to work
+ on.
+
+
+
+ {/* Benefits list */}
+
+
+
+
+
Pre-populate your backlog
+
+ Automatically generate features based on your project specification
+
+
+
+
+
+
+
Better AI assistance
+
+ Help AI agents understand your project structure and tech stack
+
+
+
+
+
+
+
Project documentation
+
+ Keep a clear record of your project's capabilities and features
+
+
+
+
+
+ {/* Info box */}
+
+
+ Tip: You can always generate or edit your
+ app_spec.txt later from the Spec Editor in the sidebar.
+
+
+
+
+
+
+ Skip for now
+
+
+
+ Generate App Spec
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx b/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx
new file mode 100644
index 00000000..bb231436
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx
@@ -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 (
+
+
+
+ Recycle Bin
+
+ Restore projects to the sidebar or delete their folders using your system Trash.
+
+
+
+ {trashedProjects.length === 0 ? (
+ Recycle bin is empty.
+ ) : (
+
+ {trashedProjects.map((project) => (
+
+
+
{project.name}
+
{project.path}
+
+ Trashed {new Date(project.trashedAt).toLocaleString()}
+
+
+
+ handleRestoreProject(project.id)}
+ data-testid={`restore-project-${project.id}`}
+ >
+
+ Restore
+
+ handleDeleteProjectFromDisk(project)}
+ disabled={activeTrashId === project.id}
+ data-testid={`delete-project-disk-${project.id}`}
+ >
+
+ {activeTrashId === project.id ? 'Deleting...' : 'Delete from disk'}
+
+ deleteTrashedProject(project.id)}
+ data-testid={`remove-project-${project.id}`}
+ >
+
+ Remove from list
+
+
+
+ ))}
+
+ )}
+
+
+ onOpenChange(false)}>
+ Close
+
+ {trashedProjects.length > 0 && (
+
+ {isEmptyingTrash ? 'Clearing...' : 'Empty Recycle Bin'}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/index.ts b/apps/ui/src/components/layout/sidebar/hooks/index.ts
new file mode 100644
index 00000000..7a047f8a
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/index.ts
@@ -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';
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts b/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts
new file mode 100644
index 00000000..570264a4
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-drag-and-drop.ts
@@ -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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
new file mode 100644
index 00000000..3148ede0
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
@@ -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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts
new file mode 100644
index 00000000..c50c3d76
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts
@@ -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`,
+ `
+ ${projectName}
+
+
+ Describe your project here. This file will be analyzed by an AI agent
+ to understand your project structure and tech stack.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ );
+
+ // 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`,
+ `
+ ${projectName}
+
+
+ This project was created from the "${template.name}" starter template.
+ ${template.description}
+
+
+
+ ${template.techStack.map((tech) => `${tech} `).join('\n ')}
+
+
+
+ ${template.features.map((feature) => `${feature} `).join('\n ')}
+
+
+
+
+
+ `
+ );
+
+ // 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`,
+ `
+ ${projectName}
+
+
+ This project was cloned from ${repoUrl}.
+ The AI agent will analyze the project structure.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ );
+
+ // 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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts
new file mode 100644
index 00000000..7a8566dc
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts
@@ -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(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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts
new file mode 100644
index 00000000..b80e605d
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-theme.ts
@@ -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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts
new file mode 100644
index 00000000..7431e934
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts
@@ -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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts b/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts
new file mode 100644
index 00000000..8a94fd18
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts
@@ -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(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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts
new file mode 100644
index 00000000..9da2954e
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts
@@ -0,0 +1,35 @@
+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]);
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts b/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts
new file mode 100644
index 00000000..5337a603
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-spec-regeneration.ts
@@ -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,
+ ]);
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts b/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts
new file mode 100644
index 00000000..46c25e93
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-theme-preview.ts
@@ -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 | 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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts b/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts
new file mode 100644
index 00000000..74c1ee9b
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-trash-dialog.ts
@@ -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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts b/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts
new file mode 100644
index 00000000..bb0dc571
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-trash-operations.ts
@@ -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(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,
+ };
+}
diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts
new file mode 100644
index 00000000..4d9ecc35
--- /dev/null
+++ b/apps/ui/src/components/layout/sidebar/types.ts
@@ -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;
+}
diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx
index c255c27f..f8452aa1 100644
--- a/apps/ui/src/components/session-manager.tsx
+++ b/apps/ui/src/components/session-manager.tsx
@@ -1,10 +1,9 @@
-
-import { useState, useEffect } from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { HotkeyButton } from "@/components/ui/hotkey-button";
-import { Input } from "@/components/ui/input";
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useState, useEffect } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { HotkeyButton } from '@/components/ui/hotkey-button';
+import { Input } from '@/components/ui/input';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Plus,
MessageSquare,
@@ -15,66 +14,66 @@ import {
X,
ArchiveRestore,
Loader2,
-} from "lucide-react";
-import { cn } from "@/lib/utils";
-import type { SessionListItem } from "@/types/electron";
-import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
-import { getElectronAPI } from "@/lib/electron";
-import { DeleteSessionDialog } from "@/components/delete-session-dialog";
-import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog";
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { SessionListItem } from '@/types/electron';
+import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
+import { getElectronAPI } from '@/lib/electron';
+import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
+import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
// Random session name generator
const adjectives = [
- "Swift",
- "Bright",
- "Clever",
- "Dynamic",
- "Eager",
- "Focused",
- "Gentle",
- "Happy",
- "Inventive",
- "Jolly",
- "Keen",
- "Lively",
- "Mighty",
- "Noble",
- "Optimal",
- "Peaceful",
- "Quick",
- "Radiant",
- "Smart",
- "Tranquil",
- "Unique",
- "Vibrant",
- "Wise",
- "Zealous",
+ 'Swift',
+ 'Bright',
+ 'Clever',
+ 'Dynamic',
+ 'Eager',
+ 'Focused',
+ 'Gentle',
+ 'Happy',
+ 'Inventive',
+ 'Jolly',
+ 'Keen',
+ 'Lively',
+ 'Mighty',
+ 'Noble',
+ 'Optimal',
+ 'Peaceful',
+ 'Quick',
+ 'Radiant',
+ 'Smart',
+ 'Tranquil',
+ 'Unique',
+ 'Vibrant',
+ 'Wise',
+ 'Zealous',
];
const nouns = [
- "Agent",
- "Builder",
- "Coder",
- "Developer",
- "Explorer",
- "Forge",
- "Garden",
- "Helper",
- "Innovator",
- "Journey",
- "Kernel",
- "Lighthouse",
- "Mission",
- "Navigator",
- "Oracle",
- "Project",
- "Quest",
- "Runner",
- "Spark",
- "Task",
- "Unicorn",
- "Voyage",
- "Workshop",
+ 'Agent',
+ 'Builder',
+ 'Coder',
+ 'Developer',
+ 'Explorer',
+ 'Forge',
+ 'Garden',
+ 'Helper',
+ 'Innovator',
+ 'Journey',
+ 'Kernel',
+ 'Lighthouse',
+ 'Mission',
+ 'Navigator',
+ 'Oracle',
+ 'Project',
+ 'Quest',
+ 'Runner',
+ 'Spark',
+ 'Task',
+ 'Unicorn',
+ 'Voyage',
+ 'Workshop',
];
function generateRandomSessionName(): string {
@@ -101,19 +100,15 @@ export function SessionManager({
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState([]);
- const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
+ const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
const [editingSessionId, setEditingSessionId] = useState(null);
- const [editingName, setEditingName] = useState("");
+ const [editingName, setEditingName] = useState('');
const [isCreating, setIsCreating] = useState(false);
- const [newSessionName, setNewSessionName] = useState("");
- const [runningSessions, setRunningSessions] = useState>(
- new Set()
- );
+ const [newSessionName, setNewSessionName] = useState('');
+ const [runningSessions, setRunningSessions] = useState>(new Set());
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [sessionToDelete, setSessionToDelete] =
- useState(null);
- const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] =
- useState(false);
+ const [sessionToDelete, setSessionToDelete] = useState(null);
+ const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
// Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
@@ -131,10 +126,7 @@ export function SessionManager({
}
} catch (err) {
// Ignore errors for individual session checks
- console.warn(
- `[SessionManager] Failed to check running state for ${session.id}:`,
- err
- );
+ console.warn(`[SessionManager] Failed to check running state for ${session.id}:`, err);
}
}
@@ -180,14 +172,10 @@ export function SessionManager({
const sessionName = newSessionName.trim() || generateRandomSessionName();
- const result = await api.sessions.create(
- sessionName,
- projectPath,
- projectPath
- );
+ const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) {
- setNewSessionName("");
+ setNewSessionName('');
setIsCreating(false);
await loadSessions();
onSelectSession(result.session.id);
@@ -201,11 +189,7 @@ export function SessionManager({
const sessionName = generateRandomSessionName();
- const result = await api.sessions.create(
- sessionName,
- projectPath,
- projectPath
- );
+ const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) {
await loadSessions();
@@ -234,7 +218,7 @@ export function SessionManager({
if (result.success) {
setEditingSessionId(null);
- setEditingName("");
+ setEditingName('');
await loadSessions();
}
};
@@ -243,7 +227,7 @@ export function SessionManager({
const handleArchiveSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) {
- console.error("[SessionManager] Sessions API not available");
+ console.error('[SessionManager] Sessions API not available');
return;
}
@@ -256,10 +240,10 @@ export function SessionManager({
}
await loadSessions();
} else {
- console.error("[SessionManager] Archive failed:", result.error);
+ console.error('[SessionManager] Archive failed:', result.error);
}
} catch (error) {
- console.error("[SessionManager] Archive error:", error);
+ console.error('[SessionManager] Archive error:', error);
}
};
@@ -267,7 +251,7 @@ export function SessionManager({
const handleUnarchiveSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) {
- console.error("[SessionManager] Sessions API not available");
+ console.error('[SessionManager] Sessions API not available');
return;
}
@@ -276,10 +260,10 @@ export function SessionManager({
if (result.success) {
await loadSessions();
} else {
- console.error("[SessionManager] Unarchive failed:", result.error);
+ console.error('[SessionManager] Unarchive failed:', result.error);
}
} catch (error) {
- console.error("[SessionManager] Unarchive error:", error);
+ console.error('[SessionManager] Unarchive error:', error);
}
};
@@ -324,8 +308,7 @@ export function SessionManager({
const activeSessions = sessions.filter((s) => !s.isArchived);
const archivedSessions = sessions.filter((s) => s.isArchived);
- const displayedSessions =
- activeTab === "active" ? activeSessions : archivedSessions;
+ const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions;
return (
@@ -337,8 +320,8 @@ export function SessionManager({
size="sm"
onClick={() => {
// Switch to active tab if on archived tab
- if (activeTab === "archived") {
- setActiveTab("active");
+ if (activeTab === 'archived') {
+ setActiveTab('active');
}
handleQuickCreateSession();
}}
@@ -354,9 +337,7 @@ export function SessionManager({
- setActiveTab(value as "active" | "archived")
- }
+ onValueChange={(value) => setActiveTab(value as 'active' | 'archived')}
className="w-full"
>