diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 98535bda..2eff16c0 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -17,7 +17,6 @@ import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import { CollapseToggleButton, SidebarHeader, - ProjectActions, SidebarNavigation, ProjectSelectorWithOptions, SidebarFooter, @@ -276,17 +275,6 @@ export function Sidebar() {
- {/* Project Actions - Moved above project selector */} - {sidebarOpen && ( - - )} - navigate({ to: '/' })} + onClick={() => navigate({ to: '/dashboard' })} data-testid="logo-button" > {/* Collapsed logo - shown when sidebar is closed OR on small screens when sidebar is open */} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index e53688c1..b7628c9c 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -95,6 +95,8 @@ export function BoardView() { } = useAppStore(); // Subscribe to pipelineConfigByProject to trigger re-renders when it changes const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject); + // Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes + const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); const shortcuts = useKeyboardShortcutsConfig(); const { features: hookFeatures, @@ -1139,6 +1141,7 @@ export function BoardView() { {/* Header */} - {/* Worktree Panel */} - setShowCreateWorktreeDialog(true)} - onDeleteWorktree={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowDeleteWorktreeDialog(true); - }} - onCommit={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCommitWorktreeDialog(true); - }} - onCreatePR={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCreatePRDialog(true); - }} - onCreateBranch={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCreateBranchDialog(true); - }} - onAddressPRComments={handleAddressPRComments} - onResolveConflicts={handleResolveConflicts} - onRemovedWorktrees={handleRemovedWorktrees} - runningFeatureIds={runningAutoTasks} - branchCardCounts={branchCardCounts} - features={hookFeatures.map((f) => ({ - id: f.id, - branchName: f.branchName, - }))} - /> + {/* Worktree Panel - conditionally rendered based on visibility setting */} + {(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( + setShowCreateWorktreeDialog(true)} + onDeleteWorktree={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowDeleteWorktreeDialog(true); + }} + onCommit={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCommitWorktreeDialog(true); + }} + onCreatePR={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreatePRDialog(true); + }} + onCreateBranch={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreateBranchDialog(true); + }} + onAddressPRComments={handleAddressPRComments} + onResolveConflicts={handleResolveConflicts} + onRemovedWorktrees={handleRemovedWorktrees} + runningFeatureIds={runningAutoTasks} + branchCardCounts={branchCardCounts} + features={hookFeatures.map((f) => ({ + id: f.id, + branchName: f.branchName, + }))} + /> + )} {/* Main Content Area */}
diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index fb9f38c2..45bb8d40 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -1,18 +1,20 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { HotkeyButton } from '@/components/ui/hotkey-button'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Plus, Bot, Wand2, Settings2 } from 'lucide-react'; +import { Plus, Bot, Wand2, Settings2, GitBranch } from 'lucide-react'; import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import { UsagePopover } from '@/components/usage-popover'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; +import { getHttpApiClient } from '@/lib/http-api-client'; interface BoardHeaderProps { projectName: string; + projectPath: string; maxConcurrency: number; runningAgentsCount: number; onConcurrencyChange: (value: number) => void; @@ -30,6 +32,7 @@ const controlContainerClass = export function BoardHeader({ projectName, + projectPath, maxConcurrency, runningAgentsCount, onConcurrencyChange, @@ -47,6 +50,29 @@ export function BoardHeader({ const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + // Worktree panel visibility (per-project) + const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); + const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible); + const isWorktreePanelVisible = worktreePanelVisibleByProject[projectPath] ?? true; + + const handleWorktreePanelToggle = useCallback( + async (visible: boolean) => { + // Update local store + setWorktreePanelVisible(projectPath, visible); + + // Persist to server + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(projectPath, { + worktreePanelVisible: visible, + }); + } catch (error) { + console.error('Failed to persist worktree panel visibility:', error); + } + }, + [projectPath, setWorktreePanelVisible] + ); + // Claude usage tracking visibility logic // Hide when using API key (only show for Claude Code CLI users) // Also hide on Windows for now (CLI usage command not supported) @@ -71,6 +97,22 @@ export function BoardHeader({ {/* Usage Popover - show if either provider is authenticated */} {isMounted && (showClaudeUsage || showCodexUsage) && } + {/* Worktrees Toggle - only show after mount to prevent hydration issues */} + {isMounted && ( +
+ + + +
+ )} + {/* Concurrency Slider - only show after mount to prevent hydration issues */} {isMounted && (
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index e0030d09..b56f65e1 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef } from 'react'; import { Button } from '@/components/ui/button'; -import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react'; +import { GitBranch, Plus, RefreshCw } from 'lucide-react'; import { cn, pathsEqual } from '@/lib/utils'; -import { useAppStore } from '@/store/app-store'; import type { WorktreePanelProps, WorktreeInfo } from './types'; import { useWorktrees, @@ -83,12 +82,6 @@ export function WorktreePanel({ features, }); - // Collapse state from store (synced via API) - const isCollapsed = useAppStore((s) => s.worktreePanelCollapsed); - const setWorktreePanelCollapsed = useAppStore((s) => s.setWorktreePanelCollapsed); - - const toggleCollapsed = () => setWorktreePanelCollapsed(!isCollapsed); - // Periodic interval check (5 seconds) to detect branch changes on disk // Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders const intervalRef = useRef(null); @@ -104,18 +97,6 @@ export function WorktreePanel({ }; }, [fetchWorktrees]); - // Get the currently selected worktree for collapsed view - const selectedWorktree = worktrees.find((w) => { - if ( - currentWorktree === null || - currentWorktree === undefined || - currentWorktree.path === null - ) { - return w.isMain; - } - return pathsEqual(w.path, currentWorktreePath); - }); - const isWorktreeSelected = (worktree: WorktreeInfo) => { return worktree.isMain ? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null @@ -138,44 +119,8 @@ export function WorktreePanel({ const mainWorktree = worktrees.find((w) => w.isMain); const nonMainWorktrees = worktrees.filter((w) => !w.isMain); - // Collapsed view - just show current branch and toggle - if (isCollapsed) { - return ( -
- - - Branch: - {selectedWorktree?.branch ?? 'main'} - {selectedWorktree?.hasChanges && ( - - {selectedWorktree.changedFilesCount ?? '!'} - - )} -
- ); - } - - // Expanded view - full worktree panel return (
- - Branch: diff --git a/apps/ui/src/components/views/dashboard-view.tsx b/apps/ui/src/components/views/dashboard-view.tsx new file mode 100644 index 00000000..b49755d0 --- /dev/null +++ b/apps/ui/src/components/views/dashboard-view.tsx @@ -0,0 +1,885 @@ +import { useState, useCallback } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { useNavigate } from '@tanstack/react-router'; +import { useAppStore, type ThemeMode } from '@/store/app-store'; +import { useOSDetection } from '@/hooks/use-os-detection'; +import { getElectronAPI, isElectron } from '@/lib/electron'; +import { initializeProject } from '@/lib/project-init'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { isMac } from '@/lib/utils'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; +import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal'; +import type { StarterTemplate } from '@/lib/templates'; +import { + FolderOpen, + Plus, + Folder, + Star, + Clock, + Loader2, + ChevronDown, + MessageSquare, + Settings, + MoreVertical, + Trash2, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +const logger = createLogger('DashboardView'); + +function getOSAbbreviation(os: string): string { + switch (os) { + case 'mac': + return 'M'; + case 'windows': + return 'W'; + case 'linux': + return 'L'; + default: + return '?'; + } +} + +export function DashboardView() { + const navigate = useNavigate(); + const { os } = useOSDetection(); + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + const appMode = import.meta.env.VITE_APP_MODE || '?'; + const versionSuffix = `${getOSAbbreviation(os)}${appMode}`; + + const { + projects, + trashedProjects, + currentProject, + upsertAndSetCurrentProject, + addProject, + setCurrentProject, + toggleProjectFavorite, + moveProjectToTrash, + theme: globalTheme, + } = useAppStore(); + + const [showNewProjectModal, setShowNewProjectModal] = useState(false); + const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [isOpening, setIsOpening] = useState(false); + const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null); + + // Sort projects: favorites first, then by last opened + const sortedProjects = [...projects].sort((a, b) => { + // Favorites first + if (a.isFavorite && !b.isFavorite) return -1; + if (!a.isFavorite && b.isFavorite) return 1; + // Then by last opened + const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0; + const dateB = b.lastOpened ? new Date(b.lastOpened).getTime() : 0; + return dateB - dateA; + }); + + const favoriteProjects = sortedProjects.filter((p) => p.isFavorite); + const recentProjects = sortedProjects.filter((p) => !p.isFavorite); + + /** + * Initialize project and navigate to board + */ + const initializeAndOpenProject = useCallback( + async (path: string, name: string) => { + setIsOpening(true); + try { + const initResult = await initializeProject(path); + + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + upsertAndSetCurrentProject(path, name, effectiveTheme); + + toast.success('Project opened', { + description: `Opened ${name}`, + }); + + navigate({ to: '/board' }); + } catch (error) { + logger.error('[Dashboard] Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsOpening(false); + } + }, + [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate] + ); + + const handleOpenProject = useCallback(async () => { + try { + const httpClient = getHttpApiClient(); + const configResult = await httpClient.workspace.getConfig(); + + if (configResult.success && configResult.configured) { + setShowWorkspacePicker(true); + } else { + const api = getElectronAPI(); + const result = await api.openDirectory(); + + if (!result.canceled && result.filePaths[0]) { + const path = result.filePaths[0]; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; + await initializeAndOpenProject(path, name); + } + } + } catch (error) { + logger.error('[Dashboard] Failed to check workspace config:', error); + const api = getElectronAPI(); + const result = await api.openDirectory(); + + if (!result.canceled && result.filePaths[0]) { + const path = result.filePaths[0]; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; + await initializeAndOpenProject(path, name); + } + } + }, [initializeAndOpenProject]); + + const handleWorkspaceSelect = useCallback( + async (path: string, name: string) => { + setShowWorkspacePicker(false); + await initializeAndOpenProject(path, name); + }, + [initializeAndOpenProject] + ); + + const handleProjectClick = useCallback( + async (project: { id: string; name: string; path: string }) => { + await initializeAndOpenProject(project.path, project.name); + }, + [initializeAndOpenProject] + ); + + const handleToggleFavorite = useCallback( + (e: React.MouseEvent, projectId: string) => { + e.stopPropagation(); + toggleProjectFavorite(projectId); + }, + [toggleProjectFavorite] + ); + + const handleRemoveProject = useCallback( + (e: React.MouseEvent, project: { id: string; name: string }) => { + e.stopPropagation(); + setProjectToRemove(project); + }, + [] + ); + + const handleConfirmRemove = useCallback(() => { + if (projectToRemove) { + moveProjectToTrash(projectToRemove.id); + toast.success('Project removed', { + description: `${projectToRemove.name} has been removed from your projects list`, + }); + setProjectToRemove(null); + } + }, [projectToRemove, moveProjectToTrash]); + + const handleNewProject = () => { + setShowNewProjectModal(true); + }; + + const handleInteractiveMode = () => { + navigate({ to: '/interview' }); + }; + + const handleCreateBlankProject = async (projectName: string, parentDir: string) => { + setIsCreating(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + const parentExists = await api.exists(parentDir); + if (!parentExists) { + toast.error('Parent directory does not exist', { + description: `Cannot create project in non-existent directory: ${parentDir}`, + }); + return; + } + + const parentStat = await api.stat(parentDir); + if (parentStat && !parentStat.stats?.isDirectory) { + toast.error('Parent path is not a directory', { + description: `${parentDir} is not a directory`, + }); + return; + } + + const mkdirResult = await api.mkdir(projectPath); + if (!mkdirResult.success) { + toast.error('Failed to create project directory', { + description: mkdirResult.error || 'Unknown error occurred', + }); + return; + } + + const initResult = await initializeProject(projectPath); + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + 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 project = { + id: `project-${Date.now()}`, + name: projectName, + path: projectPath, + lastOpened: new Date().toISOString(), + }; + + addProject(project); + setCurrentProject(project); + setShowNewProjectModal(false); + + toast.success('Project created', { + description: `Created ${projectName}`, + }); + + navigate({ to: '/board' }); + } catch (error) { + logger.error('Failed to create project:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreating(false); + } + }; + + const handleCreateFromTemplate = async ( + template: StarterTemplate, + projectName: string, + parentDir: string + ) => { + setIsCreating(true); + try { + const httpClient = getHttpApiClient(); + const api = getElectronAPI(); + + 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; + const initResult = await initializeProject(projectPath); + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + 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 project = { + id: `project-${Date.now()}`, + name: projectName, + path: projectPath, + lastOpened: new Date().toISOString(), + }; + + addProject(project); + setCurrentProject(project); + setShowNewProjectModal(false); + + toast.success('Project created from template', { + description: `Created ${projectName} from ${template.name}`, + }); + + navigate({ to: '/board' }); + } catch (error) { + logger.error('Failed to create project from template:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreating(false); + } + }; + + const handleCreateFromCustomUrl = async ( + repoUrl: string, + projectName: string, + parentDir: string + ) => { + setIsCreating(true); + try { + const httpClient = getHttpApiClient(); + const api = getElectronAPI(); + + 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; + const initResult = await initializeProject(projectPath); + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + await api.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + + + This project was cloned from ${repoUrl}. + The AI agent will analyze the project structure. + + + + + + + + + + + + + +` + ); + + const project = { + id: `project-${Date.now()}`, + name: projectName, + path: projectPath, + lastOpened: new Date().toISOString(), + }; + + addProject(project); + setCurrentProject(project); + setShowNewProjectModal(false); + + toast.success('Project created from repository', { + description: `Created ${projectName}`, + }); + + navigate({ to: '/board' }); + } catch (error) { + logger.error('Failed to create project from custom URL:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreating(false); + } + }; + + const hasProjects = projects.length > 0; + + return ( +
+ {/* Header with logo */} +
+ {/* Electron titlebar drag region */} + {isElectron() && ( +
+ + {/* Main content */} +
+
+ {/* No projects - show getting started */} + {!hasProjects && ( +
+
+

Welcome to Automaker

+

+ Your autonomous AI development studio. Get started by creating a new project or + opening an existing one. +

+
+ +
+ {/* New Project Card */} +
+
+
+
+
+ +
+
+

+ New Project +

+

+ Create a new project from scratch with AI-powered development +

+
+
+ + + + + + + + Quick Setup + + + + Interactive Mode + + + +
+
+ + {/* Open Project Card */} +
+
+
+
+
+ +
+
+

+ Open Project +

+

+ Open an existing project folder to continue working +

+
+
+ +
+
+
+
+ )} + + {/* Has projects - show project list */} + {hasProjects && ( +
+ {/* Quick actions header */} +
+

Your Projects

+
+ + + + + + + + + Quick Setup + + + + Interactive Mode + + + +
+
+ + {/* Favorites section */} + {favoriteProjects.length > 0 && ( +
+
+
+ +
+

Favorites

+
+
+ {favoriteProjects.map((project) => ( +
handleProjectClick(project)} + data-testid={`project-card-${project.id}`} + > +
+
+
+
+ +
+
+

+ {project.name} +

+

+ {project.path} +

+ {project.lastOpened && ( +

+ {new Date(project.lastOpened).toLocaleDateString()} +

+ )} +
+
+ + + + + + + handleRemoveProject(e, project)} + className="text-destructive focus:text-destructive" + > + + Remove from Automaker + + + +
+
+
+
+ ))} +
+
+ )} + + {/* Recent projects section */} + {recentProjects.length > 0 && ( +
+
+
+ +
+

Recent Projects

+
+
+ {recentProjects.map((project) => ( +
handleProjectClick(project)} + data-testid={`project-card-${project.id}`} + > +
+
+
+
+ +
+
+

+ {project.name} +

+

+ {project.path} +

+ {project.lastOpened && ( +

+ {new Date(project.lastOpened).toLocaleDateString()} +

+ )} +
+
+ + + + + + + handleRemoveProject(e, project)} + className="text-destructive focus:text-destructive" + > + + Remove from Automaker + + + +
+
+
+
+ ))} +
+
+ )} +
+ )} +
+
+ + {/* Modals */} + + + + + {/* Remove project confirmation dialog */} + !open && setProjectToRemove(null)}> + + + Remove Project + + Are you sure you want to remove {projectToRemove?.name} from + Automaker? + + +
+

+ This will only remove the project from your Automaker projects list. The project files + on your computer will not be deleted. +

+
+ + + + +
+
+ + {/* Loading overlay */} + {isOpening && ( +
+
+ +

Opening project...

+
+
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/setup-view.tsx b/apps/ui/src/components/views/setup-view.tsx index f3e9d1dd..82a73291 100644 --- a/apps/ui/src/components/views/setup-view.tsx +++ b/apps/ui/src/components/views/setup-view.tsx @@ -86,8 +86,8 @@ export function SetupView() { const handleFinish = () => { logger.debug('[Setup Flow] handleFinish called - completing setup'); completeSetup(); - logger.debug('[Setup Flow] Setup completed, redirecting to welcome view'); - navigate({ to: '/' }); + logger.debug('[Setup Flow] Setup completed, redirecting to dashboard'); + navigate({ to: '/dashboard' }); }; return ( diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index ee7a9d8c..62784f5f 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -17,6 +17,7 @@ export function useProjectSettingsLoader() { const setCardBorderEnabled = useAppStore((state) => state.setCardBorderEnabled); const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity); const setHideScrollbar = useAppStore((state) => state.setHideScrollbar); + const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible); const loadingRef = useRef(null); const currentProjectRef = useRef(null); @@ -72,6 +73,11 @@ export function useProjectSettingsLoader() { (setter as (path: string, val: typeof value) => void)(requestedProjectPath, value); } } + + // Apply worktreePanelVisible if present + if (result.settings.worktreePanelVisible !== undefined) { + setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible); + } } } catch (error) { console.error('Failed to load project settings:', error); diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 7e97832c..3449abf5 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -504,6 +504,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { path: ref.path, lastOpened: ref.lastOpened, theme: ref.theme, + isFavorite: ref.isFavorite, features: [], // Features are loaded separately when project is opened })); diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 9a746a6a..2b52a2ac 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -3003,6 +3003,7 @@ export interface Project { path: string; lastOpened?: string; theme?: string; // Per-project theme override (uses ThemeMode from app-store) + isFavorite?: boolean; // Pin project to top of dashboard } export interface TrashedProject extends Project { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 3e1e602c..c46005bd 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1951,6 +1951,7 @@ export class HttpApiClient implements ElectronAPI { cardBorderOpacity: number; hideScrollbar: boolean; }; + worktreePanelVisible?: boolean; lastSelectedSessionId?: string; }; error?: string; diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 6a883071..6ca6535c 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -84,6 +84,7 @@ function RootLayoutContent() { const isSetupRoute = location.pathname === '/setup'; const isLoginRoute = location.pathname === '/login'; const isLoggedOutRoute = location.pathname === '/logged-out'; + const isDashboardRoute = location.pathname === '/dashboard'; // Sandbox environment check state type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; @@ -389,9 +390,9 @@ function RootLayoutContent() { return; } - // Setup complete but user is still on /setup -> go to app + // Setup complete but user is still on /setup -> go to dashboard if (setupComplete && location.pathname === '/setup') { - navigate({ to: '/' }); + navigate({ to: '/dashboard' }); } }, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]); @@ -425,10 +426,16 @@ function RootLayoutContent() { testConnection(); }, [setIpcConnected]); - // Restore to board view if a project was previously open + // Redirect from welcome page based on project state useEffect(() => { - if (isMounted && currentProject && location.pathname === '/') { - navigate({ to: '/board' }); + if (isMounted && location.pathname === '/') { + if (currentProject) { + // Project is selected, go to board + navigate({ to: '/board' }); + } else { + // No project selected, go to dashboard + navigate({ to: '/dashboard' }); + } } }, [isMounted, currentProject, location.pathname, navigate]); @@ -514,6 +521,23 @@ function RootLayoutContent() { ); } + // Show dashboard page (full screen, no sidebar) - authenticated only + if (isDashboardRoute) { + return ( + <> +
+ + +
+ + + ); + } + return ( <>
diff --git a/apps/ui/src/routes/dashboard.tsx b/apps/ui/src/routes/dashboard.tsx new file mode 100644 index 00000000..e204ecf0 --- /dev/null +++ b/apps/ui/src/routes/dashboard.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { DashboardView } from '@/components/views/dashboard-view'; + +export const Route = createFileRoute('/dashboard')({ + component: DashboardView, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 54bf18eb..91dea9bd 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -656,6 +656,10 @@ export interface AppState { // Pipeline Configuration (per-project, keyed by project path) pipelineConfigByProject: Record; + // Worktree Panel Visibility (per-project, keyed by project path) + // Whether the worktree panel row is visible (default: true) + worktreePanelVisibleByProject: Record; + // UI State (previously in localStorage, now synced via API) /** Whether worktree panel is collapsed in board view */ worktreePanelCollapsed: boolean; @@ -816,6 +820,7 @@ export interface AppActions { cyclePrevProject: () => void; // Cycle back through project history (Q) cycleNextProject: () => void; // Cycle forward through project history (E) clearProjectHistory: () => void; // Clear history, keeping only current project + toggleProjectFavorite: (projectId: string) => void; // Toggle project favorite status // View actions setCurrentView: (view: ViewMode) => void; @@ -1062,6 +1067,10 @@ export interface AppActions { deletePipelineStep: (projectPath: string, stepId: string) => void; reorderPipelineSteps: (projectPath: string, stepIds: string[]) => void; + // Worktree Panel Visibility actions (per-project) + setWorktreePanelVisible: (projectPath: string, visible: boolean) => void; + getWorktreePanelVisible: (projectPath: string) => boolean; + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed: boolean) => void; setLastProjectDir: (dir: string) => void; @@ -1186,6 +1195,7 @@ const initialState: AppState = { codexModelsError: null, codexModelsLastFetched: null, pipelineConfigByProject: {}, + worktreePanelVisibleByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, lastProjectDir: '', @@ -1429,6 +1439,23 @@ export const useAppStore = create()((set, get) => ({ } }, + toggleProjectFavorite: (projectId) => { + const { projects, currentProject } = get(); + const updatedProjects = projects.map((p) => + p.id === projectId ? { ...p, isFavorite: !p.isFavorite } : p + ); + set({ projects: updatedProjects }); + // Also update currentProject if it matches + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + isFavorite: !currentProject.isFavorite, + }, + }); + } + }, + // View actions setCurrentView: (view) => set({ currentView: view }), toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }), @@ -3070,6 +3097,21 @@ export const useAppStore = create()((set, get) => ({ }); }, + // Worktree Panel Visibility actions (per-project) + setWorktreePanelVisible: (projectPath, visible) => { + set({ + worktreePanelVisibleByProject: { + ...get().worktreePanelVisibleByProject, + [projectPath]: visible, + }, + }); + }, + + getWorktreePanelVisible: (projectPath) => { + // Default to true (visible) if not set + return get().worktreePanelVisibleByProject[projectPath] ?? true; + }, + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }), diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index b1737068..3ce87a76 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -294,6 +294,8 @@ export interface ProjectRef { lastOpened?: string; /** Project-specific theme override (or undefined to use global) */ theme?: string; + /** Whether project is pinned to favorites on dashboard */ + isFavorite?: boolean; } /** @@ -595,6 +597,10 @@ export interface ProjectSettings { /** Project-specific board background settings */ boardBackground?: BoardBackgroundSettings; + // UI Visibility + /** Whether the worktree panel row is visible (default: true) */ + worktreePanelVisible?: boolean; + // Session Tracking /** Last chat session selected in this project */ lastSelectedSessionId?: string;