mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge remote-tracking branch 'upstream/feature/v0.14.0rc-1768981697539-gg62' into feature/unified-sidebar
# Conflicts: # apps/server/src/index.ts # apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx # libs/types/src/index.ts
This commit is contained in:
@@ -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);
|
||||
|
||||
12
apps/server/src/routes/projects/common.ts
Normal file
12
apps/server/src/routes/projects/common.ts
Normal 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);
|
||||
27
apps/server/src/routes/projects/index.ts
Normal file
27
apps/server/src/routes/projects/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Projects routes - HTTP API for multi-project overview and management
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../services/notification-service.js';
|
||||
import { createOverviewHandler } from './routes/overview.js';
|
||||
|
||||
export function createProjectsRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /overview - Get aggregate status for all projects
|
||||
router.get(
|
||||
'/overview',
|
||||
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
297
apps/server/src/routes/projects/routes/overview.ts
Normal file
297
apps/server/src/routes/projects/routes/overview.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* GET /overview endpoint - Get aggregate status for all projects
|
||||
*
|
||||
* Returns a complete overview of all projects including:
|
||||
* - Individual project status (features, auto-mode state)
|
||||
* - Aggregate metrics across all projects
|
||||
* - Recent activity feed (placeholder for future implementation)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import type {
|
||||
ProjectStatus,
|
||||
AggregateStatus,
|
||||
MultiProjectOverview,
|
||||
FeatureStatusCounts,
|
||||
AggregateFeatureCounts,
|
||||
AggregateProjectCounts,
|
||||
ProjectHealthStatus,
|
||||
Feature,
|
||||
ProjectRef,
|
||||
} from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Compute feature status counts from a list of features
|
||||
*/
|
||||
function computeFeatureCounts(features: Feature[]): FeatureStatusCounts {
|
||||
const counts: FeatureStatusCounts = {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
for (const feature of features) {
|
||||
switch (feature.status) {
|
||||
case 'pending':
|
||||
case 'ready':
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'running':
|
||||
case 'generating_spec':
|
||||
case 'waiting_approval':
|
||||
counts.running++;
|
||||
break;
|
||||
case 'completed':
|
||||
counts.completed++;
|
||||
break;
|
||||
case 'failed':
|
||||
counts.failed++;
|
||||
break;
|
||||
case 'verified':
|
||||
counts.verified++;
|
||||
break;
|
||||
default:
|
||||
// Unknown status, treat as pending
|
||||
counts.pending++;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the overall health status of a project based on its feature statuses
|
||||
*/
|
||||
function computeHealthStatus(
|
||||
featureCounts: FeatureStatusCounts,
|
||||
isAutoModeRunning: boolean
|
||||
): ProjectHealthStatus {
|
||||
const totalFeatures =
|
||||
featureCounts.pending +
|
||||
featureCounts.running +
|
||||
featureCounts.completed +
|
||||
featureCounts.failed +
|
||||
featureCounts.verified;
|
||||
|
||||
// If there are failed features, the project has errors
|
||||
if (featureCounts.failed > 0) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// If there are running features or auto mode is running with pending work
|
||||
if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// If all features are completed or verified
|
||||
if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
// Default to idle
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent activity timestamp from features
|
||||
*/
|
||||
function getLastActivityAt(features: Feature[]): string | undefined {
|
||||
if (features.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let latestTimestamp: number = 0;
|
||||
|
||||
for (const feature of features) {
|
||||
// Check startedAt timestamp (the main timestamp available on Feature)
|
||||
if (feature.startedAt) {
|
||||
const timestamp = new Date(feature.startedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check planSpec timestamps if available
|
||||
if (feature.planSpec?.generatedAt) {
|
||||
const timestamp = new Date(feature.planSpec.generatedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
if (feature.planSpec?.approvedAt) {
|
||||
const timestamp = new Date(feature.planSpec.approvedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined;
|
||||
}
|
||||
|
||||
export function createOverviewHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Get all projects from settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const projectRefs: ProjectRef[] = settings.projects || [];
|
||||
|
||||
// Collect project statuses in parallel
|
||||
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<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;
|
||||
|
||||
// Get notification count for this project
|
||||
let unreadNotificationCount = 0;
|
||||
try {
|
||||
const notifications = await notificationService.getNotifications(projectRef.path);
|
||||
unreadNotificationCount = notifications.filter((n) => !n.read).length;
|
||||
} catch {
|
||||
// Ignore notification errors - project may not have any notifications yet
|
||||
}
|
||||
|
||||
// Compute health status
|
||||
const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning);
|
||||
|
||||
// Get last activity timestamp
|
||||
const lastActivityAt = getLastActivityAt(features);
|
||||
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus,
|
||||
featureCounts,
|
||||
totalFeatures,
|
||||
lastActivityAt,
|
||||
isAutoModeRunning,
|
||||
activeBranch: autoModeStatus.branchName ?? undefined,
|
||||
unreadNotificationCount,
|
||||
};
|
||||
} catch (error) {
|
||||
// Return a minimal status for projects that fail to load
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus: 'error' as ProjectHealthStatus,
|
||||
featureCounts: {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalFeatures: 0,
|
||||
isAutoModeRunning: false,
|
||||
unreadNotificationCount: 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const projectStatuses = await Promise.all(projectStatusPromises);
|
||||
|
||||
// Compute aggregate metrics
|
||||
const aggregateFeatureCounts: AggregateFeatureCounts = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
const aggregateProjectCounts: AggregateProjectCounts = {
|
||||
total: projectStatuses.length,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
};
|
||||
|
||||
let totalUnreadNotifications = 0;
|
||||
let projectsWithAutoModeRunning = 0;
|
||||
|
||||
for (const status of projectStatuses) {
|
||||
// Aggregate feature counts
|
||||
aggregateFeatureCounts.total += status.totalFeatures;
|
||||
aggregateFeatureCounts.pending += status.featureCounts.pending;
|
||||
aggregateFeatureCounts.running += status.featureCounts.running;
|
||||
aggregateFeatureCounts.completed += status.featureCounts.completed;
|
||||
aggregateFeatureCounts.failed += status.featureCounts.failed;
|
||||
aggregateFeatureCounts.verified += status.featureCounts.verified;
|
||||
|
||||
// Aggregate project counts by health status
|
||||
switch (status.healthStatus) {
|
||||
case 'active':
|
||||
aggregateProjectCounts.active++;
|
||||
break;
|
||||
case 'idle':
|
||||
aggregateProjectCounts.idle++;
|
||||
break;
|
||||
case 'waiting':
|
||||
aggregateProjectCounts.waiting++;
|
||||
break;
|
||||
case 'error':
|
||||
aggregateProjectCounts.withErrors++;
|
||||
break;
|
||||
case 'completed':
|
||||
aggregateProjectCounts.allCompleted++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregate notifications
|
||||
totalUnreadNotifications += status.unreadNotificationCount;
|
||||
|
||||
// Count projects with auto-mode running
|
||||
if (status.isAutoModeRunning) {
|
||||
projectsWithAutoModeRunning++;
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateStatus: AggregateStatus = {
|
||||
projectCounts: aggregateProjectCounts,
|
||||
featureCounts: aggregateFeatureCounts,
|
||||
totalUnreadNotifications,
|
||||
projectsWithAutoModeRunning,
|
||||
computedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Build the response (recentActivity is empty for now - can be populated later)
|
||||
const overview: MultiProjectOverview = {
|
||||
projects: projectStatuses,
|
||||
aggregate: aggregateStatus,
|
||||
recentActivity: [], // Placeholder for future activity feed implementation
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...overview,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get project overview failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,14 @@ import { useCallback } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { Activity, Settings, BookOpen, MessageSquare, ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
Activity,
|
||||
Settings,
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
ExternalLink,
|
||||
LayoutDashboard,
|
||||
} from 'lucide-react';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -217,7 +224,74 @@ 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'
|
||||
)}
|
||||
>
|
||||
{/* Projects Overview Link */}
|
||||
<div className="p-2 pb-0">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/overview' })}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
||||
'transition-all duration-200 ease-out',
|
||||
isActiveRoute('overview')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground font-medium',
|
||||
'border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={!sidebarOpen ? 'Projects Overview' : undefined}
|
||||
data-testid="projects-overview-link"
|
||||
>
|
||||
<LayoutDashboard
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActiveRoute('overview')
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Projects Overview
|
||||
</span>
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
>
|
||||
Projects Overview
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Running Agents Link */}
|
||||
{!hideRunningAgents && (
|
||||
<div className="px-3 py-0.5">
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Trash2,
|
||||
Search,
|
||||
X,
|
||||
LayoutDashboard,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@@ -556,9 +557,31 @@ export function DashboardView() {
|
||||
</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"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleOpenProject}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
283
apps/ui/src/components/views/overview-view.tsx
Normal file
283
apps/ui/src/components/views/overview-view.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* OverviewView - Multi-project dashboard showing status across all projects
|
||||
*
|
||||
* Provides a unified view of all projects with active features, running agents,
|
||||
* recent completions, and alerts. Quick navigation to any project or feature.
|
||||
*/
|
||||
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useMultiProjectStatus } from '@/hooks/use-multi-project-status';
|
||||
import { isElectron } from '@/lib/electron';
|
||||
import { isMac } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ProjectStatusCard } from './overview/project-status-card';
|
||||
import { RecentActivityFeed } from './overview/recent-activity-feed';
|
||||
import { RunningAgentsPanel } from './overview/running-agents-panel';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
RefreshCw,
|
||||
Folder,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Bot,
|
||||
Bell,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function OverviewView() {
|
||||
const navigate = useNavigate();
|
||||
const { overview, isLoading, error, refresh } = useMultiProjectStatus(15000); // Refresh every 15s
|
||||
|
||||
const handleBackToDashboard = () => {
|
||||
navigate({ to: '/dashboard' });
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={handleBackToDashboard} className="h-8 w-8">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<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">Projects Overview</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>
|
||||
</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>
|
||||
<Button variant="outline" onClick={handleBackToDashboard}>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</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()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
apps/ui/src/components/views/overview/project-status-card.tsx
Normal file
196
apps/ui/src/components/views/overview/project-status-card.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* ProjectStatusCard - Individual project card for multi-project dashboard
|
||||
*
|
||||
* Displays project health, feature counts, and agent status with quick navigation.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProjectStatus, ProjectHealthStatus } from '@automaker/types';
|
||||
import { Folder, Activity, CheckCircle2, XCircle, Clock, Pause, Bot, Bell } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface ProjectStatusCardProps {
|
||||
project: ProjectStatus;
|
||||
onProjectClick?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
const healthStatusConfig: Record<
|
||||
ProjectHealthStatus,
|
||||
{ icon: typeof Activity; color: string; label: string; bgColor: string }
|
||||
> = {
|
||||
active: {
|
||||
icon: Activity,
|
||||
color: 'text-green-500',
|
||||
label: 'Active',
|
||||
bgColor: 'bg-green-500/10',
|
||||
},
|
||||
idle: {
|
||||
icon: Pause,
|
||||
color: 'text-muted-foreground',
|
||||
label: 'Idle',
|
||||
bgColor: 'bg-muted/50',
|
||||
},
|
||||
waiting: {
|
||||
icon: Clock,
|
||||
color: 'text-yellow-500',
|
||||
label: 'Waiting',
|
||||
bgColor: 'bg-yellow-500/10',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-blue-500',
|
||||
label: 'Completed',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-500',
|
||||
label: 'Error',
|
||||
bgColor: 'bg-red-500/10',
|
||||
},
|
||||
};
|
||||
|
||||
export function ProjectStatusCard({ project, onProjectClick }: ProjectStatusCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { upsertAndSetCurrentProject } = useAppStore();
|
||||
|
||||
const statusConfig = healthStatusConfig[project.healthStatus];
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (onProjectClick) {
|
||||
onProjectClick(project.projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behavior: navigate to project
|
||||
try {
|
||||
const initResult = await initializeProject(project.projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to open project', {
|
||||
description: initResult.error || 'Unknown error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertAndSetCurrentProject(project.projectPath, project.projectName);
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
toast.error('Failed to open project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [project, onProjectClick, upsertAndSetCurrentProject, navigate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
206
apps/ui/src/components/views/overview/recent-activity-feed.tsx
Normal file
206
apps/ui/src/components/views/overview/recent-activity-feed.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* RecentActivityFeed - Timeline of recent activity across all projects
|
||||
*
|
||||
* Shows completed features, failures, and auto-mode events.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RecentActivity, ActivityType, ActivitySeverity } from '@automaker/types';
|
||||
import { CheckCircle2, XCircle, Play, Bot, AlertTriangle, Info, Clock } from 'lucide-react';
|
||||
|
||||
interface RecentActivityFeedProps {
|
||||
activities: RecentActivity[];
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
const activityTypeConfig: Record<
|
||||
ActivityType,
|
||||
{ icon: typeof CheckCircle2; defaultColor: string; label: string }
|
||||
> = {
|
||||
feature_created: {
|
||||
icon: Info,
|
||||
defaultColor: 'text-blue-500',
|
||||
label: 'Feature created',
|
||||
},
|
||||
feature_completed: {
|
||||
icon: CheckCircle2,
|
||||
defaultColor: 'text-blue-500',
|
||||
label: 'Feature completed',
|
||||
},
|
||||
feature_verified: {
|
||||
icon: CheckCircle2,
|
||||
defaultColor: 'text-purple-500',
|
||||
label: 'Feature verified',
|
||||
},
|
||||
feature_failed: {
|
||||
icon: XCircle,
|
||||
defaultColor: 'text-red-500',
|
||||
label: 'Feature failed',
|
||||
},
|
||||
feature_started: {
|
||||
icon: Play,
|
||||
defaultColor: 'text-green-500',
|
||||
label: 'Feature started',
|
||||
},
|
||||
auto_mode_started: {
|
||||
icon: Bot,
|
||||
defaultColor: 'text-green-500',
|
||||
label: 'Auto-mode started',
|
||||
},
|
||||
auto_mode_stopped: {
|
||||
icon: Bot,
|
||||
defaultColor: 'text-muted-foreground',
|
||||
label: 'Auto-mode stopped',
|
||||
},
|
||||
ideation_session_started: {
|
||||
icon: Play,
|
||||
defaultColor: 'text-brand-500',
|
||||
label: 'Ideation session started',
|
||||
},
|
||||
ideation_session_ended: {
|
||||
icon: Info,
|
||||
defaultColor: 'text-muted-foreground',
|
||||
label: 'Ideation session ended',
|
||||
},
|
||||
idea_created: {
|
||||
icon: Info,
|
||||
defaultColor: 'text-brand-500',
|
||||
label: 'Idea created',
|
||||
},
|
||||
idea_converted: {
|
||||
icon: CheckCircle2,
|
||||
defaultColor: 'text-green-500',
|
||||
label: 'Idea converted to feature',
|
||||
},
|
||||
notification_created: {
|
||||
icon: AlertTriangle,
|
||||
defaultColor: 'text-yellow-500',
|
||||
label: 'Notification',
|
||||
},
|
||||
project_opened: {
|
||||
icon: Info,
|
||||
defaultColor: 'text-blue-500',
|
||||
label: 'Project opened',
|
||||
},
|
||||
};
|
||||
|
||||
const severityColors: Record<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 {
|
||||
const initResult = await initializeProject(
|
||||
// We need to find the project path - use projectId as workaround
|
||||
// In real implementation, this would look up the path from projects list
|
||||
activity.projectId
|
||||
);
|
||||
|
||||
// Navigate to the project
|
||||
const projectPath = activity.projectId;
|
||||
const projectName = activity.projectName;
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName);
|
||||
|
||||
if (activity.featureId) {
|
||||
// Navigate to the specific feature
|
||||
navigate({ to: '/board', search: { featureId: activity.featureId } });
|
||||
} else {
|
||||
navigate({ to: '/board' });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to navigate to activity', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[navigate, upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
if (displayActivities.length === 0) {
|
||||
return (
|
||||
<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}
|
||||
className="group flex items-start gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => handleActivityClick(activity)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
127
apps/ui/src/components/views/overview/running-agents-panel.tsx
Normal file
127
apps/ui/src/components/views/overview/running-agents-panel.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* RunningAgentsPanel - Shows all currently running agents across projects
|
||||
*
|
||||
* Displays active AI agents with their status and quick access to features.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProjectStatus } from '@automaker/types';
|
||||
import { Bot, Activity, Folder, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface RunningAgentsPanelProps {
|
||||
projects: ProjectStatus[];
|
||||
}
|
||||
|
||||
interface RunningAgent {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
featureCount: number;
|
||||
isAutoMode: boolean;
|
||||
activeBranch?: string;
|
||||
}
|
||||
|
||||
export function RunningAgentsPanel({ projects }: RunningAgentsPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const { upsertAndSetCurrentProject } = useAppStore();
|
||||
|
||||
// Extract running agents from projects
|
||||
const runningAgents: RunningAgent[] = projects
|
||||
.filter((p) => p.isAutoModeRunning || p.featureCounts.running > 0)
|
||||
.map((p) => ({
|
||||
projectId: p.projectId,
|
||||
projectName: p.projectName,
|
||||
projectPath: p.projectPath,
|
||||
featureCount: p.featureCounts.running,
|
||||
isAutoMode: p.isAutoModeRunning,
|
||||
activeBranch: p.activeBranch,
|
||||
}));
|
||||
|
||||
const handleAgentClick = useCallback(
|
||||
async (agent: RunningAgent) => {
|
||||
try {
|
||||
const initResult = await initializeProject(agent.projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to open project', {
|
||||
description: initResult.error || 'Unknown error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertAndSetCurrentProject(agent.projectPath, agent.projectName);
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
toast.error('Failed to navigate to agent', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[navigate, upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
if (runningAgents.length === 0) {
|
||||
return (
|
||||
<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}
|
||||
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)}
|
||||
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">
|
||||
<Folder 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>
|
||||
);
|
||||
}
|
||||
121
apps/ui/src/hooks/use-multi-project-status.ts
Normal file
121
apps/ui/src/hooks/use-multi-project-status.ts
Normal 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?.message || 'Failed to fetch project overview');
|
||||
}
|
||||
|
||||
return {
|
||||
projects: data.projects,
|
||||
aggregate: data.aggregate,
|
||||
recentActivity: data.recentActivity,
|
||||
generatedAt: data.generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage multi-project overview data
|
||||
*
|
||||
* @param refreshInterval - Optional interval in ms to auto-refresh (default: 30000)
|
||||
*/
|
||||
export function useMultiProjectStatus(refreshInterval = 30000): UseMultiProjectStatusResult {
|
||||
const [overview, setOverview] = useState<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,
|
||||
};
|
||||
}
|
||||
6
apps/ui/src/routes/overview.tsx
Normal file
6
apps/ui/src/routes/overview.tsx
Normal 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,
|
||||
});
|
||||
350
apps/ui/tests/projects/overview-dashboard.spec.ts
Normal file
350
apps/ui/tests/projects/overview-dashboard.spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Projects Overview Dashboard End-to-End Test
|
||||
*
|
||||
* Tests the multi-project overview dashboard that shows status across all projects.
|
||||
* This verifies that:
|
||||
* 1. The overview view can be accessed via the sidebar
|
||||
* 2. The overview displays aggregate statistics
|
||||
* 3. Navigation back to dashboard works correctly
|
||||
* 4. The UI responds to API data correctly
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
setupMockMultipleProjects,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
test.describe('Projects Overview Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Set up mock projects state
|
||||
await setupMockMultipleProjects(page, 3);
|
||||
await authenticateForTests(page);
|
||||
});
|
||||
|
||||
test('should navigate to overview from sidebar and display overview UI', async ({ page }) => {
|
||||
// Go to the app
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the board view to load
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Expand sidebar if collapsed
|
||||
const expandSidebarButton = page.locator('button:has-text("Expand sidebar")');
|
||||
if (await expandSidebarButton.isVisible()) {
|
||||
await expandSidebarButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Click on the Projects Overview link in the sidebar
|
||||
const overviewLink = page.locator('[data-testid="projects-overview-link"]');
|
||||
await expect(overviewLink).toBeVisible({ timeout: 5000 });
|
||||
await overviewLink.click();
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify the header is visible with title
|
||||
await expect(page.getByText('Projects Overview')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the refresh button is present
|
||||
await expect(page.getByRole('button', { name: /Refresh/i })).toBeVisible();
|
||||
|
||||
// Verify the back button is present (navigates to dashboard)
|
||||
const backButton = page
|
||||
.locator('button')
|
||||
.filter({ has: page.locator('svg') })
|
||||
.first();
|
||||
await expect(backButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display aggregate statistics cards', async ({ page }) => {
|
||||
// Mock the projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
projects: [
|
||||
{
|
||||
projectId: 'test-project-1',
|
||||
projectName: 'Test Project 1',
|
||||
projectPath: '/mock/test-project-1',
|
||||
healthStatus: 'active',
|
||||
featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 },
|
||||
totalFeatures: 8,
|
||||
isAutoModeRunning: true,
|
||||
unreadNotificationCount: 1,
|
||||
},
|
||||
{
|
||||
projectId: 'test-project-2',
|
||||
projectName: 'Test Project 2',
|
||||
projectPath: '/mock/test-project-2',
|
||||
healthStatus: 'idle',
|
||||
featureCounts: { pending: 5, running: 0, completed: 10, failed: 1, verified: 8 },
|
||||
totalFeatures: 24,
|
||||
isAutoModeRunning: false,
|
||||
unreadNotificationCount: 0,
|
||||
},
|
||||
],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 2,
|
||||
active: 1,
|
||||
idle: 1,
|
||||
waiting: 0,
|
||||
withErrors: 1,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 32,
|
||||
pending: 7,
|
||||
running: 1,
|
||||
completed: 13,
|
||||
failed: 1,
|
||||
verified: 10,
|
||||
},
|
||||
totalUnreadNotifications: 1,
|
||||
projectsWithAutoModeRunning: 1,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'activity-1',
|
||||
projectId: 'test-project-1',
|
||||
projectName: 'Test Project 1',
|
||||
type: 'feature_completed',
|
||||
description: 'Feature completed: Add login form',
|
||||
severity: 'success',
|
||||
timestamp: new Date().toISOString(),
|
||||
featureId: 'feature-1',
|
||||
featureTitle: 'Add login form',
|
||||
},
|
||||
],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify aggregate stat cards are displayed
|
||||
// Projects count card
|
||||
await expect(page.getByText('Projects').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Running features card
|
||||
await expect(page.getByText('Running').first()).toBeVisible();
|
||||
|
||||
// Pending features card
|
||||
await expect(page.getByText('Pending').first()).toBeVisible();
|
||||
|
||||
// Completed features card
|
||||
await expect(page.getByText('Completed').first()).toBeVisible();
|
||||
|
||||
// Auto-mode card
|
||||
await expect(page.getByText('Auto-mode').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display project status cards', async ({ page }) => {
|
||||
// Mock the projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
projects: [
|
||||
{
|
||||
projectId: 'test-project-1',
|
||||
projectName: 'Test Project 1',
|
||||
projectPath: '/mock/test-project-1',
|
||||
healthStatus: 'active',
|
||||
featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 },
|
||||
totalFeatures: 8,
|
||||
isAutoModeRunning: true,
|
||||
unreadNotificationCount: 1,
|
||||
},
|
||||
],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 1,
|
||||
active: 1,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 8,
|
||||
pending: 2,
|
||||
running: 1,
|
||||
completed: 3,
|
||||
failed: 0,
|
||||
verified: 2,
|
||||
},
|
||||
totalUnreadNotifications: 1,
|
||||
projectsWithAutoModeRunning: 1,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify project status card is displayed
|
||||
const projectCard = page.locator('[data-testid="project-status-card-test-project-1"]');
|
||||
await expect(projectCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify project name is displayed
|
||||
await expect(projectCard.getByText('Test Project 1')).toBeVisible();
|
||||
|
||||
// Verify the Active status badge
|
||||
await expect(projectCard.getByText('Active')).toBeVisible();
|
||||
|
||||
// Verify auto-mode indicator is shown
|
||||
await expect(projectCard.getByText('Auto-mode active')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate back to dashboard when clicking back button', async ({ page }) => {
|
||||
// Mock the projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
projects: [],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalUnreadNotifications: 0,
|
||||
projectsWithAutoModeRunning: 0,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the back button (first button in the header with ArrowLeft icon)
|
||||
const backButton = page.locator('[data-testid="overview-view"] header button').first();
|
||||
await backButton.click();
|
||||
|
||||
// Wait for navigation to dashboard
|
||||
await expect(page.locator('[data-testid="dashboard-view"]')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('should display empty state when no projects exist', async ({ page }) => {
|
||||
// Mock empty projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
projects: [],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalUnreadNotifications: 0,
|
||||
projectsWithAutoModeRunning: 0,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify empty state message
|
||||
await expect(page.getByText('No projects yet')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Create or open a project to get started')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error state when API fails', async ({ page }) => {
|
||||
// Mock API error
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify error state message
|
||||
await expect(page.getByText('Failed to load overview')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify the "Try again" button is visible
|
||||
await expect(page.getByRole('button', { name: /Try again/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
244
libs/types/src/project-overview.ts
Normal file
244
libs/types/src/project-overview.ts
Normal 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[];
|
||||
}
|
||||
119
tests/e2e/multi-project-dashboard.spec.ts
Normal file
119
tests/e2e/multi-project-dashboard.spec.ts
Normal 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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user