feat(ui): add Projects Overview link and button to sidebar and dashboard

- Introduced a new Projects Overview link in the sidebar footer for easy navigation.
- Added a button for Projects Overview in the dashboard view, enhancing accessibility to project insights.
- Updated types to include project overview-related definitions, supporting the new features.
This commit is contained in:
Shirone
2026-01-21 13:15:24 +01:00
parent db71dc9aa5
commit c55654b737
16 changed files with 2094 additions and 1 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}