diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 653baeda0..a5f7cbcb1 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -83,6 +83,7 @@ import { getNotificationService } from './services/notification-service.js'; import { createEventHistoryRoutes } from './routes/event-history/index.js'; import { getEventHistoryService } from './services/event-history-service.js'; import { getTestRunnerService } from './services/test-runner-service.js'; +import { createProjectsRoutes } from './routes/projects/index.js'; // Load environment variables dotenv.config(); @@ -347,6 +348,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..24ecef14b --- /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 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 { 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..e58c9c0c8 --- /dev/null +++ b/apps/server/src/routes/projects/routes/overview.ts @@ -0,0 +1,317 @@ +/** + * 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 'in_progress': + counts.running++; + break; + case 'waiting_approval': + // waiting_approval means agent finished, needs human review - count as pending + counts.pending++; + 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'; + } + + // Pending work but no active execution + if (featureCounts.pending > 0) { + return 'waiting'; + } + + // 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 || []; + + // Get all running agents once to count live running features per project + const allRunningAgents = await autoModeService.getRunningAgents(); + + // 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; + + // Count live running features for this project (across all branches) + // This ensures we only count features that are actually running in memory + const liveRunningCount = allRunningAgents.filter( + (agent) => agent.projectPath === projectRef.path + ).length; + featureCounts.running = liveRunningCount; + + // 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) { + logError(error, `Failed to load project status: ${projectRef.name}`); + // 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/automaker-logo.tsx b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx index 92b7af992..9f47fffe0 100644 --- a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx +++ b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx @@ -32,7 +32,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) { 'flex items-center gap-3 titlebar-no-drag cursor-pointer group', !sidebarOpen && 'flex-col gap-1' )} - onClick={() => navigate({ to: '/dashboard' })} + onClick={() => navigate({ to: '/overview' })} data-testid="logo-button" > {/* Collapsed logo - only shown when sidebar is closed */} 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 49f4eccf5..0dab16947 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -217,7 +217,15 @@ export function SidebarFooter({ // Expanded state return ( -
+
{/* Running Agents Link */} {!hideRunningAgents && (
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 afca3e9c2..a1360e793 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -37,7 +37,7 @@ export function SidebarHeader({ const [dropdownOpen, setDropdownOpen] = useState(false); const handleLogoClick = useCallback(() => { - navigate({ to: '/dashboard' }); + navigate({ to: '/overview' }); }, [navigate]); const handleProjectSelect = useCallback( 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 4a1ab1fc0..e7fd179e0 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -89,7 +89,7 @@ export function SidebarNavigation({ // 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')) { + if (!section.label && section.items.some((item) => item.id === 'overview')) { return true; } // Show other sections only when project is selected 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 df5d033f5..90d59db9d 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -175,12 +175,12 @@ export function useNavigation({ } const sections: NavSection[] = [ - // Dashboard - standalone at top + // Dashboard - standalone at top (links to projects overview) { label: '', items: [ { - id: 'dashboard', + id: 'overview', label: 'Dashboard', icon: Home, }, diff --git a/apps/ui/src/components/views/dashboard-view.tsx b/apps/ui/src/components/views/dashboard-view.tsx index 872b97a85..a8ca953f4 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,32 @@ 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..d622384c3 --- /dev/null +++ b/apps/ui/src/components/views/overview-view.tsx @@ -0,0 +1,519 @@ +/** + * 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 { useState, useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { createLogger } from '@automaker/utils/logger'; +import { useMultiProjectStatus } from '@/hooks/use-multi-project-status'; +import { useAppStore } from '@/store/app-store'; +import { isElectron, getElectronAPI } from '@/lib/electron'; +import { isMac } from '@/lib/utils'; +import { initializeProject } from '@/lib/project-init'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; +import { Spinner } from '@/components/ui/spinner'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; +import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal'; +import { ProjectStatusCard } from './overview/project-status-card'; +import { RecentActivityFeed } from './overview/recent-activity-feed'; +import { RunningAgentsPanel } from './overview/running-agents-panel'; +import type { StarterTemplate } from '@/lib/templates'; +import { + LayoutDashboard, + RefreshCw, + Folder, + FolderOpen, + Plus, + Activity, + CheckCircle2, + XCircle, + Clock, + Bot, + Bell, +} from 'lucide-react'; + +const logger = createLogger('OverviewView'); + +export function OverviewView() { + const navigate = useNavigate(); + const { overview, isLoading, error, refresh } = useMultiProjectStatus(15000); // Refresh every 15s + const { upsertAndSetCurrentProject } = useAppStore(); + + // Modal state + const [showNewProjectModal, setShowNewProjectModal] = useState(false); + const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + const initializeAndOpenProject = useCallback( + async (path: string, name: string) => { + try { + const initResult = await initializeProject(path); + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + upsertAndSetCurrentProject(path, name); + + toast.success('Project opened', { description: `Opened ${name}` }); + navigate({ to: '/board' }); + } catch (error) { + logger.error('[Overview] Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + [upsertAndSetCurrentProject, navigate] + ); + + const handleOpenProject = useCallback(async () => { + try { + const httpClient = getHttpApiClient(); + const configResult = await httpClient.workspace.getConfig(); + + if (configResult.success && configResult.configured) { + setShowWorkspacePicker(true); + } else { + const api = getElectronAPI(); + const result = await api.openDirectory(); + + if (!result.canceled && result.filePaths[0]) { + const path = result.filePaths[0]; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; + await initializeAndOpenProject(path, name); + } + } + } catch (error) { + logger.error('[Overview] Failed to check workspace config:', error); + const api = getElectronAPI(); + const result = await api.openDirectory(); + + if (!result.canceled && result.filePaths[0]) { + const path = result.filePaths[0]; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; + await initializeAndOpenProject(path, name); + } + } + }, [initializeAndOpenProject]); + + const handleWorkspaceSelect = useCallback( + async (path: string, name: string) => { + setShowWorkspacePicker(false); + await initializeAndOpenProject(path, name); + }, + [initializeAndOpenProject] + ); + + const handleCreateBlankProject = useCallback( + async (projectName: string, parentDir: string) => { + setIsCreating(true); + try { + const api = getElectronAPI(); + const projectPath = `${parentDir}/${projectName}`; + + await api.mkdir(projectPath); + + const initResult = await initializeProject(projectPath); + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + await api.writeFile( + `${projectPath}/.automaker/app_spec.txt`, + ` + ${projectName} + Describe your project here. + + + +` + ); + + upsertAndSetCurrentProject(projectPath, projectName); + setShowNewProjectModal(false); + + toast.success('Project created', { description: `Created ${projectName}` }); + navigate({ to: '/board' }); + } catch (error) { + logger.error('Failed to create project:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreating(false); + } + }, + [upsertAndSetCurrentProject, navigate] + ); + + const handleCreateFromTemplate = useCallback( + async (template: StarterTemplate, projectName: string, parentDir: string) => { + setIsCreating(true); + try { + const httpClient = getHttpApiClient(); + const cloneResult = await httpClient.templates.clone( + template.repoUrl, + projectName, + parentDir + ); + + if (!cloneResult.success || !cloneResult.projectPath) { + toast.error('Failed to clone template', { + description: cloneResult.error || 'Unknown error occurred', + }); + return; + } + + const initResult = await initializeProject(cloneResult.projectPath); + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + upsertAndSetCurrentProject(cloneResult.projectPath, projectName); + setShowNewProjectModal(false); + + toast.success('Project created from template', { + description: `Created ${projectName} from ${template.name}`, + }); + navigate({ to: '/board' }); + } catch (error) { + logger.error('Failed to create from template:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreating(false); + } + }, + [upsertAndSetCurrentProject, navigate] + ); + + const handleCreateFromCustomUrl = useCallback( + async (repoUrl: string, projectName: string, parentDir: string) => { + setIsCreating(true); + try { + const httpClient = getHttpApiClient(); + const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir); + + if (!cloneResult.success || !cloneResult.projectPath) { + toast.error('Failed to clone repository', { + description: cloneResult.error || 'Unknown error occurred', + }); + return; + } + + const initResult = await initializeProject(cloneResult.projectPath); + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + upsertAndSetCurrentProject(cloneResult.projectPath, projectName); + setShowNewProjectModal(false); + + toast.success('Project created from repository', { description: `Created ${projectName}` }); + navigate({ to: '/board' }); + } catch (error) { + logger.error('Failed to create from custom URL:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreating(false); + } + }, + [upsertAndSetCurrentProject, navigate] + ); + + 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 +

+

+ Use the sidebar to create or open a project +

+
+
+ ) : ( +
+ {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(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} +
+
+ )} +
+ + {/* Modals */} + + + +
+ ); +} 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..a2f2565c5 --- /dev/null +++ b/apps/ui/src/components/views/overview/project-status-card.tsx @@ -0,0 +1,207 @@ +/** + * 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]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }; + + 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..9eb801890 --- /dev/null +++ b/apps/ui/src/components/views/overview/recent-activity-feed.tsx @@ -0,0 +1,223 @@ +/** + * 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 { + // Get project path from the activity (projectId is actually the path in our data model) + const projectPath = activity.projectPath || activity.projectId; + const projectName = activity.projectName; + + const initResult = await initializeProject(projectPath); + + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error', + }); + return; + } + + 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] + ); + + const handleActivityKeyDown = useCallback( + (e: React.KeyboardEvent, activity: RecentActivity) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleActivityClick(activity); + } + }, + [handleActivityClick] + ); + + 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)} + onKeyDown={(e) => handleActivityKeyDown(e, activity)} + aria-label={`${config.label}: ${activity.featureName || activity.message} in ${activity.projectName}`} + 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..fc91170d0 --- /dev/null +++ b/apps/ui/src/components/views/overview/running-agents-panel.tsx @@ -0,0 +1,141 @@ +/** + * 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, GitBranch, 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] + ); + + const handleAgentKeyDown = useCallback( + (e: React.KeyboardEvent, agent: RunningAgent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleAgentClick(agent); + } + }, + [handleAgentClick] + ); + + if (runningAgents.length === 0) { + return ( +
+ +

No agents running

+

Start auto-mode on a project to see activity here

+
+ ); + } + + return ( +
+ {runningAgents.map((agent) => ( +
handleAgentClick(agent)} + onKeyDown={(e) => handleAgentKeyDown(e, agent)} + aria-label={`View running agent for ${agent.projectName}`} + 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..2282ec7e5 --- /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 || '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..31b62453f --- /dev/null +++ b/apps/ui/tests/projects/overview-dashboard.spec.ts @@ -0,0 +1,394 @@ +/** + * 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 }) => { + // 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({ + success: true, + 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(), + }), + }); + }); + + // 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 Dashboard link in the sidebar (navigates to /overview) + const overviewLink = page.locator('[data-testid="nav-overview"]'); + 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('Automaker Dashboard')).toBeVisible({ timeout: 5000 }); + + // Verify the refresh button is present + await expect(page.getByRole('button', { name: /Refresh/i })).toBeVisible(); + + // Verify the Open Project and New Project buttons are present + await expect(page.getByRole('button', { name: /Open Project/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /New Project/i })).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({ + success: true, + 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({ + success: true, + 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 (use .first() to avoid strict mode violation due to "Auto-mode active" also containing "active") + await expect(projectCard.getByText('Active').first()).toBeVisible(); + + // Verify auto-mode indicator is shown + await expect(projectCard.getByText('Auto-mode active')).toBeVisible(); + }); + + test('should navigate to board when clicking on a project card', 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({ + success: true, + projects: [ + { + projectId: 'test-project-1', + projectName: 'Test Project 1', + projectPath: '/mock/test-project-1', + healthStatus: 'idle', + featureCounts: { pending: 0, running: 0, completed: 0, failed: 0, verified: 0 }, + totalFeatures: 0, + isAutoModeRunning: false, + unreadNotificationCount: 0, + }, + ], + aggregate: { + projectCounts: { + total: 1, + active: 0, + idle: 1, + 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 project card is displayed (clicking it would navigate to board, but requires more mocking) + const projectCard = page.locator('[data-testid="project-status-card-test-project-1"]'); + await expect(projectCard).toBeVisible({ timeout: 10000 }); + }); + + 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({ + success: true, + 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 54d8cf3ce..f94b7ff9b 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -353,3 +353,19 @@ export type { TerminalInfo } from './terminal.js'; // Test runner types export type { TestRunnerInfo } from './test-runner.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..ec003cf1f --- /dev/null +++ b/tests/e2e/multi-project-dashboard.spec.ts @@ -0,0 +1,119 @@ +/** + * 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/); + } + }); +});