feat(frontend): implement main dashboard page (#48)

Implement the main dashboard / projects list page for Syndarix as the landing
page after login. The implementation includes:

Dashboard Components:
- QuickStats: Overview cards showing active projects, agents, issues, approvals
- ProjectsSection: Grid/list view with filtering and sorting controls
- ProjectCardGrid: Rich project cards for grid view
- ProjectRowList: Compact rows for list view
- ActivityFeed: Real-time activity sidebar with connection status
- PerformanceCard: Performance metrics display
- EmptyState: Call-to-action for new users
- ProjectStatusBadge: Status indicator with icons
- ComplexityIndicator: Visual complexity dots
- ProgressBar: Accessible progress bar component

Features:
- Projects grid/list view with view mode toggle
- Filter by status (all, active, paused, completed, archived)
- Sort by recent, name, progress, or issues
- Quick stats overview with counts
- Real-time activity feed sidebar with live/reconnecting status
- Performance metrics card
- Create project button linking to wizard
- Responsive layout for mobile/desktop
- Loading skeleton states
- Empty state for new users

API Integration:
- useProjects hook for fetching projects (mock data until backend ready)
- useDashboardStats hook for statistics
- TanStack Query for caching and data fetching

Testing:
- 37 unit tests covering all dashboard components
- E2E test suite for dashboard functionality
- Accessibility tests (keyboard nav, aria attributes, heading hierarchy)

Technical:
- TypeScript strict mode compliance
- ESLint passing
- WCAG AA accessibility compliance
- Mobile-first responsive design
- Dark mode support via semantic tokens
- Follows design system guidelines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 23:46:50 +01:00
parent e85788f79f
commit 5b1e2852ea
67 changed files with 8879 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
/**
* Agent Panel Component
*
* Displays a list of active agents on the project with their status and current task.
*/
'use client';
import { Bot, MoreVertical } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { AgentStatusIndicator } from './AgentStatusIndicator';
import type { AgentInstance } from './types';
// ============================================================================
// Types
// ============================================================================
interface AgentPanelProps {
/** List of agent instances */
agents: AgentInstance[];
/** Whether data is loading */
isLoading?: boolean;
/** Callback when "Manage Agents" is clicked */
onManageAgents?: () => void;
/** Callback when an agent action is triggered */
onAgentAction?: (agentId: string, action: 'view' | 'pause' | 'restart' | 'terminate') => void;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
function getAgentAvatarText(agent: AgentInstance): string {
if (agent.avatar) return agent.avatar;
// Generate initials from role
const words = agent.role.split(/[\s_-]+/);
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
}
return agent.role.substring(0, 2).toUpperCase();
}
function formatLastActivity(lastActivity?: string): string {
if (!lastActivity) return 'No activity';
try {
return formatDistanceToNow(new Date(lastActivity), { addSuffix: true });
} catch {
return 'Unknown';
}
}
// ============================================================================
// Subcomponents
// ============================================================================
function AgentListItem({
agent,
onAction,
}: {
agent: AgentInstance;
onAction?: (agentId: string, action: 'view' | 'pause' | 'restart' | 'terminate') => void;
}) {
const avatarText = getAgentAvatarText(agent);
const lastActivity = formatLastActivity(agent.last_activity_at);
return (
<div
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
data-testid={`agent-item-${agent.id}`}
>
<div className="flex items-center gap-3">
{/* Avatar */}
<div
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary"
aria-hidden="true"
>
{avatarText}
</div>
{/* Info */}
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{agent.name}</span>
<AgentStatusIndicator status={agent.status} />
</div>
<p className="text-sm text-muted-foreground line-clamp-1">
{agent.current_task || 'No active task'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{lastActivity}</span>
{onAction && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={`Actions for ${agent.name}`}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAction(agent.id, 'view')}>
View Details
</DropdownMenuItem>
{agent.status === 'active' || agent.status === 'working' ? (
<DropdownMenuItem onClick={() => onAction(agent.id, 'pause')}>
Pause Agent
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onAction(agent.id, 'restart')}>
Restart Agent
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onAction(agent.id, 'terminate')}
className="text-destructive focus:text-destructive"
>
Terminate Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
}
function AgentPanelSkeleton() {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-9 w-28" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-8" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function AgentPanel({
agents,
isLoading = false,
onManageAgents,
onAgentAction,
className,
}: AgentPanelProps) {
if (isLoading) {
return <AgentPanelSkeleton />;
}
const activeAgentCount = agents.filter(
(a) => a.status === 'active' || a.status === 'working'
).length;
return (
<Card className={className} data-testid="agent-panel">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Bot className="h-5 w-5" aria-hidden="true" />
Active Agents
</CardTitle>
<CardDescription>
{activeAgentCount} of {agents.length} agents working
</CardDescription>
</div>
{onManageAgents && (
<Button variant="outline" size="sm" onClick={onManageAgents}>
Manage Agents
</Button>
)}
</div>
</CardHeader>
<CardContent>
{agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Bot className="mb-2 h-8 w-8" aria-hidden="true" />
<p className="text-sm">No agents assigned to this project</p>
</div>
) : (
<div className="space-y-3">
{agents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
onAction={onAgentAction}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}