Merge pull request #644 from AutoMaker-Org/feature/v0.14.0rc-1768981697539-gg62

feat: projects overview dashboard
This commit is contained in:
Shirone
2026-01-23 09:13:31 +00:00
committed by GitHub
20 changed files with 2389 additions and 6 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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<void> => {
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<ProjectStatus> => {
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) });
}
};
}

View File

@@ -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 */}

View File

@@ -217,7 +217,15 @@ export function SidebarFooter({
// Expanded state
return (
<div className="shrink-0">
<div
className={cn(
'shrink-0',
// Top border with gradient fade
'border-t border-border/40',
// Elevated background for visual separation
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)}
>
{/* Running Agents Link */}
{!hideRunningAgents && (
<div className="px-3 py-0.5">

View File

@@ -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(

View File

@@ -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

View File

@@ -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,
},

View File

@@ -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() {
</div>
</div>
{/* Projects Overview button */}
{hasProjects && (
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: '/overview' })}
className="hidden sm:flex gap-2 titlebar-no-drag"
data-testid="projects-overview-button"
>
<LayoutDashboard className="w-4 h-4" />
Overview
</Button>
)}
{/* Mobile action buttons in header */}
{hasProjects && (
<div className="flex sm:hidden gap-2 titlebar-no-drag">
<Button
variant="outline"
size="icon"
onClick={() => navigate({ to: '/overview' })}
title="Projects Overview"
data-testid="projects-overview-button-mobile"
>
<LayoutDashboard className="w-4 h-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleOpenProject}>
<FolderOpen className="w-4 h-4" />
</Button>

View File

@@ -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`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>Describe your project here.</overview>
<technology_stack></technology_stack>
<core_capabilities></core_capabilities>
<implemented_features></implemented_features>
</project_specification>`
);
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 (
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="overview-view">
{/* Header */}
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
{/* Electron titlebar drag region */}
{isElectron() && (
<div
className={`absolute top-0 left-0 right-0 h-6 titlebar-drag-region z-40 pointer-events-none ${isMac ? 'pl-20' : ''}`}
aria-hidden="true"
/>
)}
<div className="px-4 sm:px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3 titlebar-no-drag">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
<LayoutDashboard className="w-4 h-4 text-brand-500" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">Automaker Dashboard</h1>
<p className="text-xs text-muted-foreground">
{overview ? `${overview.aggregate.projectCounts.total} projects` : 'Loading...'}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 titlebar-no-drag">
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button variant="outline" size="sm" onClick={handleOpenProject} className="gap-2">
<FolderOpen className="w-4 h-4" />
Open Project
</Button>
<Button
size="sm"
onClick={() => setShowNewProjectModal(true)}
className="gap-2 bg-brand-500 hover:bg-brand-600 text-white"
>
<Plus className="w-4 h-4" />
New Project
</Button>
</div>
</div>
</header>
{/* Main content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
{/* Loading state */}
{isLoading && !overview && (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4">
<Spinner size="lg" />
<p className="text-sm text-muted-foreground">Loading project overview...</p>
</div>
</div>
)}
{/* Error state */}
{error && !overview && (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4 text-center">
<div className="w-12 h-12 rounded-full bg-red-500/10 flex items-center justify-center">
<XCircle className="w-6 h-6 text-red-500" />
</div>
<div>
<h3 className="font-medium text-foreground mb-1">Failed to load overview</h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<Button variant="outline" size="sm" onClick={refresh}>
Try again
</Button>
</div>
</div>
</div>
)}
{/* Content */}
{overview && (
<div className="max-w-7xl mx-auto space-y-6">
{/* Aggregate stats */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<Card className="bg-card/60">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
<Folder className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">
{overview.aggregate.projectCounts.total}
</p>
<p className="text-xs text-muted-foreground">Projects</p>
</div>
</CardContent>
</Card>
<Card className="bg-card/60">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<Activity className="w-5 h-5 text-green-500" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">
{overview.aggregate.featureCounts.running}
</p>
<p className="text-xs text-muted-foreground">Running</p>
</div>
</CardContent>
</Card>
<Card className="bg-card/60">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-yellow-500" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">
{overview.aggregate.featureCounts.pending}
</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</CardContent>
</Card>
<Card className="bg-card/60">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<CheckCircle2 className="w-5 h-5 text-blue-500" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">
{overview.aggregate.featureCounts.completed}
</p>
<p className="text-xs text-muted-foreground">Completed</p>
</div>
</CardContent>
</Card>
<Card className="bg-card/60">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center">
<XCircle className="w-5 h-5 text-red-500" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">
{overview.aggregate.featureCounts.failed}
</p>
<p className="text-xs text-muted-foreground">Failed</p>
</div>
</CardContent>
</Card>
<Card className="bg-card/60">
<CardContent className="p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center">
<Bot className="w-5 h-5 text-brand-500" />
</div>
<div>
<p className="text-2xl font-bold text-foreground">
{overview.aggregate.projectsWithAutoModeRunning}
</p>
<p className="text-xs text-muted-foreground">Auto-mode</p>
</div>
</CardContent>
</Card>
</div>
{/* Main content grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column: Project cards */}
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">All Projects</h2>
{overview.aggregate.totalUnreadNotifications > 0 && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Bell className="w-4 h-4" />
{overview.aggregate.totalUnreadNotifications} unread notifications
</div>
)}
</div>
{overview.projects.length === 0 ? (
<Card className="bg-card/60">
<CardContent className="py-12 text-center">
<Folder className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="font-medium text-foreground mb-1">No projects yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Create or open a project to get started
</p>
<p className="text-sm text-muted-foreground">
Use the sidebar to create or open a project
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{overview.projects.map((project) => (
<ProjectStatusCard key={project.projectId} project={project} />
))}
</div>
)}
</div>
{/* Right column: Running agents and activity */}
<div className="space-y-4">
{/* Running agents */}
<Card className="bg-card/60">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Bot className="w-4 h-4 text-green-500" />
Running Agents
{overview.aggregate.projectsWithAutoModeRunning > 0 && (
<span className="text-xs font-normal text-muted-foreground ml-auto">
{overview.aggregate.projectsWithAutoModeRunning} active
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<RunningAgentsPanel projects={overview.projects} />
</CardContent>
</Card>
{/* Recent activity */}
<Card className="bg-card/60">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="w-4 h-4 text-brand-500" />
Recent Activity
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<RecentActivityFeed activities={overview.recentActivity} maxItems={8} />
</CardContent>
</Card>
</div>
</div>
{/* Footer timestamp */}
<div className="text-center text-xs text-muted-foreground pt-4">
Last updated:{' '}
{new Date(overview.generatedAt).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
</div>
)}
</div>
{/* Modals */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreating}
/>
<WorkspacePickerModal
open={showWorkspacePicker}
onOpenChange={setShowWorkspacePicker}
onSelect={handleWorkspaceSelect}
/>
</div>
);
}

View File

@@ -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 (
<div
role="button"
tabIndex={0}
className={cn(
'group relative rounded-xl border bg-card/60 backdrop-blur-sm transition-all duration-300 cursor-pointer hover:-translate-y-0.5',
project.healthStatus === 'active' && 'border-green-500/30 hover:border-green-500/50',
project.healthStatus === 'error' && 'border-red-500/30 hover:border-red-500/50',
project.healthStatus === 'waiting' && 'border-yellow-500/30 hover:border-yellow-500/50',
project.healthStatus === 'completed' && 'border-blue-500/30 hover:border-blue-500/50',
project.healthStatus === 'idle' && 'border-border hover:border-brand-500/40'
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
aria-label={`Open project ${project.projectName}`}
data-testid={`project-status-card-${project.projectId}`}
>
<div className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 min-w-0">
<div
className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center shrink-0 transition-colors',
statusConfig.bgColor
)}
>
<Folder className={cn('w-5 h-5', statusConfig.color)} />
</div>
<div className="min-w-0">
<h3 className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors">
{project.projectName}
</h3>
<p className="text-xs text-muted-foreground truncate">{project.projectPath}</p>
</div>
</div>
{/* Status badge */}
<div className="flex items-center gap-2 shrink-0">
{project.unreadNotificationCount > 0 && (
<Badge variant="destructive" className="h-5 px-1.5 text-xs">
<Bell className="w-3 h-3 mr-1" />
{project.unreadNotificationCount}
</Badge>
)}
<Badge
variant="outline"
className={cn(
'h-6 px-2 text-xs gap-1',
statusConfig.color,
project.healthStatus === 'active' && 'border-green-500/30 bg-green-500/10',
project.healthStatus === 'error' && 'border-red-500/30 bg-red-500/10'
)}
>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</Badge>
</div>
</div>
{/* Feature counts */}
<div className="flex flex-wrap gap-2 mb-3">
{project.featureCounts.running > 0 && (
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400">
<Activity className="w-3 h-3" />
{project.featureCounts.running} running
</div>
)}
{project.featureCounts.pending > 0 && (
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
<Clock className="w-3 h-3" />
{project.featureCounts.pending} pending
</div>
)}
{project.featureCounts.completed > 0 && (
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400">
<CheckCircle2 className="w-3 h-3" />
{project.featureCounts.completed} completed
</div>
)}
{project.featureCounts.failed > 0 && (
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-red-500/10 text-red-600 dark:text-red-400">
<XCircle className="w-3 h-3" />
{project.featureCounts.failed} failed
</div>
)}
{project.featureCounts.verified > 0 && (
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-purple-500/10 text-purple-600 dark:text-purple-400">
<CheckCircle2 className="w-3 h-3" />
{project.featureCounts.verified} verified
</div>
)}
</div>
{/* Footer: Total features and auto-mode status */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border/50">
<span>{project.totalFeatures} total features</span>
{project.isAutoModeRunning && (
<div className="flex items-center gap-1.5 text-green-500">
<Bot className="w-3.5 h-3.5 animate-pulse" />
<span className="font-medium">Auto-mode active</span>
</div>
)}
{project.lastActivityAt && !project.isAutoModeRunning && (
<span>Last activity: {new Date(project.lastActivityAt).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<ActivitySeverity, string> = {
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 (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Clock className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">No recent activity</p>
</div>
);
}
return (
<div className="space-y-1">
{displayActivities.map((activity) => {
const config = activityTypeConfig[activity.type];
const Icon = config.icon;
const iconColor = severityColors[activity.severity] || config.defaultColor;
return (
<div
key={activity.id}
role="button"
tabIndex={0}
className="group flex items-start gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => 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 */}
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5',
activity.severity === 'error' && 'bg-red-500/10',
activity.severity === 'success' && 'bg-green-500/10',
activity.severity === 'warning' && 'bg-yellow-500/10',
activity.severity === 'info' && 'bg-blue-500/10'
)}
>
<Icon className={cn('w-4 h-4', iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
{activity.projectName}
</span>
<span className="text-xs text-muted-foreground/50">
{formatRelativeTime(activity.timestamp)}
</span>
</div>
<p className="text-sm text-foreground truncate group-hover:text-brand-500 transition-colors">
{activity.featureTitle || activity.description}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">{config.label}</p>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Bot className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">No agents running</p>
<p className="text-xs mt-1">Start auto-mode on a project to see activity here</p>
</div>
);
}
return (
<div className="space-y-2">
{runningAgents.map((agent) => (
<div
key={agent.projectId}
role="button"
tabIndex={0}
className="group flex items-center gap-3 p-3 rounded-lg border border-green-500/20 bg-green-500/5 hover:bg-green-500/10 cursor-pointer transition-all"
onClick={() => handleAgentClick(agent)}
onKeyDown={(e) => handleAgentKeyDown(e, agent)}
aria-label={`View running agent for ${agent.projectName}`}
data-testid={`running-agent-${agent.projectId}`}
>
{/* Animated icon */}
<div className="relative w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center shrink-0">
<Bot className="w-5 h-5 text-green-500" />
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full animate-pulse" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate group-hover:text-green-500 transition-colors">
{agent.projectName}
</span>
{agent.isAutoMode && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-500 font-medium">
Auto
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
{agent.featureCount > 0 && (
<span className="flex items-center gap-1">
<Activity className="w-3 h-3" />
{agent.featureCount} feature{agent.featureCount !== 1 ? 's' : ''} running
</span>
)}
{agent.activeBranch && (
<span className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{agent.activeBranch}
</span>
)}
</div>
</div>
{/* Arrow */}
<ArrowRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
))}
</div>
);
}

View File

@@ -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<void>;
}
/**
* Custom fetch function for projects overview
* Uses the same pattern as HttpApiClient for proper authentication
*/
async function fetchProjectsOverview(): Promise<MultiProjectOverview> {
// Ensure API key is initialized before making request (handles Electron/web mode timing)
await waitForApiKeyInit();
const serverUrl = getServerUrlSync();
const headers: Record<string, string> = {
'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<MultiProjectOverview | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { OverviewView } from '@/components/views/overview-view';
export const Route = createFileRoute('/overview')({
component: OverviewView,
});

View File

@@ -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();
});
});

View File

@@ -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';

View File

@@ -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[];
}

View File

@@ -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/);
}
});
});