From c55654b73707eae4f6c80d826e87f83490e88af9 Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 21 Jan 2026 13:15:24 +0100 Subject: [PATCH 01/13] feat(ui): add Projects Overview link and button to sidebar and dashboard - Introduced a new Projects Overview link in the sidebar footer for easy navigation. - Added a button for Projects Overview in the dashboard view, enhancing accessibility to project insights. - Updated types to include project overview-related definitions, supporting the new features. --- apps/server/src/index.ts | 5 + apps/server/src/routes/projects/common.ts | 12 + apps/server/src/routes/projects/index.ts | 27 ++ .../src/routes/projects/routes/overview.ts | 297 +++++++++++++++ .../sidebar/components/sidebar-footer.tsx | 61 ++- .../src/components/views/dashboard-view.tsx | 23 ++ .../ui/src/components/views/overview-view.tsx | 283 ++++++++++++++ .../views/overview/project-status-card.tsx | 196 ++++++++++ .../views/overview/recent-activity-feed.tsx | 206 +++++++++++ .../views/overview/running-agents-panel.tsx | 127 +++++++ apps/ui/src/hooks/use-multi-project-status.ts | 121 ++++++ apps/ui/src/routes/overview.tsx | 6 + .../tests/projects/overview-dashboard.spec.ts | 350 ++++++++++++++++++ libs/types/src/index.ts | 16 + libs/types/src/project-overview.ts | 244 ++++++++++++ tests/e2e/multi-project-dashboard.spec.ts | 121 ++++++ 16 files changed, 2094 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/routes/projects/common.ts create mode 100644 apps/server/src/routes/projects/index.ts create mode 100644 apps/server/src/routes/projects/routes/overview.ts create mode 100644 apps/ui/src/components/views/overview-view.tsx create mode 100644 apps/ui/src/components/views/overview/project-status-card.tsx create mode 100644 apps/ui/src/components/views/overview/recent-activity-feed.tsx create mode 100644 apps/ui/src/components/views/overview/running-agents-panel.tsx create mode 100644 apps/ui/src/hooks/use-multi-project-status.ts create mode 100644 apps/ui/src/routes/overview.tsx create mode 100644 apps/ui/tests/projects/overview-dashboard.spec.ts create mode 100644 libs/types/src/project-overview.ts create mode 100644 tests/e2e/multi-project-dashboard.spec.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3c90fd385..ab6021dd2 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -83,6 +83,7 @@ import { createNotificationsRoutes } from './routes/notifications/index.js'; import { getNotificationService } from './services/notification-service.js'; import { createEventHistoryRoutes } from './routes/event-history/index.js'; import { getEventHistoryService } from './services/event-history-service.js'; +import { createProjectsRoutes } from './routes/projects/index.js'; // Load environment variables dotenv.config(); @@ -344,6 +345,10 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService)); app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); app.use('/api/notifications', createNotificationsRoutes(notificationService)); app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService)); +app.use( + '/api/projects', + createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService) +); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/projects/common.ts b/apps/server/src/routes/projects/common.ts new file mode 100644 index 000000000..aa06248ae --- /dev/null +++ b/apps/server/src/routes/projects/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for projects routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Projects'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/projects/index.ts b/apps/server/src/routes/projects/index.ts new file mode 100644 index 000000000..df0b558d0 --- /dev/null +++ b/apps/server/src/routes/projects/index.ts @@ -0,0 +1,27 @@ +/** + * Projects routes - HTTP API for multi-project overview and management + */ + +import { Router } from 'express'; +import { FeatureLoader } from '../../services/feature-loader.js'; +import type { AutoModeService } from '../../services/auto-mode-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import type { NotificationService } from '../../services/notification-service.js'; +import { createOverviewHandler } from './routes/overview.js'; + +export function createProjectsRoutes( + featureLoader: FeatureLoader, + autoModeService: AutoModeService, + settingsService: SettingsService, + notificationService: NotificationService +): Router { + const router = Router(); + + // GET /overview - Get aggregate status for all projects + router.get( + '/overview', + createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService) + ); + + return router; +} diff --git a/apps/server/src/routes/projects/routes/overview.ts b/apps/server/src/routes/projects/routes/overview.ts new file mode 100644 index 000000000..18f6c8b07 --- /dev/null +++ b/apps/server/src/routes/projects/routes/overview.ts @@ -0,0 +1,297 @@ +/** + * GET /overview endpoint - Get aggregate status for all projects + * + * Returns a complete overview of all projects including: + * - Individual project status (features, auto-mode state) + * - Aggregate metrics across all projects + * - Recent activity feed (placeholder for future implementation) + */ + +import type { Request, Response } from 'express'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import type { NotificationService } from '../../../services/notification-service.js'; +import type { + ProjectStatus, + AggregateStatus, + MultiProjectOverview, + FeatureStatusCounts, + AggregateFeatureCounts, + AggregateProjectCounts, + ProjectHealthStatus, + Feature, + ProjectRef, +} from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Compute feature status counts from a list of features + */ +function computeFeatureCounts(features: Feature[]): FeatureStatusCounts { + const counts: FeatureStatusCounts = { + pending: 0, + running: 0, + completed: 0, + failed: 0, + verified: 0, + }; + + for (const feature of features) { + switch (feature.status) { + case 'pending': + case 'ready': + counts.pending++; + break; + case 'running': + case 'generating_spec': + case 'waiting_approval': + counts.running++; + break; + case 'completed': + counts.completed++; + break; + case 'failed': + counts.failed++; + break; + case 'verified': + counts.verified++; + break; + default: + // Unknown status, treat as pending + counts.pending++; + } + } + + return counts; +} + +/** + * Determine the overall health status of a project based on its feature statuses + */ +function computeHealthStatus( + featureCounts: FeatureStatusCounts, + isAutoModeRunning: boolean +): ProjectHealthStatus { + const totalFeatures = + featureCounts.pending + + featureCounts.running + + featureCounts.completed + + featureCounts.failed + + featureCounts.verified; + + // If there are failed features, the project has errors + if (featureCounts.failed > 0) { + return 'error'; + } + + // If there are running features or auto mode is running with pending work + if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) { + return 'active'; + } + + // If all features are completed or verified + if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) { + return 'completed'; + } + + // Default to idle + return 'idle'; +} + +/** + * Get the most recent activity timestamp from features + */ +function getLastActivityAt(features: Feature[]): string | undefined { + if (features.length === 0) { + return undefined; + } + + let latestTimestamp: number = 0; + + for (const feature of features) { + // Check startedAt timestamp (the main timestamp available on Feature) + if (feature.startedAt) { + const timestamp = new Date(feature.startedAt).getTime(); + if (!isNaN(timestamp) && timestamp > latestTimestamp) { + latestTimestamp = timestamp; + } + } + + // Also check planSpec timestamps if available + if (feature.planSpec?.generatedAt) { + const timestamp = new Date(feature.planSpec.generatedAt).getTime(); + if (!isNaN(timestamp) && timestamp > latestTimestamp) { + latestTimestamp = timestamp; + } + } + if (feature.planSpec?.approvedAt) { + const timestamp = new Date(feature.planSpec.approvedAt).getTime(); + if (!isNaN(timestamp) && timestamp > latestTimestamp) { + latestTimestamp = timestamp; + } + } + } + + return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined; +} + +export function createOverviewHandler( + featureLoader: FeatureLoader, + autoModeService: AutoModeService, + settingsService: SettingsService, + notificationService: NotificationService +) { + return async (_req: Request, res: Response): Promise => { + try { + // Get all projects from settings + const settings = await settingsService.getGlobalSettings(); + const projectRefs: ProjectRef[] = settings.projects || []; + + // Collect project statuses in parallel + const projectStatusPromises = projectRefs.map(async (projectRef): Promise => { + try { + // Load features for this project + const features = await featureLoader.getAll(projectRef.path); + const featureCounts = computeFeatureCounts(features); + const totalFeatures = features.length; + + // Get auto-mode status for this project (main worktree, branchName = null) + const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null); + const isAutoModeRunning = autoModeStatus.isAutoLoopRunning; + + // Get notification count for this project + let unreadNotificationCount = 0; + try { + const notifications = await notificationService.getNotifications(projectRef.path); + unreadNotificationCount = notifications.filter((n) => !n.read).length; + } catch { + // Ignore notification errors - project may not have any notifications yet + } + + // Compute health status + const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning); + + // Get last activity timestamp + const lastActivityAt = getLastActivityAt(features); + + return { + projectId: projectRef.id, + projectName: projectRef.name, + projectPath: projectRef.path, + healthStatus, + featureCounts, + totalFeatures, + lastActivityAt, + isAutoModeRunning, + activeBranch: autoModeStatus.branchName ?? undefined, + unreadNotificationCount, + }; + } catch (error) { + // Return a minimal status for projects that fail to load + return { + projectId: projectRef.id, + projectName: projectRef.name, + projectPath: projectRef.path, + healthStatus: 'error' as ProjectHealthStatus, + featureCounts: { + pending: 0, + running: 0, + completed: 0, + failed: 0, + verified: 0, + }, + totalFeatures: 0, + isAutoModeRunning: false, + unreadNotificationCount: 0, + }; + } + }); + + const projectStatuses = await Promise.all(projectStatusPromises); + + // Compute aggregate metrics + const aggregateFeatureCounts: AggregateFeatureCounts = { + total: 0, + pending: 0, + running: 0, + completed: 0, + failed: 0, + verified: 0, + }; + + const aggregateProjectCounts: AggregateProjectCounts = { + total: projectStatuses.length, + active: 0, + idle: 0, + waiting: 0, + withErrors: 0, + allCompleted: 0, + }; + + let totalUnreadNotifications = 0; + let projectsWithAutoModeRunning = 0; + + for (const status of projectStatuses) { + // Aggregate feature counts + aggregateFeatureCounts.total += status.totalFeatures; + aggregateFeatureCounts.pending += status.featureCounts.pending; + aggregateFeatureCounts.running += status.featureCounts.running; + aggregateFeatureCounts.completed += status.featureCounts.completed; + aggregateFeatureCounts.failed += status.featureCounts.failed; + aggregateFeatureCounts.verified += status.featureCounts.verified; + + // Aggregate project counts by health status + switch (status.healthStatus) { + case 'active': + aggregateProjectCounts.active++; + break; + case 'idle': + aggregateProjectCounts.idle++; + break; + case 'waiting': + aggregateProjectCounts.waiting++; + break; + case 'error': + aggregateProjectCounts.withErrors++; + break; + case 'completed': + aggregateProjectCounts.allCompleted++; + break; + } + + // Aggregate notifications + totalUnreadNotifications += status.unreadNotificationCount; + + // Count projects with auto-mode running + if (status.isAutoModeRunning) { + projectsWithAutoModeRunning++; + } + } + + const aggregateStatus: AggregateStatus = { + projectCounts: aggregateProjectCounts, + featureCounts: aggregateFeatureCounts, + totalUnreadNotifications, + projectsWithAutoModeRunning, + computedAt: new Date().toISOString(), + }; + + // Build the response (recentActivity is empty for now - can be populated later) + const overview: MultiProjectOverview = { + projects: projectStatuses, + aggregate: aggregateStatus, + recentActivity: [], // Placeholder for future activity feed implementation + generatedAt: new Date().toISOString(), + }; + + res.json({ + success: true, + ...overview, + }); + } catch (error) { + logError(error, 'Get project overview failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx index 4f864eead..1f4186da3 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -1,7 +1,7 @@ import type { NavigateOptions } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; import { formatShortcut } from '@/store/app-store'; -import { Activity, Settings } from 'lucide-react'; +import { Activity, Settings, LayoutDashboard } from 'lucide-react'; interface SidebarFooterProps { sidebarOpen: boolean; @@ -32,6 +32,65 @@ export function SidebarFooter({ 'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent' )} > + {/* Projects Overview Link */} +
+ +
+ {/* Running Agents Link */} {!hideRunningAgents && (
diff --git a/apps/ui/src/components/views/dashboard-view.tsx b/apps/ui/src/components/views/dashboard-view.tsx index 872b97a85..842ba2516 100644 --- a/apps/ui/src/components/views/dashboard-view.tsx +++ b/apps/ui/src/components/views/dashboard-view.tsx @@ -24,6 +24,7 @@ import { Trash2, Search, X, + LayoutDashboard, type LucideIcon, } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; @@ -556,9 +557,31 @@ export function DashboardView() {
+ {/* Projects Overview button */} + {hasProjects && ( + + )} + {/* Mobile action buttons in header */} {hasProjects && (
+ diff --git a/apps/ui/src/components/views/overview-view.tsx b/apps/ui/src/components/views/overview-view.tsx new file mode 100644 index 000000000..823ca1327 --- /dev/null +++ b/apps/ui/src/components/views/overview-view.tsx @@ -0,0 +1,283 @@ +/** + * OverviewView - Multi-project dashboard showing status across all projects + * + * Provides a unified view of all projects with active features, running agents, + * recent completions, and alerts. Quick navigation to any project or feature. + */ + +import { useNavigate } from '@tanstack/react-router'; +import { useMultiProjectStatus } from '@/hooks/use-multi-project-status'; +import { isElectron } from '@/lib/electron'; +import { isMac } from '@/lib/utils'; +import { Spinner } from '@/components/ui/spinner'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ProjectStatusCard } from './overview/project-status-card'; +import { RecentActivityFeed } from './overview/recent-activity-feed'; +import { RunningAgentsPanel } from './overview/running-agents-panel'; +import { + LayoutDashboard, + RefreshCw, + Folder, + Activity, + CheckCircle2, + XCircle, + Clock, + Bot, + Bell, + ArrowLeft, +} from 'lucide-react'; + +export function OverviewView() { + const navigate = useNavigate(); + const { overview, isLoading, error, refresh } = useMultiProjectStatus(15000); // Refresh every 15s + + const handleBackToDashboard = () => { + navigate({ to: '/dashboard' }); + }; + + return ( +
+ {/* Header */} +
+ {/* Electron titlebar drag region */} + {isElectron() && ( +
+ + {/* Main content */} +
+ {/* Loading state */} + {isLoading && !overview && ( +
+
+ +

Loading project overview...

+
+
+ )} + + {/* Error state */} + {error && !overview && ( +
+
+
+ +
+
+

Failed to load overview

+

{error}

+ +
+
+
+ )} + + {/* Content */} + {overview && ( +
+ {/* Aggregate stats */} +
+ + +
+ +
+
+

+ {overview.aggregate.projectCounts.total} +

+

Projects

+
+
+
+ + + +
+ +
+
+

+ {overview.aggregate.featureCounts.running} +

+

Running

+
+
+
+ + + +
+ +
+
+

+ {overview.aggregate.featureCounts.pending} +

+

Pending

+
+
+
+ + + +
+ +
+
+

+ {overview.aggregate.featureCounts.completed} +

+

Completed

+
+
+
+ + + +
+ +
+
+

+ {overview.aggregate.featureCounts.failed} +

+

Failed

+
+
+
+ + + +
+ +
+
+

+ {overview.aggregate.projectsWithAutoModeRunning} +

+

Auto-mode

+
+
+
+
+ + {/* Main content grid */} +
+ {/* Left column: Project cards */} +
+
+

All Projects

+ {overview.aggregate.totalUnreadNotifications > 0 && ( +
+ + {overview.aggregate.totalUnreadNotifications} unread notifications +
+ )} +
+ + {overview.projects.length === 0 ? ( + + + +

No projects yet

+

+ Create or open a project to get started +

+ +
+
+ ) : ( +
+ {overview.projects.map((project) => ( + + ))} +
+ )} +
+ + {/* Right column: Running agents and activity */} +
+ {/* Running agents */} + + + + + Running Agents + {overview.aggregate.projectsWithAutoModeRunning > 0 && ( + + {overview.aggregate.projectsWithAutoModeRunning} active + + )} + + + + + + + + {/* Recent activity */} + + + + + Recent Activity + + + + + + +
+
+ + {/* Footer timestamp */} +
+ Last updated: {new Date(overview.generatedAt).toLocaleTimeString()} +
+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/overview/project-status-card.tsx b/apps/ui/src/components/views/overview/project-status-card.tsx new file mode 100644 index 000000000..490d880bf --- /dev/null +++ b/apps/ui/src/components/views/overview/project-status-card.tsx @@ -0,0 +1,196 @@ +/** + * ProjectStatusCard - Individual project card for multi-project dashboard + * + * Displays project health, feature counts, and agent status with quick navigation. + */ + +import { useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useAppStore } from '@/store/app-store'; +import { initializeProject } from '@/lib/project-init'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import type { ProjectStatus, ProjectHealthStatus } from '@automaker/types'; +import { Folder, Activity, CheckCircle2, XCircle, Clock, Pause, Bot, Bell } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; + +interface ProjectStatusCardProps { + project: ProjectStatus; + onProjectClick?: (projectId: string) => void; +} + +const healthStatusConfig: Record< + ProjectHealthStatus, + { icon: typeof Activity; color: string; label: string; bgColor: string } +> = { + active: { + icon: Activity, + color: 'text-green-500', + label: 'Active', + bgColor: 'bg-green-500/10', + }, + idle: { + icon: Pause, + color: 'text-muted-foreground', + label: 'Idle', + bgColor: 'bg-muted/50', + }, + waiting: { + icon: Clock, + color: 'text-yellow-500', + label: 'Waiting', + bgColor: 'bg-yellow-500/10', + }, + completed: { + icon: CheckCircle2, + color: 'text-blue-500', + label: 'Completed', + bgColor: 'bg-blue-500/10', + }, + error: { + icon: XCircle, + color: 'text-red-500', + label: 'Error', + bgColor: 'bg-red-500/10', + }, +}; + +export function ProjectStatusCard({ project, onProjectClick }: ProjectStatusCardProps) { + const navigate = useNavigate(); + const { upsertAndSetCurrentProject } = useAppStore(); + + const statusConfig = healthStatusConfig[project.healthStatus]; + const StatusIcon = statusConfig.icon; + + const handleClick = useCallback(async () => { + if (onProjectClick) { + onProjectClick(project.projectId); + return; + } + + // Default behavior: navigate to project + try { + const initResult = await initializeProject(project.projectPath); + if (!initResult.success) { + toast.error('Failed to open project', { + description: initResult.error || 'Unknown error', + }); + return; + } + + upsertAndSetCurrentProject(project.projectPath, project.projectName); + navigate({ to: '/board' }); + } catch (error) { + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, [project, onProjectClick, upsertAndSetCurrentProject, navigate]); + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {project.projectName} +

+

{project.projectPath}

+
+
+ + {/* Status badge */} +
+ {project.unreadNotificationCount > 0 && ( + + + {project.unreadNotificationCount} + + )} + + + {statusConfig.label} + +
+
+ + {/* Feature counts */} +
+ {project.featureCounts.running > 0 && ( +
+ + {project.featureCounts.running} running +
+ )} + {project.featureCounts.pending > 0 && ( +
+ + {project.featureCounts.pending} pending +
+ )} + {project.featureCounts.completed > 0 && ( +
+ + {project.featureCounts.completed} completed +
+ )} + {project.featureCounts.failed > 0 && ( +
+ + {project.featureCounts.failed} failed +
+ )} + {project.featureCounts.verified > 0 && ( +
+ + {project.featureCounts.verified} verified +
+ )} +
+ + {/* Footer: Total features and auto-mode status */} +
+ {project.totalFeatures} total features + {project.isAutoModeRunning && ( +
+ + Auto-mode active +
+ )} + {project.lastActivityAt && !project.isAutoModeRunning && ( + Last activity: {new Date(project.lastActivityAt).toLocaleDateString()} + )} +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/overview/recent-activity-feed.tsx b/apps/ui/src/components/views/overview/recent-activity-feed.tsx new file mode 100644 index 000000000..0f797a1cc --- /dev/null +++ b/apps/ui/src/components/views/overview/recent-activity-feed.tsx @@ -0,0 +1,206 @@ +/** + * RecentActivityFeed - Timeline of recent activity across all projects + * + * Shows completed features, failures, and auto-mode events. + */ + +import { useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useAppStore } from '@/store/app-store'; +import { initializeProject } from '@/lib/project-init'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import type { RecentActivity, ActivityType, ActivitySeverity } from '@automaker/types'; +import { CheckCircle2, XCircle, Play, Bot, AlertTriangle, Info, Clock } from 'lucide-react'; + +interface RecentActivityFeedProps { + activities: RecentActivity[]; + maxItems?: number; +} + +const activityTypeConfig: Record< + ActivityType, + { icon: typeof CheckCircle2; defaultColor: string; label: string } +> = { + feature_created: { + icon: Info, + defaultColor: 'text-blue-500', + label: 'Feature created', + }, + feature_completed: { + icon: CheckCircle2, + defaultColor: 'text-blue-500', + label: 'Feature completed', + }, + feature_verified: { + icon: CheckCircle2, + defaultColor: 'text-purple-500', + label: 'Feature verified', + }, + feature_failed: { + icon: XCircle, + defaultColor: 'text-red-500', + label: 'Feature failed', + }, + feature_started: { + icon: Play, + defaultColor: 'text-green-500', + label: 'Feature started', + }, + auto_mode_started: { + icon: Bot, + defaultColor: 'text-green-500', + label: 'Auto-mode started', + }, + auto_mode_stopped: { + icon: Bot, + defaultColor: 'text-muted-foreground', + label: 'Auto-mode stopped', + }, + ideation_session_started: { + icon: Play, + defaultColor: 'text-brand-500', + label: 'Ideation session started', + }, + ideation_session_ended: { + icon: Info, + defaultColor: 'text-muted-foreground', + label: 'Ideation session ended', + }, + idea_created: { + icon: Info, + defaultColor: 'text-brand-500', + label: 'Idea created', + }, + idea_converted: { + icon: CheckCircle2, + defaultColor: 'text-green-500', + label: 'Idea converted to feature', + }, + notification_created: { + icon: AlertTriangle, + defaultColor: 'text-yellow-500', + label: 'Notification', + }, + project_opened: { + icon: Info, + defaultColor: 'text-blue-500', + label: 'Project opened', + }, +}; + +const severityColors: Record = { + info: 'text-blue-500', + success: 'text-green-500', + warning: 'text-yellow-500', + error: 'text-red-500', +}; + +function formatRelativeTime(timestamp: string): string { + const now = new Date(); + const date = new Date(timestamp); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +export function RecentActivityFeed({ activities, maxItems = 10 }: RecentActivityFeedProps) { + const navigate = useNavigate(); + const { upsertAndSetCurrentProject } = useAppStore(); + + const displayActivities = activities.slice(0, maxItems); + + const handleActivityClick = useCallback( + async (activity: RecentActivity) => { + try { + const initResult = await initializeProject( + // We need to find the project path - use projectId as workaround + // In real implementation, this would look up the path from projects list + activity.projectId + ); + + // Navigate to the project + const projectPath = activity.projectId; + const projectName = activity.projectName; + + upsertAndSetCurrentProject(projectPath, projectName); + + if (activity.featureId) { + // Navigate to the specific feature + navigate({ to: '/board', search: { featureId: activity.featureId } }); + } else { + navigate({ to: '/board' }); + } + } catch (error) { + toast.error('Failed to navigate to activity', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + [navigate, upsertAndSetCurrentProject] + ); + + if (displayActivities.length === 0) { + return ( +
+ +

No recent activity

+
+ ); + } + + return ( +
+ {displayActivities.map((activity) => { + const config = activityTypeConfig[activity.type]; + const Icon = config.icon; + const iconColor = severityColors[activity.severity] || config.defaultColor; + + return ( +
handleActivityClick(activity)} + data-testid={`activity-item-${activity.id}`} + > + {/* Icon */} +
+ +
+ + {/* Content */} +
+
+ + {activity.projectName} + + + {formatRelativeTime(activity.timestamp)} + +
+

+ {activity.featureTitle || activity.description} +

+

{config.label}

+
+
+ ); + })} +
+ ); +} diff --git a/apps/ui/src/components/views/overview/running-agents-panel.tsx b/apps/ui/src/components/views/overview/running-agents-panel.tsx new file mode 100644 index 000000000..e2d9413ce --- /dev/null +++ b/apps/ui/src/components/views/overview/running-agents-panel.tsx @@ -0,0 +1,127 @@ +/** + * RunningAgentsPanel - Shows all currently running agents across projects + * + * Displays active AI agents with their status and quick access to features. + */ + +import { useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useAppStore } from '@/store/app-store'; +import { initializeProject } from '@/lib/project-init'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import type { ProjectStatus } from '@automaker/types'; +import { Bot, Activity, Folder, ArrowRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface RunningAgentsPanelProps { + projects: ProjectStatus[]; +} + +interface RunningAgent { + projectId: string; + projectName: string; + projectPath: string; + featureCount: number; + isAutoMode: boolean; + activeBranch?: string; +} + +export function RunningAgentsPanel({ projects }: RunningAgentsPanelProps) { + const navigate = useNavigate(); + const { upsertAndSetCurrentProject } = useAppStore(); + + // Extract running agents from projects + const runningAgents: RunningAgent[] = projects + .filter((p) => p.isAutoModeRunning || p.featureCounts.running > 0) + .map((p) => ({ + projectId: p.projectId, + projectName: p.projectName, + projectPath: p.projectPath, + featureCount: p.featureCounts.running, + isAutoMode: p.isAutoModeRunning, + activeBranch: p.activeBranch, + })); + + const handleAgentClick = useCallback( + async (agent: RunningAgent) => { + try { + const initResult = await initializeProject(agent.projectPath); + if (!initResult.success) { + toast.error('Failed to open project', { + description: initResult.error || 'Unknown error', + }); + return; + } + + upsertAndSetCurrentProject(agent.projectPath, agent.projectName); + navigate({ to: '/board' }); + } catch (error) { + toast.error('Failed to navigate to agent', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + [navigate, upsertAndSetCurrentProject] + ); + + if (runningAgents.length === 0) { + return ( +
+ +

No agents running

+

Start auto-mode on a project to see activity here

+
+ ); + } + + return ( +
+ {runningAgents.map((agent) => ( +
handleAgentClick(agent)} + data-testid={`running-agent-${agent.projectId}`} + > + {/* Animated icon */} +
+ + +
+ + {/* Content */} +
+
+ + {agent.projectName} + + {agent.isAutoMode && ( + + Auto + + )} +
+
+ {agent.featureCount > 0 && ( + + + {agent.featureCount} feature{agent.featureCount !== 1 ? 's' : ''} running + + )} + {agent.activeBranch && ( + + + {agent.activeBranch} + + )} +
+
+ + {/* Arrow */} + +
+ ))} +
+ ); +} diff --git a/apps/ui/src/hooks/use-multi-project-status.ts b/apps/ui/src/hooks/use-multi-project-status.ts new file mode 100644 index 000000000..ab377795d --- /dev/null +++ b/apps/ui/src/hooks/use-multi-project-status.ts @@ -0,0 +1,121 @@ +/** + * Hook for fetching multi-project overview data + * + * Provides real-time status across all projects for the unified dashboard. + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { MultiProjectOverview } from '@automaker/types'; +import { createLogger } from '@automaker/utils/logger'; +import { + getApiKey, + getSessionToken, + waitForApiKeyInit, + getServerUrlSync, +} from '@/lib/http-api-client'; + +const logger = createLogger('useMultiProjectStatus'); + +interface UseMultiProjectStatusResult { + overview: MultiProjectOverview | null; + isLoading: boolean; + error: string | null; + refresh: () => Promise; +} + +/** + * Custom fetch function for projects overview + * Uses the same pattern as HttpApiClient for proper authentication + */ +async function fetchProjectsOverview(): Promise { + // Ensure API key is initialized before making request (handles Electron/web mode timing) + await waitForApiKeyInit(); + + const serverUrl = getServerUrlSync(); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Electron mode: use API key + const apiKey = getApiKey(); + if (apiKey) { + headers['X-API-Key'] = apiKey; + } else { + // Web mode: use session token if available + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + } + + const response = await fetch(`${serverUrl}/api/projects/overview`, { + method: 'GET', + headers, + credentials: 'include', // Include cookies for session auth + cache: 'no-store', + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error?.message || 'Failed to fetch project overview'); + } + + return { + projects: data.projects, + aggregate: data.aggregate, + recentActivity: data.recentActivity, + generatedAt: data.generatedAt, + }; +} + +/** + * Hook to fetch and manage multi-project overview data + * + * @param refreshInterval - Optional interval in ms to auto-refresh (default: 30000) + */ +export function useMultiProjectStatus(refreshInterval = 30000): UseMultiProjectStatusResult { + const [overview, setOverview] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setError(null); + const data = await fetchProjectsOverview(); + setOverview(data); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch overview'; + logger.error('Failed to fetch project overview:', err); + setError(errorMessage); + } finally { + setIsLoading(false); + } + }, []); + + // Initial fetch + useEffect(() => { + refresh(); + }, [refresh]); + + // Auto-refresh interval + useEffect(() => { + if (refreshInterval <= 0) return; + + const intervalId = setInterval(refresh, refreshInterval); + return () => clearInterval(intervalId); + }, [refresh, refreshInterval]); + + return { + overview, + isLoading, + error, + refresh, + }; +} diff --git a/apps/ui/src/routes/overview.tsx b/apps/ui/src/routes/overview.tsx new file mode 100644 index 000000000..ecb51ef3e --- /dev/null +++ b/apps/ui/src/routes/overview.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { OverviewView } from '@/components/views/overview-view'; + +export const Route = createFileRoute('/overview')({ + component: OverviewView, +}); diff --git a/apps/ui/tests/projects/overview-dashboard.spec.ts b/apps/ui/tests/projects/overview-dashboard.spec.ts new file mode 100644 index 000000000..7b570c256 --- /dev/null +++ b/apps/ui/tests/projects/overview-dashboard.spec.ts @@ -0,0 +1,350 @@ +/** + * Projects Overview Dashboard End-to-End Test + * + * Tests the multi-project overview dashboard that shows status across all projects. + * This verifies that: + * 1. The overview view can be accessed via the sidebar + * 2. The overview displays aggregate statistics + * 3. Navigation back to dashboard works correctly + * 4. The UI responds to API data correctly + */ + +import { test, expect } from '@playwright/test'; +import { + setupMockMultipleProjects, + authenticateForTests, + handleLoginScreenIfPresent, +} from '../utils'; + +test.describe('Projects Overview Dashboard', () => { + test.beforeEach(async ({ page }) => { + // Set up mock projects state + await setupMockMultipleProjects(page, 3); + await authenticateForTests(page); + }); + + test('should navigate to overview from sidebar and display overview UI', async ({ page }) => { + // Go to the app + await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + + // Wait for the board view to load + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); + + // Expand sidebar if collapsed + const expandSidebarButton = page.locator('button:has-text("Expand sidebar")'); + if (await expandSidebarButton.isVisible()) { + await expandSidebarButton.click(); + await page.waitForTimeout(300); + } + + // Click on the Projects Overview link in the sidebar + const overviewLink = page.locator('[data-testid="projects-overview-link"]'); + await expect(overviewLink).toBeVisible({ timeout: 5000 }); + await overviewLink.click(); + + // Wait for the overview view to appear + await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 }); + + // Verify the header is visible with title + await expect(page.getByText('Projects Overview')).toBeVisible({ timeout: 5000 }); + + // Verify the refresh button is present + await expect(page.getByRole('button', { name: /Refresh/i })).toBeVisible(); + + // Verify the back button is present (navigates to dashboard) + const backButton = page + .locator('button') + .filter({ has: page.locator('svg') }) + .first(); + await expect(backButton).toBeVisible(); + }); + + test('should display aggregate statistics cards', async ({ page }) => { + // Mock the projects overview API response + await page.route('**/api/projects/overview', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + projects: [ + { + projectId: 'test-project-1', + projectName: 'Test Project 1', + projectPath: '/mock/test-project-1', + healthStatus: 'active', + featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 }, + totalFeatures: 8, + isAutoModeRunning: true, + unreadNotificationCount: 1, + }, + { + projectId: 'test-project-2', + projectName: 'Test Project 2', + projectPath: '/mock/test-project-2', + healthStatus: 'idle', + featureCounts: { pending: 5, running: 0, completed: 10, failed: 1, verified: 8 }, + totalFeatures: 24, + isAutoModeRunning: false, + unreadNotificationCount: 0, + }, + ], + aggregate: { + projectCounts: { + total: 2, + active: 1, + idle: 1, + waiting: 0, + withErrors: 1, + allCompleted: 0, + }, + featureCounts: { + total: 32, + pending: 7, + running: 1, + completed: 13, + failed: 1, + verified: 10, + }, + totalUnreadNotifications: 1, + projectsWithAutoModeRunning: 1, + computedAt: new Date().toISOString(), + }, + recentActivity: [ + { + id: 'activity-1', + projectId: 'test-project-1', + projectName: 'Test Project 1', + type: 'feature_completed', + description: 'Feature completed: Add login form', + severity: 'success', + timestamp: new Date().toISOString(), + featureId: 'feature-1', + featureTitle: 'Add login form', + }, + ], + generatedAt: new Date().toISOString(), + }), + }); + }); + + // Navigate directly to overview + await page.goto('/overview'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + + // Wait for the overview view to appear + await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 }); + + // Verify aggregate stat cards are displayed + // Projects count card + await expect(page.getByText('Projects').first()).toBeVisible({ timeout: 10000 }); + + // Running features card + await expect(page.getByText('Running').first()).toBeVisible(); + + // Pending features card + await expect(page.getByText('Pending').first()).toBeVisible(); + + // Completed features card + await expect(page.getByText('Completed').first()).toBeVisible(); + + // Auto-mode card + await expect(page.getByText('Auto-mode').first()).toBeVisible(); + }); + + test('should display project status cards', async ({ page }) => { + // Mock the projects overview API response + await page.route('**/api/projects/overview', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + projects: [ + { + projectId: 'test-project-1', + projectName: 'Test Project 1', + projectPath: '/mock/test-project-1', + healthStatus: 'active', + featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 }, + totalFeatures: 8, + isAutoModeRunning: true, + unreadNotificationCount: 1, + }, + ], + aggregate: { + projectCounts: { + total: 1, + active: 1, + idle: 0, + waiting: 0, + withErrors: 0, + allCompleted: 0, + }, + featureCounts: { + total: 8, + pending: 2, + running: 1, + completed: 3, + failed: 0, + verified: 2, + }, + totalUnreadNotifications: 1, + projectsWithAutoModeRunning: 1, + computedAt: new Date().toISOString(), + }, + recentActivity: [], + generatedAt: new Date().toISOString(), + }), + }); + }); + + // Navigate directly to overview + await page.goto('/overview'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + + // Wait for the overview view to appear + await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 }); + + // Verify project status card is displayed + const projectCard = page.locator('[data-testid="project-status-card-test-project-1"]'); + await expect(projectCard).toBeVisible({ timeout: 10000 }); + + // Verify project name is displayed + await expect(projectCard.getByText('Test Project 1')).toBeVisible(); + + // Verify the Active status badge + await expect(projectCard.getByText('Active')).toBeVisible(); + + // Verify auto-mode indicator is shown + await expect(projectCard.getByText('Auto-mode active')).toBeVisible(); + }); + + test('should navigate back to dashboard when clicking back button', async ({ page }) => { + // Mock the projects overview API response + await page.route('**/api/projects/overview', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + projects: [], + aggregate: { + projectCounts: { + total: 0, + active: 0, + idle: 0, + waiting: 0, + withErrors: 0, + allCompleted: 0, + }, + featureCounts: { + total: 0, + pending: 0, + running: 0, + completed: 0, + failed: 0, + verified: 0, + }, + totalUnreadNotifications: 0, + projectsWithAutoModeRunning: 0, + computedAt: new Date().toISOString(), + }, + recentActivity: [], + generatedAt: new Date().toISOString(), + }), + }); + }); + + // Navigate directly to overview + await page.goto('/overview'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + + // Wait for the overview view to appear + await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 }); + + // Click the back button (first button in the header with ArrowLeft icon) + const backButton = page.locator('[data-testid="overview-view"] header button').first(); + await backButton.click(); + + // Wait for navigation to dashboard + await expect(page.locator('[data-testid="dashboard-view"]')).toBeVisible({ timeout: 15000 }); + }); + + test('should display empty state when no projects exist', async ({ page }) => { + // Mock empty projects overview API response + await page.route('**/api/projects/overview', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + projects: [], + aggregate: { + projectCounts: { + total: 0, + active: 0, + idle: 0, + waiting: 0, + withErrors: 0, + allCompleted: 0, + }, + featureCounts: { + total: 0, + pending: 0, + running: 0, + completed: 0, + failed: 0, + verified: 0, + }, + totalUnreadNotifications: 0, + projectsWithAutoModeRunning: 0, + computedAt: new Date().toISOString(), + }, + recentActivity: [], + generatedAt: new Date().toISOString(), + }), + }); + }); + + // Navigate directly to overview + await page.goto('/overview'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + + // Wait for the overview view to appear + await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 }); + + // Verify empty state message + await expect(page.getByText('No projects yet')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Create or open a project to get started')).toBeVisible(); + }); + + test('should show error state when API fails', async ({ page }) => { + // Mock API error + await page.route('**/api/projects/overview', async (route) => { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ + error: 'Internal server error', + }), + }); + }); + + // Navigate directly to overview + await page.goto('/overview'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + + // Wait for the overview view to appear + await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 }); + + // Verify error state message + await expect(page.getByText('Failed to load overview')).toBeVisible({ timeout: 10000 }); + + // Verify the "Try again" button is visible + await expect(page.getByRole('button', { name: /Try again/i })).toBeVisible(); + }); +}); diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a8f2644db..6060e0d8f 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -329,3 +329,19 @@ export { PR_STATES, validatePRState } from './worktree.js'; // Terminal types export type { TerminalInfo } from './terminal.js'; + +// Project overview types (multi-project dashboard) +export type { + ProjectHealthStatus, + FeatureStatusCounts, + ProjectStatus, + AggregateFeatureCounts, + AggregateProjectCounts, + AggregateStatus, + ActivityType, + ActivitySeverity, + RecentActivity, + ActivityFeedOptions, + MultiProjectOverview, + ProjectOverviewError, +} from './project-overview.js'; diff --git a/libs/types/src/project-overview.ts b/libs/types/src/project-overview.ts new file mode 100644 index 000000000..4c1bd5570 --- /dev/null +++ b/libs/types/src/project-overview.ts @@ -0,0 +1,244 @@ +/** + * Project Overview Types - Multi-project dashboard data structures + * + * Defines types for aggregating and displaying status across multiple projects, + * including individual project health, aggregate metrics, and recent activity feeds. + * Used by the multi-project overview dashboard for at-a-glance monitoring. + */ + +// ============================================================================ +// Project Status Types +// ============================================================================ + +/** + * ProjectHealthStatus - Overall health indicator for a project + * + * Represents the computed health state based on feature statuses: + * - idle: No active work, all features are pending or completed + * - active: Features are currently running or in progress + * - waiting: Features are waiting for user approval or input + * - error: One or more features have failed + * - completed: All features have been completed successfully + */ +export type ProjectHealthStatus = 'idle' | 'active' | 'waiting' | 'error' | 'completed'; + +/** + * FeatureStatusCounts - Breakdown of features by status + * + * Provides counts for each feature status to show progress at a glance. + */ +export interface FeatureStatusCounts { + /** Number of features waiting to be started */ + pending: number; + /** Number of features currently executing */ + running: number; + /** Number of features that completed successfully */ + completed: number; + /** Number of features that encountered errors */ + failed: number; + /** Number of features that passed verification */ + verified: number; +} + +/** + * ProjectStatus - Status summary for an individual project + * + * Contains all information needed to display a project's current state + * in the multi-project overview dashboard. + */ +export interface ProjectStatus { + /** Project unique identifier (matches ProjectRef.id) */ + projectId: string; + /** Project display name */ + projectName: string; + /** Absolute filesystem path to project */ + projectPath: string; + /** Computed overall health status */ + healthStatus: ProjectHealthStatus; + /** Breakdown of features by status */ + featureCounts: FeatureStatusCounts; + /** Total number of features in the project */ + totalFeatures: number; + /** ISO timestamp of last activity in this project */ + lastActivityAt?: string; + /** Whether auto-mode is currently running */ + isAutoModeRunning: boolean; + /** Name of the currently active branch (if in a worktree) */ + activeBranch?: string; + /** Number of unread notifications for this project */ + unreadNotificationCount: number; + /** Extensibility for future properties */ + [key: string]: unknown; +} + +// ============================================================================ +// Aggregate Status Types +// ============================================================================ + +/** + * AggregateFeatureCounts - Total feature counts across all projects + */ +export interface AggregateFeatureCounts { + /** Total features across all projects */ + total: number; + /** Total pending features */ + pending: number; + /** Total running features */ + running: number; + /** Total completed features */ + completed: number; + /** Total failed features */ + failed: number; + /** Total verified features */ + verified: number; +} + +/** + * AggregateProjectCounts - Project counts by health status + */ +export interface AggregateProjectCounts { + /** Total number of projects */ + total: number; + /** Projects with active work */ + active: number; + /** Projects in idle state */ + idle: number; + /** Projects waiting for input */ + waiting: number; + /** Projects with errors */ + withErrors: number; + /** Projects with all work completed */ + allCompleted: number; +} + +/** + * AggregateStatus - Summary metrics across all projects + * + * Provides a bird's-eye view of work status across the entire workspace, + * useful for dashboard headers and summary widgets. + */ +export interface AggregateStatus { + /** Counts of projects by health status */ + projectCounts: AggregateProjectCounts; + /** Aggregate feature counts across all projects */ + featureCounts: AggregateFeatureCounts; + /** Total unread notifications across all projects */ + totalUnreadNotifications: number; + /** Number of projects with auto-mode running */ + projectsWithAutoModeRunning: number; + /** ISO timestamp when this aggregate was computed */ + computedAt: string; + /** Extensibility for future properties */ + [key: string]: unknown; +} + +// ============================================================================ +// Recent Activity Types +// ============================================================================ + +/** + * ActivityType - Types of activities that can appear in the activity feed + * + * Maps to significant events that users would want to see in an overview. + */ +export type ActivityType = + | 'feature_created' + | 'feature_started' + | 'feature_completed' + | 'feature_failed' + | 'feature_verified' + | 'auto_mode_started' + | 'auto_mode_stopped' + | 'ideation_session_started' + | 'ideation_session_ended' + | 'idea_created' + | 'idea_converted' + | 'notification_created' + | 'project_opened'; + +/** + * ActivitySeverity - Visual importance level for activity items + */ +export type ActivitySeverity = 'info' | 'success' | 'warning' | 'error'; + +/** + * RecentActivity - A single activity entry for the activity feed + * + * Represents a notable event that occurred in a project, displayed + * in chronological order in the activity feed widget. + */ +export interface RecentActivity { + /** Unique identifier for this activity entry */ + id: string; + /** Project this activity belongs to */ + projectId: string; + /** Project display name (denormalized for display) */ + projectName: string; + /** Type of activity */ + type: ActivityType; + /** Human-readable description of what happened */ + description: string; + /** Visual importance level */ + severity: ActivitySeverity; + /** ISO timestamp when the activity occurred */ + timestamp: string; + /** Related feature ID if applicable */ + featureId?: string; + /** Related feature title if applicable */ + featureTitle?: string; + /** Related ideation session ID if applicable */ + sessionId?: string; + /** Related idea ID if applicable */ + ideaId?: string; + /** Extensibility for future properties */ + [key: string]: unknown; +} + +/** + * ActivityFeedOptions - Options for fetching activity feed + */ +export interface ActivityFeedOptions { + /** Maximum number of activities to return */ + limit?: number; + /** Filter to specific project IDs */ + projectIds?: string[]; + /** Filter to specific activity types */ + types?: ActivityType[]; + /** Only return activities after this ISO timestamp */ + since?: string; + /** Only return activities before this ISO timestamp */ + until?: string; +} + +// ============================================================================ +// Multi-Project Overview Response Types +// ============================================================================ + +/** + * MultiProjectOverview - Complete overview data for the dashboard + * + * Contains all data needed to render the multi-project overview page, + * including individual project statuses, aggregate metrics, and recent activity. + */ +export interface MultiProjectOverview { + /** Individual status for each project */ + projects: ProjectStatus[]; + /** Aggregate metrics across all projects */ + aggregate: AggregateStatus; + /** Recent activity feed (sorted by timestamp, most recent first) */ + recentActivity: RecentActivity[]; + /** ISO timestamp when this overview was generated */ + generatedAt: string; +} + +/** + * ProjectOverviewError - Error response for overview requests + */ +export interface ProjectOverviewError { + /** Error code for programmatic handling */ + code: 'PROJECTS_NOT_FOUND' | 'PERMISSION_DENIED' | 'INTERNAL_ERROR'; + /** Human-readable error message */ + message: string; + /** Project IDs that failed to load, if applicable */ + failedProjectIds?: string[]; +} diff --git a/tests/e2e/multi-project-dashboard.spec.ts b/tests/e2e/multi-project-dashboard.spec.ts new file mode 100644 index 000000000..e4d8bbc05 --- /dev/null +++ b/tests/e2e/multi-project-dashboard.spec.ts @@ -0,0 +1,121 @@ +/** + * Multi-Project Dashboard E2E Tests + * + * Verifies the unified dashboard showing status across all projects. + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Multi-Project Dashboard', () => { + test.beforeEach(async ({ page }) => { + // Navigate to the dashboard first + await page.goto('/dashboard'); + await expect(page.getByTestId('dashboard-view')).toBeVisible(); + }); + + test('should navigate to overview from dashboard when projects exist', async ({ page }) => { + // Check if the overview button is visible (only shows when projects exist) + const overviewButton = page.getByTestId('projects-overview-button'); + + // If there are projects, the button should be visible + if (await overviewButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await overviewButton.click(); + + // Should navigate to overview page + await expect(page).toHaveURL(/\/overview/); + await expect(page.getByTestId('overview-view')).toBeVisible(); + } else { + // No projects - overview button won't be shown + test + .info() + .annotations.push({ + type: 'info', + description: 'No projects available - skipping overview navigation test', + }); + } + }); + + test('should display overview view with correct structure', async ({ page }) => { + // Navigate directly to overview + await page.goto('/overview'); + + // Wait for the overview view to load + const overviewView = page.getByTestId('overview-view'); + + // The view should be visible (even if loading) + await expect(overviewView).toBeVisible({ timeout: 5000 }); + + // Should have a back button to return to dashboard + const backButton = page + .locator('button') + .filter({ has: page.locator('svg.lucide-arrow-left') }); + await expect(backButton).toBeVisible(); + + // Click back to return to dashboard + await backButton.click(); + await expect(page).toHaveURL(/\/dashboard/); + }); + + test('should show loading state and then content or empty state', async ({ page }) => { + await page.goto('/overview'); + + // Should show the view + const overviewView = page.getByTestId('overview-view'); + await expect(overviewView).toBeVisible({ timeout: 5000 }); + + // Wait for loading to complete (either shows content or error) + await page.waitForTimeout(2000); + + // After loading, should show either: + // 1. Project cards if projects exist + // 2. Empty state message if no projects + // 3. Error message if API failed + const hasProjects = (await page.locator('[data-testid^="project-status-card-"]').count()) > 0; + const hasEmptyState = await page + .getByText('No projects yet') + .isVisible() + .catch(() => false); + const hasError = await page + .getByText('Failed to load overview') + .isVisible() + .catch(() => false); + + // At least one of these should be true + expect(hasProjects || hasEmptyState || hasError).toBeTruthy(); + }); + + test('should have overview link in sidebar footer', async ({ page }) => { + // First open a project to see the sidebar + await page.goto('/overview'); + + // The overview link should be in the sidebar footer + const sidebarOverviewLink = page.getByTestId('projects-overview-link'); + + if (await sidebarOverviewLink.isVisible({ timeout: 3000 }).catch(() => false)) { + // Should be clickable + await sidebarOverviewLink.click(); + await expect(page).toHaveURL(/\/overview/); + } + }); + + test('should refresh data when refresh button is clicked', async ({ page }) => { + await page.goto('/overview'); + + // Wait for initial load + await page.waitForTimeout(1000); + + // Find the refresh button + const refreshButton = page.locator('button').filter({ hasText: 'Refresh' }); + + if (await refreshButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await refreshButton.click(); + + // The button should show loading state (spinner icon) + // Wait a moment for the refresh to complete + await page.waitForTimeout(1000); + + // Page should still be on overview + await expect(page).toHaveURL(/\/overview/); + } + }); +}); From 02dfda108ec70708c35f43e15b359acfa7296dd9 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Tue, 20 Jan 2026 22:54:13 +0100 Subject: [PATCH 02/13] feat(ui): add unified sidebar component Add new unified-sidebar component for layout improvements. - Export UnifiedSidebar from layout components - Update root route to use new sidebar structure --- apps/ui/src/components/layout/index.ts | 1 + .../unified-sidebar/components/index.ts | 2 + .../components/sidebar-footer.tsx | 372 ++++++++++++++ .../components/sidebar-header.tsx | 349 +++++++++++++ .../layout/unified-sidebar/index.ts | 1 + .../unified-sidebar/unified-sidebar.tsx | 479 ++++++++++++++++++ apps/ui/src/routes/__root.tsx | 15 +- 7 files changed, 1207 insertions(+), 12 deletions(-) create mode 100644 apps/ui/src/components/layout/unified-sidebar/components/index.ts create mode 100644 apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx create mode 100644 apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx create mode 100644 apps/ui/src/components/layout/unified-sidebar/index.ts create mode 100644 apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx diff --git a/apps/ui/src/components/layout/index.ts b/apps/ui/src/components/layout/index.ts index bfed62466..d702d78de 100644 --- a/apps/ui/src/components/layout/index.ts +++ b/apps/ui/src/components/layout/index.ts @@ -1 +1,2 @@ export { Sidebar } from './sidebar'; +export { UnifiedSidebar } from './unified-sidebar'; diff --git a/apps/ui/src/components/layout/unified-sidebar/components/index.ts b/apps/ui/src/components/layout/unified-sidebar/components/index.ts new file mode 100644 index 000000000..42f3195fc --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/components/index.ts @@ -0,0 +1,2 @@ +export { SidebarHeader } from './sidebar-header'; +export { SidebarFooter } from './sidebar-footer'; diff --git a/apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx new file mode 100644 index 000000000..1c8bcc8ef --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx @@ -0,0 +1,372 @@ +import { useCallback } from 'react'; +import type { NavigateOptions } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; +import { Activity, Settings, User, Bug, BookOpen, ExternalLink } from 'lucide-react'; +import { useOSDetection } from '@/hooks/use-os-detection'; +import { getElectronAPI } from '@/lib/electron'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +function getOSAbbreviation(os: string): string { + switch (os) { + case 'mac': + return 'M'; + case 'windows': + return 'W'; + case 'linux': + return 'L'; + default: + return '?'; + } +} + +interface SidebarFooterProps { + sidebarOpen: boolean; + isActiveRoute: (id: string) => boolean; + navigate: (opts: NavigateOptions) => void; + hideRunningAgents: boolean; + hideWiki: boolean; + runningAgentsCount: number; + shortcuts: { + settings: string; + }; +} + +export function SidebarFooter({ + sidebarOpen, + isActiveRoute, + navigate, + hideRunningAgents, + hideWiki, + runningAgentsCount, + shortcuts, +}: SidebarFooterProps) { + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + const { os } = useOSDetection(); + const appMode = import.meta.env.VITE_APP_MODE || '?'; + const versionSuffix = `${getOSAbbreviation(os)}${appMode}`; + + const handleWikiClick = useCallback(() => { + navigate({ to: '/wiki' }); + }, [navigate]); + + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); + + // Collapsed state + if (!sidebarOpen) { + return ( +
+
+ {/* Running Agents */} + {!hideRunningAgents && ( + + + + + + + Running Agents + {runningAgentsCount > 0 && ( + + {runningAgentsCount} + + )} + + + + )} + + {/* Settings */} + + + + + + + Global Settings + + {formatShortcut(shortcuts.settings, true)} + + + + + + {/* User Dropdown */} + + + + + + + + + + More options + + + + + {!hideWiki && ( + + + Documentation + + )} + + + Report Bug + + + +
+ + v{appVersion} {versionSuffix} + +
+
+
+
+
+ ); + } + + // Expanded state + return ( +
+ {/* Running Agents Link */} + {!hideRunningAgents && ( +
+ +
+ )} + + {/* Settings Link */} +
+ +
+ + {/* User area with dropdown */} +
+ + + + + + {!hideWiki && ( + + + Documentation + + )} + + + Report Bug / Feature Request + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx new file mode 100644 index 000000000..4a5317187 --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx @@ -0,0 +1,349 @@ +import { useState, useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { ChevronDown, Folder, Plus, FolderOpen, Check } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { cn, isMac } from '@/lib/utils'; +import { isElectron, type Project } from '@/lib/electron'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; +import { useAppStore } from '@/store/app-store'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +interface SidebarHeaderProps { + sidebarOpen: boolean; + currentProject: Project | null; + onNewProject: () => void; + onOpenFolder: () => void; + onProjectContextMenu: (project: Project, event: React.MouseEvent) => void; +} + +export function SidebarHeader({ + sidebarOpen, + currentProject, + onNewProject, + onOpenFolder, + onProjectContextMenu, +}: SidebarHeaderProps) { + const navigate = useNavigate(); + const { projects, setCurrentProject } = useAppStore(); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const handleLogoClick = useCallback(() => { + navigate({ to: '/dashboard' }); + }, [navigate]); + + const handleProjectSelect = useCallback( + (project: Project) => { + setCurrentProject(project); + setDropdownOpen(false); + navigate({ to: '/board' }); + }, + [setCurrentProject, navigate] + ); + + const getIconComponent = (project: Project): LucideIcon => { + if (project?.icon && project.icon in LucideIcons) { + return (LucideIcons as unknown as Record)[project.icon]; + } + return Folder; + }; + + const renderProjectIcon = (project: Project, size: 'sm' | 'md' = 'md') => { + const IconComponent = getIconComponent(project); + const sizeClasses = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8'; + const iconSizeClasses = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'; + + if (project.customIconPath) { + return ( + {project.name} + ); + } + + return ( +
+ +
+ ); + }; + + // Collapsed state - show logo only + if (!sidebarOpen) { + return ( +
+ + + + + + + Go to Dashboard + + + + + {/* Collapsed project icon */} + {currentProject && ( + <> +
+ + + + + + + {currentProject.name} + + + + + )} +
+ ); + } + + // Expanded state - show logo + project dropdown + return ( +
+ {/* Header with logo and project dropdown */} +
+ {/* Logo */} + + + {/* Project Dropdown */} + {currentProject ? ( + + + + + +
+ Projects +
+ {projects.map((project, index) => { + const isActive = currentProject?.id === project.id; + const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined; + + return ( + handleProjectSelect(project)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setDropdownOpen(false); + onProjectContextMenu(project, e); + }} + className={cn( + 'flex items-center gap-3 cursor-pointer', + isActive && 'bg-brand-500/10' + )} + data-testid={`project-item-${project.id}`} + > + {renderProjectIcon(project, 'sm')} + {project.name} + {hotkeyLabel && ( + + {hotkeyLabel} + + )} + {isActive && } + + ); + })} + + { + setDropdownOpen(false); + onNewProject(); + }} + className="cursor-pointer" + data-testid="new-project-dropdown-item" + > + + New Project + + { + setDropdownOpen(false); + onOpenFolder(); + }} + className="cursor-pointer" + data-testid="open-project-dropdown-item" + > + + Open Project + +
+
+ ) : ( +
+ + +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/layout/unified-sidebar/index.ts b/apps/ui/src/components/layout/unified-sidebar/index.ts new file mode 100644 index 000000000..a88954e51 --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/index.ts @@ -0,0 +1 @@ +export { UnifiedSidebar } from './unified-sidebar'; diff --git a/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx b/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx new file mode 100644 index 000000000..eb8841ac3 --- /dev/null +++ b/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx @@ -0,0 +1,479 @@ +import { useState, useCallback, useEffect } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { useNavigate, useLocation } from '@tanstack/react-router'; +import { PanelLeftClose } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { useNotificationsStore } from '@/store/notifications-store'; +import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; +import { getElectronAPI } from '@/lib/electron'; +import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; +import { toast } from 'sonner'; +import { useIsCompact } from '@/hooks/use-media-query'; +import type { Project } from '@/lib/electron'; + +// Reuse existing sidebar components +import { SidebarNavigation, CollapseToggleButton, MobileSidebarToggle } from '../sidebar/components'; +import { SIDEBAR_FEATURE_FLAGS } from '../sidebar/constants'; +import { + useSidebarAutoCollapse, + useRunningAgents, + useSpecRegeneration, + useNavigation, + useProjectCreation, + useSetupDialog, + useTrashOperations, + useUnviewedValidations, +} from '../sidebar/hooks'; +import { TrashDialog, OnboardingDialog } from '../sidebar/dialogs'; + +// Reuse dialogs from project-switcher +import { ProjectContextMenu } from '../project-switcher/components/project-context-menu'; +import { EditProjectDialog } from '../project-switcher/components/edit-project-dialog'; + +// Import shared dialogs +import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; +import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; + +// Local components +import { SidebarHeader, SidebarFooter } from './components'; + +const logger = createLogger('UnifiedSidebar'); + +export function UnifiedSidebar() { + const navigate = useNavigate(); + const location = useLocation(); + + const { + projects, + trashedProjects, + currentProject, + sidebarOpen, + mobileSidebarHidden, + projectHistory, + upsertAndSetCurrentProject, + toggleSidebar, + toggleMobileSidebarHidden, + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + cyclePrevProject, + cycleNextProject, + moveProjectToTrash, + specCreatingForProject, + setSpecCreatingForProject, + setCurrentProject, + } = useAppStore(); + + const isCompact = useIsCompact(); + + // Environment variable flags for hiding sidebar items + const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor, hideWiki } = + SIDEBAR_FEATURE_FLAGS; + + // Get customizable keyboard shortcuts + const shortcuts = useKeyboardShortcutsConfig(); + + // Get unread notifications count + const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount); + + // State for context menu + const [contextMenuProject, setContextMenuProject] = useState(null); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( + null + ); + const [editDialogProject, setEditDialogProject] = useState(null); + + // State for delete project confirmation dialog + const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); + + // State for trash dialog + const [showTrashDialog, setShowTrashDialog] = useState(false); + + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + setNewProjectName, + newProjectPath, + setNewProjectPath, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + upsertAndSetCurrentProject, + }); + + // Setup dialog state and handlers + const { + showSetupDialog, + setShowSetupDialog, + setupProjectPath, + setSetupProjectPath, + projectOverview, + setProjectOverview, + generateFeatures, + setGenerateFeatures, + analyzeProject, + setAnalyzeProject, + featureCount, + setFeatureCount, + handleCreateInitialSpec, + handleSkipSetup, + handleOnboardingGenerateSpec, + handleOnboardingSkip, + } = useSetupDialog({ + setSpecCreatingForProject, + newProjectPath, + setNewProjectName, + setNewProjectPath, + setShowOnboardingDialog, + }); + + // Derive isCreatingSpec from store state + const isCreatingSpec = specCreatingForProject !== null; + const creatingSpecProjectPath = specCreatingForProject; + // Check if the current project is specifically the one generating spec + const isCurrentProjectGeneratingSpec = + specCreatingForProject !== null && specCreatingForProject === currentProject?.path; + + // Auto-collapse sidebar on small screens + useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); + + // Running agents count + const { runningAgentsCount } = useRunningAgents(); + + // Unviewed validations count + const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject); + + // Trash operations + const { + activeTrashId, + isEmptyingTrash, + handleRestoreProject, + handleDeleteProjectFromDisk, + handleEmptyTrash, + } = useTrashOperations({ + restoreTrashedProject, + deleteTrashedProject, + emptyTrash, + }); + + // Spec regeneration events + useSpecRegeneration({ + creatingSpecProjectPath, + setupProjectPath, + setSpecCreatingForProject, + setShowSetupDialog, + setProjectOverview, + setSetupProjectPath, + setNewProjectName, + setNewProjectPath, + }); + + // Context menu handlers + const handleContextMenu = useCallback((project: Project, event: React.MouseEvent) => { + event.preventDefault(); + setContextMenuProject(project); + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + }, []); + + const handleCloseContextMenu = useCallback(() => { + setContextMenuProject(null); + setContextMenuPosition(null); + }, []); + + const handleEditProject = useCallback((project: Project) => { + setEditDialogProject(project); + handleCloseContextMenu(); + }, [handleCloseContextMenu]); + + /** + * Opens the system folder selection dialog and initializes the selected project. + */ + const handleOpenFolder = useCallback(async () => { + 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'; + + try { + const hadAutomakerDir = await hasAutomakerDir(path); + const initResult = await initializeProject(path); + + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + upsertAndSetCurrentProject(path, name); + const specExists = await hasAppSpec(path); + + if (!hadAutomakerDir && !specExists) { + setSetupProjectPath(path); + setShowSetupDialog(true); + toast.success('Project opened', { + description: `Opened ${name}. Let's set up your app specification!`, + }); + } else if (initResult.createdFiles && initResult.createdFiles.length > 0) { + toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', { + description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`, + }); + } else { + toast.success('Project opened', { + description: `Opened ${name}`, + }); + } + + navigate({ to: '/board' }); + } catch (error) { + logger.error('Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + }, [upsertAndSetCurrentProject, navigate, setSetupProjectPath, setShowSetupDialog]); + + const handleNewProject = useCallback(() => { + setShowNewProjectModal(true); + }, [setShowNewProjectModal]); + + // Navigation sections and keyboard shortcuts + const { navSections, navigationShortcuts } = useNavigation({ + shortcuts, + hideSpecEditor, + hideContext, + hideTerminal, + currentProject, + projects, + projectHistory, + navigate, + toggleSidebar, + handleOpenFolder, + cyclePrevProject, + cycleNextProject, + unviewedValidationsCount, + unreadNotificationsCount, + isSpecGenerating: isCurrentProjectGeneratingSpec, + }); + + // Register keyboard shortcuts + useKeyboardShortcuts(navigationShortcuts); + + // Keyboard shortcuts for project switching (1-9, 0) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + const key = event.key; + let projectIndex: number | null = null; + + if (key >= '1' && key <= '9') { + projectIndex = parseInt(key, 10) - 1; + } else if (key === '0') { + projectIndex = 9; + } + + if (projectIndex !== null && projectIndex < projects.length) { + const targetProject = projects[projectIndex]; + if (targetProject && targetProject.id !== currentProject?.id) { + setCurrentProject(targetProject); + navigate({ to: '/board' }); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [projects, currentProject, setCurrentProject, navigate]); + + const isActiveRoute = (id: string) => { + const routePath = id === 'welcome' ? '/' : `/${id}`; + return location.pathname === routePath; + }; + + // Check if sidebar should be completely hidden on mobile + const shouldHideSidebar = isCompact && mobileSidebarHidden; + + return ( + <> + {/* Floating toggle to show sidebar on mobile when hidden */} + + + {/* Mobile backdrop overlay */} + {sidebarOpen && !shouldHideSidebar && ( +
+ )} + + + + {/* Context Menu */} + {contextMenuProject && contextMenuPosition && ( + + )} + + {/* Edit Project Dialog */} + {editDialogProject && ( + !open && setEditDialogProject(null)} + /> + )} + + ); +} diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 907d2b196..f8379c709 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -3,8 +3,7 @@ import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'reac import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createLogger } from '@automaker/utils/logger'; -import { Sidebar } from '@/components/layout/sidebar'; -import { ProjectSwitcher } from '@/components/layout/project-switcher'; +import { UnifiedSidebar } from '@/components/layout/unified-sidebar'; import { FileBrowserProvider, useFileBrowser, @@ -171,8 +170,6 @@ function RootLayoutContent() { skipSandboxWarning, setSkipSandboxWarning, fetchCodexModels, - sidebarOpen, - toggleSidebar, } = useAppStore(); const { setupComplete, codexCliStatus } = useSetupStore(); const navigate = useNavigate(); @@ -186,7 +183,7 @@ function RootLayoutContent() { // Load project settings when switching projects useProjectSettingsLoader(); - // Check if we're in compact mode (< 1240px) to hide project switcher + // Check if we're in compact mode (< 1240px) const isCompact = useIsCompact(); const isSetupRoute = location.pathname === '/setup'; @@ -853,11 +850,6 @@ function RootLayoutContent() { ); } - // Show project switcher on all app pages (not on dashboard, setup, or login) - // Also hide on compact screens (< 1240px) - the sidebar will show a logo instead - const showProjectSwitcher = - !isDashboardRoute && !isSetupRoute && !isLoginRoute && !isLoggedOutRoute && !isCompact; - return ( <>
@@ -868,8 +860,7 @@ function RootLayoutContent() { aria-hidden="true" /> )} - {showProjectSwitcher && } - +
Date: Thu, 22 Jan 2026 18:52:30 +0100 Subject: [PATCH 03/13] refactor(ui): consolidate unified-sidebar into sidebar folder Merge the unified-sidebar implementation into the standard sidebar folder structure. The unified sidebar becomes the canonical sidebar with improved features including collapsible sections, scroll indicators, and enhanced mobile support. - Delete old sidebar.tsx - Move unified-sidebar components to sidebar/components - Rename UnifiedSidebar to Sidebar - Update all imports in __root.tsx - Remove redundant unified-sidebar folder --- apps/ui/src/components/layout/index.ts | 1 - apps/ui/src/components/layout/sidebar.tsx | 397 -------------- .../components/collapse-toggle-button.tsx | 2 +- .../sidebar/components/sidebar-footer.tsx | 375 +++++++++---- .../sidebar/components/sidebar-header.tsx | 499 +++++++++++++----- .../sidebar/components/sidebar-navigation.tsx | 417 ++++++++++----- .../layout/sidebar/hooks/use-navigation.ts | 20 + .../ui/src/components/layout/sidebar/index.ts | 1 + .../sidebar.tsx} | 51 +- .../ui/src/components/layout/sidebar/types.ts | 4 + .../unified-sidebar/components/index.ts | 2 - .../components/sidebar-footer.tsx | 372 ------------- .../components/sidebar-header.tsx | 349 ------------ .../layout/unified-sidebar/index.ts | 1 - apps/ui/src/routes/__root.tsx | 4 +- 15 files changed, 971 insertions(+), 1524 deletions(-) delete mode 100644 apps/ui/src/components/layout/sidebar.tsx create mode 100644 apps/ui/src/components/layout/sidebar/index.ts rename apps/ui/src/components/layout/{unified-sidebar/unified-sidebar.tsx => sidebar/sidebar.tsx} (92%) delete mode 100644 apps/ui/src/components/layout/unified-sidebar/components/index.ts delete mode 100644 apps/ui/src/components/layout/unified-sidebar/components/sidebar-footer.tsx delete mode 100644 apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx delete mode 100644 apps/ui/src/components/layout/unified-sidebar/index.ts diff --git a/apps/ui/src/components/layout/index.ts b/apps/ui/src/components/layout/index.ts index d702d78de..bfed62466 100644 --- a/apps/ui/src/components/layout/index.ts +++ b/apps/ui/src/components/layout/index.ts @@ -1,2 +1 @@ export { Sidebar } from './sidebar'; -export { UnifiedSidebar } from './unified-sidebar'; diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx deleted file mode 100644 index 05ff1328c..000000000 --- a/apps/ui/src/components/layout/sidebar.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import { useState, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { useNavigate, useLocation } from '@tanstack/react-router'; - -const logger = createLogger('Sidebar'); -import { cn } from '@/lib/utils'; -import { useAppStore } from '@/store/app-store'; -import { useNotificationsStore } from '@/store/notifications-store'; -import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; -import { getElectronAPI } from '@/lib/electron'; -import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; -import { toast } from 'sonner'; -import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; -import { NewProjectModal } from '@/components/dialogs/new-project-modal'; -import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; - -// Local imports from subfolder -import { - CollapseToggleButton, - SidebarHeader, - SidebarNavigation, - SidebarFooter, - MobileSidebarToggle, -} from './sidebar/components'; -import { useIsCompact } from '@/hooks/use-media-query'; -import { PanelLeftClose } from 'lucide-react'; -import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; -import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; -import { - useSidebarAutoCollapse, - useRunningAgents, - useSpecRegeneration, - useNavigation, - useProjectCreation, - useSetupDialog, - useTrashOperations, - useUnviewedValidations, -} from './sidebar/hooks'; - -export function Sidebar() { - const navigate = useNavigate(); - const location = useLocation(); - - const { - projects, - trashedProjects, - currentProject, - sidebarOpen, - mobileSidebarHidden, - projectHistory, - upsertAndSetCurrentProject, - toggleSidebar, - toggleMobileSidebarHidden, - restoreTrashedProject, - deleteTrashedProject, - emptyTrash, - cyclePrevProject, - cycleNextProject, - moveProjectToTrash, - specCreatingForProject, - setSpecCreatingForProject, - } = useAppStore(); - - const isCompact = useIsCompact(); - - // Environment variable flags for hiding sidebar items - const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS; - - // Get customizable keyboard shortcuts - const shortcuts = useKeyboardShortcutsConfig(); - - // Get unread notifications count - const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount); - - // State for delete project confirmation dialog - const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); - - // State for trash dialog - const [showTrashDialog, setShowTrashDialog] = useState(false); - - // Project creation state and handlers - const { - showNewProjectModal, - setShowNewProjectModal, - isCreatingProject, - showOnboardingDialog, - setShowOnboardingDialog, - newProjectName, - setNewProjectName, - newProjectPath, - setNewProjectPath, - handleCreateBlankProject, - handleCreateFromTemplate, - handleCreateFromCustomUrl, - } = useProjectCreation({ - upsertAndSetCurrentProject, - }); - - // Setup dialog state and handlers - const { - showSetupDialog, - setShowSetupDialog, - setupProjectPath, - setSetupProjectPath, - projectOverview, - setProjectOverview, - generateFeatures, - setGenerateFeatures, - analyzeProject, - setAnalyzeProject, - featureCount, - setFeatureCount, - handleCreateInitialSpec, - handleSkipSetup, - handleOnboardingGenerateSpec, - handleOnboardingSkip, - } = useSetupDialog({ - setSpecCreatingForProject, - newProjectPath, - setNewProjectName, - setNewProjectPath, - setShowOnboardingDialog, - }); - - // Derive isCreatingSpec from store state - const isCreatingSpec = specCreatingForProject !== null; - const creatingSpecProjectPath = specCreatingForProject; - // Check if the current project is specifically the one generating spec - const isCurrentProjectGeneratingSpec = - specCreatingForProject !== null && specCreatingForProject === currentProject?.path; - - // Auto-collapse sidebar on small screens and update Electron window minWidth - useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); - - // Running agents count - const { runningAgentsCount } = useRunningAgents(); - - // Unviewed validations count - const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject); - - // Trash operations - const { - activeTrashId, - isEmptyingTrash, - handleRestoreProject, - handleDeleteProjectFromDisk, - handleEmptyTrash, - } = useTrashOperations({ - restoreTrashedProject, - deleteTrashedProject, - emptyTrash, - }); - - // Spec regeneration events - useSpecRegeneration({ - creatingSpecProjectPath, - setupProjectPath, - setSpecCreatingForProject, - setShowSetupDialog, - setProjectOverview, - setSetupProjectPath, - setNewProjectName, - setNewProjectPath, - }); - - /** - * Opens the system folder selection dialog and initializes the selected project. - * Used by both the 'O' keyboard shortcut and the folder icon button. - */ - const handleOpenFolder = useCallback(async () => { - const api = getElectronAPI(); - const result = await api.openDirectory(); - - if (!result.canceled && result.filePaths[0]) { - const path = result.filePaths[0]; - // Extract folder name from path (works on both Windows and Mac/Linux) - const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; - - try { - // Check if this is a brand new project (no .automaker directory) - const hadAutomakerDir = await hasAutomakerDir(path); - - // Initialize the .automaker directory structure - const initResult = await initializeProject(path); - - if (!initResult.success) { - toast.error('Failed to initialize project', { - description: initResult.error || 'Unknown error occurred', - }); - return; - } - - // Upsert project and set as current (handles both create and update cases) - // Theme handling (trashed project recovery or undefined for global) is done by the store - upsertAndSetCurrentProject(path, name); - - // Check if app_spec.txt exists - const specExists = await hasAppSpec(path); - - if (!hadAutomakerDir && !specExists) { - // This is a brand new project - show setup dialog - setSetupProjectPath(path); - setShowSetupDialog(true); - toast.success('Project opened', { - description: `Opened ${name}. Let's set up your app specification!`, - }); - } else if (initResult.createdFiles && initResult.createdFiles.length > 0) { - toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', { - description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`, - }); - } else { - toast.success('Project opened', { - description: `Opened ${name}`, - }); - } - } catch (error) { - logger.error('Failed to open project:', error); - toast.error('Failed to open project', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - }, [upsertAndSetCurrentProject]); - - // Navigation sections and keyboard shortcuts (defined after handlers) - const { navSections, navigationShortcuts } = useNavigation({ - shortcuts, - hideSpecEditor, - hideContext, - hideTerminal, - currentProject, - projects, - projectHistory, - navigate, - toggleSidebar, - handleOpenFolder, - cyclePrevProject, - cycleNextProject, - unviewedValidationsCount, - unreadNotificationsCount, - isSpecGenerating: isCurrentProjectGeneratingSpec, - }); - - // Register keyboard shortcuts - useKeyboardShortcuts(navigationShortcuts); - - const isActiveRoute = (id: string) => { - // Map view IDs to route paths - const routePath = id === 'welcome' ? '/' : `/${id}`; - return location.pathname === routePath; - }; - - // Check if sidebar should be completely hidden on mobile - const shouldHideSidebar = isCompact && mobileSidebarHidden; - - return ( - <> - {/* Floating toggle to show sidebar on mobile when hidden */} - - - {/* Mobile backdrop overlay */} - {sidebarOpen && !shouldHideSidebar && ( -
- )} - - - ); -} 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 index 29a716444..2a503fc57 100644 --- a/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx +++ b/apps/ui/src/components/layout/sidebar/components/collapse-toggle-button.tsx @@ -25,7 +25,7 @@ export function CollapseToggleButton({ + + + Running Agents + {runningAgentsCount > 0 && ( + + {runningAgentsCount} + + )} + + + + )} + + {/* Settings */} + + + + + + + Global Settings + + {formatShortcut(shortcuts.settings, true)} + + + + + + {/* Documentation */} + {!hideWiki && ( + + + + + + + Documentation + + + + )} + + {/* Feedback */} + + + + + + + Feedback + + + +
+
+ ); + } + + // Expanded state return ( -
+
{/* Running Agents Link */} {!hideRunningAgents && ( -
+
)} + {/* Settings Link */} -
+
+
+ + {/* Separator */} +
+ + {/* Documentation Link */} + {!hideWiki && ( +
+ +
+ )} + + {/* Feedback Link */} +
+
+ + {/* Version */} +
+ + v{appVersion} {versionSuffix} + +
); } diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index 8f3d921e8..db4835ddd 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -1,179 +1,406 @@ -import { useState } from 'react'; -import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react'; +import { useState, useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import { cn, isMac } from '@/lib/utils'; -import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { isElectron, type Project } from '@/lib/electron'; -import { useIsCompact } from '@/hooks/use-media-query'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { useAppStore } from '@/store/app-store'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; interface SidebarHeaderProps { sidebarOpen: boolean; currentProject: Project | null; - onClose?: () => void; - onExpand?: () => void; + onNewProject: () => void; + onOpenFolder: () => void; + onProjectContextMenu: (project: Project, event: React.MouseEvent) => void; } export function SidebarHeader({ sidebarOpen, currentProject, - onClose, - onExpand, + onNewProject, + onOpenFolder, + onProjectContextMenu, }: SidebarHeaderProps) { - const isCompact = useIsCompact(); - const [projectListOpen, setProjectListOpen] = useState(false); + const navigate = useNavigate(); const { projects, setCurrentProject } = useAppStore(); - // Get the icon component from lucide-react - const getIconComponent = (): LucideIcon => { - if (currentProject?.icon && currentProject.icon in LucideIcons) { - return (LucideIcons as unknown as Record)[currentProject.icon]; + const [dropdownOpen, setDropdownOpen] = useState(false); + + const handleLogoClick = useCallback(() => { + navigate({ to: '/dashboard' }); + }, [navigate]); + + const handleProjectSelect = useCallback( + (project: Project) => { + setCurrentProject(project); + setDropdownOpen(false); + navigate({ to: '/board' }); + }, + [setCurrentProject, navigate] + ); + + const getIconComponent = (project: Project): LucideIcon => { + if (project?.icon && project.icon in LucideIcons) { + return (LucideIcons as unknown as Record)[project.icon]; } return Folder; }; - const IconComponent = getIconComponent(); - const hasCustomIcon = !!currentProject?.customIconPath; + const renderProjectIcon = (project: Project, size: 'sm' | 'md' = 'md') => { + const IconComponent = getIconComponent(project); + const sizeClasses = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8'; + const iconSizeClasses = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'; + + if (project.customIconPath) { + return ( + {project.name} + ); + } + + return ( +
+ +
+ ); + }; + + // Collapsed state - show logo only + if (!sidebarOpen) { + return ( +
+ + + + + + + Go to Dashboard + + + + {/* Collapsed project icon with dropdown */} + {currentProject && ( + <> +
+ + + + + + + + + + {currentProject.name} + + + + +
+ Projects +
+ {projects.map((project, index) => { + const isActive = currentProject?.id === project.id; + const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined; + + return ( + handleProjectSelect(project)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setDropdownOpen(false); + onProjectContextMenu(project, e); + }} + className="flex items-center gap-3 cursor-pointer" + data-testid={`collapsed-project-item-${project.id}`} + > + {renderProjectIcon(project, 'sm')} + + {project.name} + + {hotkeyLabel && ( + ⌘{hotkeyLabel} + )} + + ); + })} + + { + setDropdownOpen(false); + onNewProject(); + }} + className="cursor-pointer" + data-testid="collapsed-new-project-dropdown-item" + > + + New Project + + { + setDropdownOpen(false); + onOpenFolder(); + }} + className="cursor-pointer" + data-testid="collapsed-open-project-dropdown-item" + > + + Open Project + +
+
+ + )} +
+ ); + } + + // Expanded state - show logo + project dropdown return (
- {/* Mobile close button - only visible on mobile when sidebar is open */} - {sidebarOpen && onClose && ( + {/* Header with logo and project dropdown */} +
+ {/* Logo */} - )} - {/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */} - {!sidebarOpen && isCompact && onExpand && ( - - )} - {/* Project name and icon display - entire element clickable on mobile */} - {currentProject && ( - - - + + {/* Project Dropdown */} + {currentProject ? ( + + + + + +
+ Projects
- - {/* Project Name - only show when sidebar is open */} - {sidebarOpen && ( -
-

- {currentProject.name} -

-
- )} - -
- -
-

Switch Project

- {projects.map((project) => { - const ProjectIcon = - project.icon && project.icon in LucideIcons - ? (LucideIcons as unknown as Record)[project.icon] - : Folder; + {projects.map((project, index) => { const isActive = currentProject?.id === project.id; + const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined; return ( - + ); })} -
-
-
- )} + + { + setDropdownOpen(false); + onNewProject(); + }} + className="cursor-pointer" + data-testid="new-project-dropdown-item" + > + + New Project + + { + setDropdownOpen(false); + onOpenFolder(); + }} + className="cursor-pointer" + data-testid="open-project-dropdown-item" + > + + Open Project + + + + ) : ( +
+ + +
+ )} +
); } diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index c4956159e..f303ad44c 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -1,9 +1,24 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; import type { NavigateOptions } from '@tanstack/react-router'; +import { ChevronDown, Wrench, Github } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatShortcut } from '@/store/app-store'; import type { NavSection } from '../types'; import type { Project } from '@/lib/electron'; import { Spinner } from '@/components/ui/spinner'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +// Map section labels to icons +const sectionIcons: Record> = { + Tools: Wrench, + GitHub: Github, +}; interface SidebarNavigationProps { currentProject: Project | null; @@ -11,6 +26,7 @@ interface SidebarNavigationProps { navSections: NavSection[]; isActiveRoute: (id: string) => boolean; navigate: (opts: NavigateOptions) => void; + onScrollStateChange?: (canScrollDown: boolean) => void; } export function SidebarNavigation({ @@ -19,174 +35,305 @@ export function SidebarNavigation({ navSections, isActiveRoute, navigate, + onScrollStateChange, }: SidebarNavigationProps) { + const navRef = useRef(null); + + // Track collapsed state for each collapsible section + const [collapsedSections, setCollapsedSections] = useState>({}); + + // Initialize collapsed state when sections change (e.g., GitHub section appears) + useEffect(() => { + setCollapsedSections((prev) => { + const updated = { ...prev }; + navSections.forEach((section) => { + if (section.collapsible && section.label && !(section.label in updated)) { + updated[section.label] = section.defaultCollapsed ?? false; + } + }); + return updated; + }); + }, [navSections]); + + // Check scroll state + const checkScrollState = useCallback(() => { + if (!navRef.current || !onScrollStateChange) return; + const { scrollTop, scrollHeight, clientHeight } = navRef.current; + const canScrollDown = scrollTop + clientHeight < scrollHeight - 10; + onScrollStateChange(canScrollDown); + }, [onScrollStateChange]); + + // Monitor scroll state + useEffect(() => { + checkScrollState(); + const nav = navRef.current; + if (!nav) return; + + nav.addEventListener('scroll', checkScrollState); + const resizeObserver = new ResizeObserver(checkScrollState); + resizeObserver.observe(nav); + + return () => { + nav.removeEventListener('scroll', checkScrollState); + resizeObserver.disconnect(); + }; + }, [checkScrollState, collapsedSections]); + + const toggleSection = useCallback((label: string) => { + setCollapsedSections((prev) => ({ + ...prev, + [label]: !prev[label], + })); + }, []); + + // Filter sections: always show non-project sections, only show project sections when project exists + const visibleSections = navSections.filter((section) => { + // Always show Dashboard (first section with no label) + if (!section.label && section.items.some((item) => item.id === 'dashboard')) { + return true; + } + // Show other sections only when project is selected + return !!currentProject; + }); + return (
- )) - ) : null} + ); + })} + + {/* Placeholder when no project is selected */} + {!currentProject && sidebarOpen && ( +
+

+ Select or create a project to continue +

+
+ )} ); } diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 91b40e4ac..df5d033f5 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -13,6 +13,7 @@ import { Network, Bell, Settings, + Home, } from 'lucide-react'; import type { NavSection, NavItem } from '../types'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; @@ -174,13 +175,30 @@ export function useNavigation({ } const sections: NavSection[] = [ + // Dashboard - standalone at top + { + label: '', + items: [ + { + id: 'dashboard', + label: 'Dashboard', + icon: Home, + }, + ], + }, + // Project section - expanded by default { label: 'Project', items: projectItems, + collapsible: true, + defaultCollapsed: false, }, + // Tools section - collapsed by default { label: 'Tools', items: visibleToolsItems, + collapsible: true, + defaultCollapsed: true, }, ]; @@ -203,6 +221,8 @@ export function useNavigation({ shortcut: shortcuts.githubPrs, }, ], + collapsible: true, + defaultCollapsed: true, }); } diff --git a/apps/ui/src/components/layout/sidebar/index.ts b/apps/ui/src/components/layout/sidebar/index.ts new file mode 100644 index 000000000..bfed62466 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/index.ts @@ -0,0 +1 @@ +export { Sidebar } from './sidebar'; diff --git a/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx b/apps/ui/src/components/layout/sidebar/sidebar.tsx similarity index 92% rename from apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx rename to apps/ui/src/components/layout/sidebar/sidebar.tsx index eb8841ac3..5b63921f7 100644 --- a/apps/ui/src/components/layout/unified-sidebar/unified-sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar/sidebar.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { useNavigate, useLocation } from '@tanstack/react-router'; -import { PanelLeftClose } from 'lucide-react'; +import { PanelLeftClose, ChevronDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { useNotificationsStore } from '@/store/notifications-store'; @@ -12,9 +12,15 @@ import { toast } from 'sonner'; import { useIsCompact } from '@/hooks/use-media-query'; import type { Project } from '@/lib/electron'; -// Reuse existing sidebar components -import { SidebarNavigation, CollapseToggleButton, MobileSidebarToggle } from '../sidebar/components'; -import { SIDEBAR_FEATURE_FLAGS } from '../sidebar/constants'; +// Sidebar components +import { + SidebarNavigation, + CollapseToggleButton, + MobileSidebarToggle, + SidebarHeader, + SidebarFooter, +} from './components'; +import { SIDEBAR_FEATURE_FLAGS } from './constants'; import { useSidebarAutoCollapse, useRunningAgents, @@ -24,8 +30,8 @@ import { useSetupDialog, useTrashOperations, useUnviewedValidations, -} from '../sidebar/hooks'; -import { TrashDialog, OnboardingDialog } from '../sidebar/dialogs'; +} from './hooks'; +import { TrashDialog, OnboardingDialog } from './dialogs'; // Reuse dialogs from project-switcher import { ProjectContextMenu } from '../project-switcher/components/project-context-menu'; @@ -36,12 +42,9 @@ import { DeleteProjectDialog } from '@/components/views/settings-view/components import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; -// Local components -import { SidebarHeader, SidebarFooter } from './components'; - -const logger = createLogger('UnifiedSidebar'); +const logger = createLogger('Sidebar'); -export function UnifiedSidebar() { +export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); @@ -188,10 +191,13 @@ export function UnifiedSidebar() { setContextMenuPosition(null); }, []); - const handleEditProject = useCallback((project: Project) => { - setEditDialogProject(project); - handleCloseContextMenu(); - }, [handleCloseContextMenu]); + const handleEditProject = useCallback( + (project: Project) => { + setEditDialogProject(project); + handleCloseContextMenu(); + }, + [handleCloseContextMenu] + ); /** * Opens the system folder selection dialog and initializes the selected project. @@ -309,6 +315,9 @@ export function UnifiedSidebar() { return location.pathname === routePath; }; + // Track if nav can scroll down + const [canScrollDown, setCanScrollDown] = useState(false); + // Check if sidebar should be completely hidden on mobile const shouldHideSidebar = isCompact && mobileSidebarHidden; @@ -339,7 +348,9 @@ export function UnifiedSidebar() { shouldHideSidebar && 'hidden', // Width based on state !shouldHideSidebar && - (sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16') + (sidebarOpen + ? 'fixed inset-y-0 left-0 w-[17rem] lg:relative lg:w-[17rem]' + : 'relative w-14') )} data-testid="sidebar" > @@ -384,9 +395,17 @@ export function UnifiedSidebar() { navSections={navSections} isActiveRoute={isActiveRoute} navigate={navigate} + onScrollStateChange={setCanScrollDown} />
+ {/* Scroll indicator - shows there's more content below */} + {canScrollDown && sidebarOpen && ( +
+ +
+ )} + boolean; - navigate: (opts: NavigateOptions) => void; - hideRunningAgents: boolean; - hideWiki: boolean; - runningAgentsCount: number; - shortcuts: { - settings: string; - }; -} - -export function SidebarFooter({ - sidebarOpen, - isActiveRoute, - navigate, - hideRunningAgents, - hideWiki, - runningAgentsCount, - shortcuts, -}: SidebarFooterProps) { - const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; - const { os } = useOSDetection(); - const appMode = import.meta.env.VITE_APP_MODE || '?'; - const versionSuffix = `${getOSAbbreviation(os)}${appMode}`; - - const handleWikiClick = useCallback(() => { - navigate({ to: '/wiki' }); - }, [navigate]); - - const handleBugReportClick = useCallback(() => { - const api = getElectronAPI(); - api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); - }, []); - - // Collapsed state - if (!sidebarOpen) { - return ( -
-
- {/* Running Agents */} - {!hideRunningAgents && ( - - - - - - - Running Agents - {runningAgentsCount > 0 && ( - - {runningAgentsCount} - - )} - - - - )} - - {/* Settings */} - - - - - - - Global Settings - - {formatShortcut(shortcuts.settings, true)} - - - - - - {/* User Dropdown */} - - - - - - - - - - More options - - - - - {!hideWiki && ( - - - Documentation - - )} - - - Report Bug - - - -
- - v{appVersion} {versionSuffix} - -
-
-
-
-
- ); - } - - // Expanded state - return ( -
- {/* Running Agents Link */} - {!hideRunningAgents && ( -
- -
- )} - - {/* Settings Link */} -
- -
- - {/* User area with dropdown */} -
- - - - - - {!hideWiki && ( - - - Documentation - - )} - - - Report Bug / Feature Request - - - - -
-
- ); -} diff --git a/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx deleted file mode 100644 index 4a5317187..000000000 --- a/apps/ui/src/components/layout/unified-sidebar/components/sidebar-header.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useNavigate } from '@tanstack/react-router'; -import { ChevronDown, Folder, Plus, FolderOpen, Check } from 'lucide-react'; -import * as LucideIcons from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; -import { cn, isMac } from '@/lib/utils'; -import { isElectron, type Project } from '@/lib/electron'; -import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; -import { useAppStore } from '@/store/app-store'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; - -interface SidebarHeaderProps { - sidebarOpen: boolean; - currentProject: Project | null; - onNewProject: () => void; - onOpenFolder: () => void; - onProjectContextMenu: (project: Project, event: React.MouseEvent) => void; -} - -export function SidebarHeader({ - sidebarOpen, - currentProject, - onNewProject, - onOpenFolder, - onProjectContextMenu, -}: SidebarHeaderProps) { - const navigate = useNavigate(); - const { projects, setCurrentProject } = useAppStore(); - const [dropdownOpen, setDropdownOpen] = useState(false); - - const handleLogoClick = useCallback(() => { - navigate({ to: '/dashboard' }); - }, [navigate]); - - const handleProjectSelect = useCallback( - (project: Project) => { - setCurrentProject(project); - setDropdownOpen(false); - navigate({ to: '/board' }); - }, - [setCurrentProject, navigate] - ); - - const getIconComponent = (project: Project): LucideIcon => { - if (project?.icon && project.icon in LucideIcons) { - return (LucideIcons as unknown as Record)[project.icon]; - } - return Folder; - }; - - const renderProjectIcon = (project: Project, size: 'sm' | 'md' = 'md') => { - const IconComponent = getIconComponent(project); - const sizeClasses = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8'; - const iconSizeClasses = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'; - - if (project.customIconPath) { - return ( - {project.name} - ); - } - - return ( -
- -
- ); - }; - - // Collapsed state - show logo only - if (!sidebarOpen) { - return ( -
- - - - - - - Go to Dashboard - - - - - {/* Collapsed project icon */} - {currentProject && ( - <> -
- - - - - - - {currentProject.name} - - - - - )} -
- ); - } - - // Expanded state - show logo + project dropdown - return ( -
- {/* Header with logo and project dropdown */} -
- {/* Logo */} - - - {/* Project Dropdown */} - {currentProject ? ( - - - - - -
- Projects -
- {projects.map((project, index) => { - const isActive = currentProject?.id === project.id; - const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined; - - return ( - handleProjectSelect(project)} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - setDropdownOpen(false); - onProjectContextMenu(project, e); - }} - className={cn( - 'flex items-center gap-3 cursor-pointer', - isActive && 'bg-brand-500/10' - )} - data-testid={`project-item-${project.id}`} - > - {renderProjectIcon(project, 'sm')} - {project.name} - {hotkeyLabel && ( - - {hotkeyLabel} - - )} - {isActive && } - - ); - })} - - { - setDropdownOpen(false); - onNewProject(); - }} - className="cursor-pointer" - data-testid="new-project-dropdown-item" - > - - New Project - - { - setDropdownOpen(false); - onOpenFolder(); - }} - className="cursor-pointer" - data-testid="open-project-dropdown-item" - > - - Open Project - -
-
- ) : ( -
- - -
- )} -
-
- ); -} diff --git a/apps/ui/src/components/layout/unified-sidebar/index.ts b/apps/ui/src/components/layout/unified-sidebar/index.ts deleted file mode 100644 index a88954e51..000000000 --- a/apps/ui/src/components/layout/unified-sidebar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UnifiedSidebar } from './unified-sidebar'; diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index f8379c709..f374b7dd0 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'reac import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createLogger } from '@automaker/utils/logger'; -import { UnifiedSidebar } from '@/components/layout/unified-sidebar'; +import { Sidebar } from '@/components/layout/sidebar'; import { FileBrowserProvider, useFileBrowser, @@ -860,7 +860,7 @@ function RootLayoutContent() { aria-hidden="true" /> )} - +
Date: Fri, 23 Jan 2026 01:58:15 +0100 Subject: [PATCH 04/13] fix(ui): address PR review comments and fix E2E tests for unified sidebar - Add try/catch for getElectronAPI() in sidebar-footer with window.open fallback - Use formatShortcut() for OS-aware hotkey display in sidebar-header - Remove unnecessary optional chaining on project.icon - Remove redundant ternary in sidebar-navigation className - Update E2E tests to use new project-dropdown-trigger data-testid Co-Authored-By: Claude Opus 4.5 --- .../layout/sidebar/components/sidebar-footer.tsx | 9 +++++++-- .../layout/sidebar/components/sidebar-header.tsx | 11 ++++++++--- .../layout/sidebar/components/sidebar-navigation.tsx | 8 +------- .../tests/features/feature-manual-review-flow.spec.ts | 9 ++++----- apps/ui/tests/projects/new-project-creation.spec.ts | 9 ++++----- apps/ui/tests/projects/open-existing-project.spec.ts | 9 ++++----- 6 files changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx index b407e365b..49f4eccf5 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -51,8 +51,13 @@ export function SidebarFooter({ }, [navigate]); const handleFeedbackClick = useCallback(() => { - const api = getElectronAPI(); - api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + try { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + } catch { + // Fallback for non-Electron environments (SSR, web browser) + window.open('https://github.com/AutoMaker-Org/automaker/issues', '_blank'); + } }, []); // Collapsed state diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index db4835ddd..afca3e9c2 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -4,6 +4,7 @@ import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { cn, isMac } from '@/lib/utils'; +import { formatShortcut } from '@/store/app-store'; import { isElectron, type Project } from '@/lib/electron'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { useAppStore } from '@/store/app-store'; @@ -49,7 +50,7 @@ export function SidebarHeader({ ); const getIconComponent = (project: Project): LucideIcon => { - if (project?.icon && project.icon in LucideIcons) { + if (project.icon && project.icon in LucideIcons) { return (LucideIcons as unknown as Record)[project.icon]; } return Folder; @@ -200,7 +201,9 @@ export function SidebarHeader({ {project.name} {hotkeyLabel && ( - ⌘{hotkeyLabel} + + {formatShortcut(`Cmd+${hotkeyLabel}`, true)} + )} ); @@ -342,7 +345,9 @@ export function SidebarHeader({ {project.name} {hotkeyLabel && ( - ⌘{hotkeyLabel} + + {formatShortcut(`Cmd+${hotkeyLabel}`, true)} + )} ); diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index f303ad44c..4a1ab1fc0 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -97,13 +97,7 @@ export function SidebarNavigation({ }); return ( -