feat(frontend): add Projects, Agents, and Settings pages for enhanced project management

- Added routing and localization for "Projects" and "Agents" in `Header.tsx`.
- Introduced `ProjectAgentsPage` to manage and display agent details per project.
- Added `ProjectActivityPage` for real-time event tracking and approval workflows.
- Implemented `ProjectSettingsPage` for project configuration, including autonomy levels and repository integration.
- Updated language files (`en.json`, `it.json`) with new translations for "Projects" and "Agents".
This commit is contained in:
2026-01-03 02:12:26 +01:00
parent 664415111a
commit fe2104822e
6 changed files with 798 additions and 2 deletions

View File

@@ -32,7 +32,9 @@
"demos": "Demos",
"design": "Design System",
"admin": "Admin",
"adminPanel": "Admin Panel"
"adminPanel": "Admin Panel",
"projects": "Projects",
"agents": "Agents"
},
"auth": {
"login": {

View File

@@ -32,7 +32,9 @@
"demos": "Demo",
"design": "Design System",
"admin": "Admin",
"adminPanel": "Pannello Admin"
"adminPanel": "Pannello Admin",
"projects": "Progetti",
"agents": "Agenti"
},
"auth": {
"login": {

View File

@@ -0,0 +1,249 @@
'use client';
/**
* Project Activity Page
*
* Full-page view of real-time project activity with:
* - Real-time SSE connection for project events
* - Event filtering and search
* - Time-based grouping
* - Approval handling
*
* @module app/[locale]/(authenticated)/projects/[id]/activity/page
*/
import { use, useState, useCallback } from 'react';
import { useRouter } from '@/lib/i18n/routing';
import { ArrowLeft, RefreshCw, Bell, BellOff, AlertTriangle } from 'lucide-react';
import { useProjectEvents } from '@/lib/hooks/useProjectEvents';
import { ActivityFeed } from '@/components/activity';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EventType, type ProjectEvent } from '@/lib/types/events';
import { toast } from 'sonner';
interface ProjectActivityPageProps {
params: Promise<{
locale: string;
id: string;
}>;
}
// Demo events for when SSE is not connected
function getDemoEvents(projectId: string): ProjectEvent[] {
return [
{
id: 'demo-001',
type: EventType.APPROVAL_REQUESTED,
timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
project_id: projectId,
actor_id: 'agent-001',
actor_type: 'agent',
payload: {
approval_id: 'apr-001',
approval_type: 'architecture_decision',
description: 'Approval required for API design document for the checkout flow.',
requested_by: 'Architect',
timeout_minutes: 60,
},
},
{
id: 'demo-002',
type: EventType.AGENT_MESSAGE,
timestamp: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
project_id: projectId,
actor_id: 'agent-002',
actor_type: 'agent',
payload: {
agent_instance_id: 'agent-002',
message: 'Completed JWT token generation and validation. Moving on to session management.',
message_type: 'info',
metadata: { progress: 65 },
},
},
{
id: 'demo-003',
type: EventType.AGENT_STATUS_CHANGED,
timestamp: new Date(Date.now() - 1000 * 60 * 20).toISOString(),
project_id: projectId,
actor_id: 'agent-003',
actor_type: 'agent',
payload: {
agent_instance_id: 'agent-003',
previous_status: 'idle',
new_status: 'active',
reason: 'Started working on product catalog component',
},
},
{
id: 'demo-004',
type: EventType.ISSUE_UPDATED,
timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
project_id: projectId,
actor_id: 'agent-002',
actor_type: 'agent',
payload: {
issue_id: 'issue-038',
changes: { status: { from: 'in_progress', to: 'in_review' } },
},
},
{
id: 'demo-005',
type: EventType.SPRINT_STARTED,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
project_id: projectId,
actor_id: null,
actor_type: 'system',
payload: {
sprint_id: 'sprint-003',
sprint_name: 'Sprint 3 - Checkout Flow',
goal: 'Complete checkout and payment integration',
issue_count: 15,
},
},
{
id: 'demo-006',
type: EventType.ISSUE_CREATED,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(),
project_id: projectId,
actor_id: 'agent-001',
actor_type: 'agent',
payload: {
issue_id: 'issue-050',
title: 'Implement shopping cart persistence',
priority: 'high',
labels: ['feature', 'sprint-3'],
},
},
];
}
export default function ProjectActivityPage({ params }: ProjectActivityPageProps) {
const { id: projectId } = use(params);
const router = useRouter();
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
// SSE hook for real-time events
const {
events: sseEvents,
connectionState,
reconnect,
isConnected,
} = useProjectEvents(projectId, {
autoConnect: true,
onEvent: (event) => {
// Show notification for new events if enabled
if (notificationsEnabled && event.type === EventType.APPROVAL_REQUESTED) {
toast.info('New approval request', {
description: 'An agent is requesting your approval.',
});
}
},
});
// Use demo events when not connected or no events received
const demoEvents = getDemoEvents(projectId);
const events = isConnected && sseEvents.length > 0 ? sseEvents : demoEvents;
const handleBack = useCallback(() => {
router.push(`/projects/${projectId}`);
}, [router, projectId]);
// Approval handlers
const handleApprove = useCallback((event: ProjectEvent) => {
// TODO: Call API to approve the request
toast.success('Approval granted', {
description: `Approved request ${event.id}`,
});
}, []);
const handleReject = useCallback((event: ProjectEvent) => {
// TODO: Call API to reject the request
toast.info('Approval rejected', {
description: `Rejected request ${event.id}`,
});
}, []);
const handleEventClick = useCallback(
(event: ProjectEvent) => {
// Navigate to relevant detail page based on event type
if (event.type.startsWith('issue.')) {
const payload = event.payload as Record<string, unknown>;
if (payload.issue_id) {
router.push(`/projects/${projectId}/issues/${payload.issue_id}`);
}
}
},
[router, projectId]
);
const pendingCount = events.filter((e) => e.type === EventType.APPROVAL_REQUESTED).length;
return (
<div className="container mx-auto px-4 py-6">
<div className="mx-auto max-w-4xl space-y-6">
{/* Page Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Project Activity</h1>
<p className="mt-1 text-muted-foreground">Real-time updates from this project</p>
</div>
</div>
<div className="flex items-center gap-2">
{pendingCount > 0 && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{pendingCount} pending
</Badge>
)}
<Button
variant="ghost"
size="icon"
onClick={() => setNotificationsEnabled(!notificationsEnabled)}
aria-label={notificationsEnabled ? 'Disable notifications' : 'Enable notifications'}
>
{notificationsEnabled ? (
<Bell className="h-4 w-4" />
) : (
<BellOff className="h-4 w-4" />
)}
</Button>
<Button variant="ghost" size="icon" onClick={reconnect} aria-label="Refresh connection">
<RefreshCw
className={connectionState === 'connecting' ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
/>
</Button>
</div>
</div>
{/* Demo Mode Banner */}
{(!isConnected || sseEvents.length === 0) && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-950">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Demo Mode:</strong> Showing sample events. Events will appear here when agents
are active.
</p>
</div>
)}
{/* Activity Feed Component */}
<ActivityFeed
events={events}
connectionState={isConnected ? connectionState : 'disconnected'}
onReconnect={reconnect}
onApprove={handleApprove}
onReject={handleReject}
onEventClick={handleEventClick}
maxHeight={600}
showHeader={false}
enableFiltering
enableSearch
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,245 @@
'use client';
/**
* Project Agents Page
*
* Displays and manages agents assigned to a specific project.
* Shows agent status, current tasks, and provides management controls.
*
* @module app/[locale]/(authenticated)/projects/[id]/agents/page
*/
import { use, useCallback } from 'react';
import { useRouter } from '@/lib/i18n/routing';
import { ArrowLeft, Bot, Plus, Settings2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
interface ProjectAgentsPageProps {
params: Promise<{
locale: string;
id: string;
}>;
}
type AgentStatus = 'working' | 'idle' | 'waiting' | 'error' | 'terminated';
interface MockAgent {
id: string;
name: string;
role: string;
status: AgentStatus;
current_task: string;
last_activity_at: string;
avatar: string;
}
// Mock agents data - will be replaced with API calls
const mockAgents: MockAgent[] = [
{
id: 'agent-001',
name: 'Product Owner',
role: 'product_owner',
status: 'working',
current_task: 'Reviewing user story acceptance criteria',
last_activity_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
avatar: 'PO',
},
{
id: 'agent-002',
name: 'Architect',
role: 'architect',
status: 'working',
current_task: 'Designing API contract for checkout flow',
last_activity_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
avatar: 'AR',
},
{
id: 'agent-003',
name: 'Backend Engineer',
role: 'backend_engineer',
status: 'idle',
current_task: 'Waiting for architecture review',
last_activity_at: new Date(Date.now() - 15 * 60 * 1000).toISOString(),
avatar: 'BE',
},
{
id: 'agent-004',
name: 'Frontend Engineer',
role: 'frontend_engineer',
status: 'working',
current_task: 'Implementing product catalog component',
last_activity_at: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
avatar: 'FE',
},
{
id: 'agent-005',
name: 'QA Engineer',
role: 'qa_engineer',
status: 'waiting',
current_task: 'Preparing test cases for Sprint 3',
last_activity_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
avatar: 'QA',
},
];
const statusColors = {
working: 'bg-green-500',
idle: 'bg-yellow-500',
waiting: 'bg-blue-500',
error: 'bg-red-500',
terminated: 'bg-gray-500',
};
const statusLabels = {
working: 'Working',
idle: 'Idle',
waiting: 'Waiting',
error: 'Error',
terminated: 'Terminated',
};
function formatTimeAgo(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export default function ProjectAgentsPage({ params }: ProjectAgentsPageProps) {
const { id: projectId } = use(params);
const router = useRouter();
const handleBack = useCallback(() => {
router.push(`/projects/${projectId}`);
}, [router, projectId]);
const handleAgentClick = useCallback(
(agentId: string) => {
router.push(`/projects/${projectId}/agents/${agentId}`);
},
[router, projectId]
);
const handleSpawnAgent = useCallback(() => {
// TODO: Open spawn agent dialog
console.log('Spawn agent clicked');
}, []);
const handleConfigureAgents = useCallback(() => {
router.push('/agents');
}, [router]);
return (
<div className="container mx-auto px-4 py-6">
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Project Agents</h1>
<p className="text-muted-foreground">
{mockAgents.length} agents assigned to this project
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleConfigureAgents}>
<Settings2 className="mr-2 h-4 w-4" aria-hidden="true" />
Configure Agent Types
</Button>
<Button size="sm" onClick={handleSpawnAgent}>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
Spawn Agent
</Button>
</div>
</div>
{/* Agent Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{mockAgents.map((agent) => (
<Card
key={agent.id}
className="cursor-pointer transition-shadow hover:shadow-md"
onClick={() => handleAgentClick(agent.id)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-primary/10 text-primary">
{agent.avatar}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">{agent.name}</CardTitle>
<CardDescription className="text-xs capitalize">
{agent.role.replace(/_/g, ' ')}
</CardDescription>
</div>
</div>
<Badge
variant="outline"
className={cn(
'flex items-center gap-1.5',
agent.status === 'working' && 'border-green-200 text-green-700',
agent.status === 'idle' && 'border-yellow-200 text-yellow-700',
agent.status === 'waiting' && 'border-blue-200 text-blue-700',
agent.status === 'error' && 'border-red-200 text-red-700'
)}
>
<span
className={cn('h-2 w-2 rounded-full', statusColors[agent.status])}
aria-hidden="true"
/>
{statusLabels[agent.status]}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="text-sm text-muted-foreground line-clamp-2">{agent.current_task}</p>
<p className="text-xs text-muted-foreground">
Last active: {formatTimeAgo(agent.last_activity_at)}
</p>
</div>
</CardContent>
</Card>
))}
</div>
{/* Empty State */}
{mockAgents.length === 0 && (
<Card className="py-12 text-center">
<CardContent>
<Bot className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-semibold">No agents assigned</h3>
<p className="mt-2 text-sm text-muted-foreground">
Spawn agents to start working on this project
</p>
<Button className="mt-4" onClick={handleSpawnAgent}>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
Spawn First Agent
</Button>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,296 @@
'use client';
/**
* Project Settings Page
*
* Configuration page for project-level settings including:
* - Basic info (name, description)
* - Autonomy level
* - Repository integration
* - Danger zone (archive, delete)
*
* @module app/[locale]/(authenticated)/projects/[id]/settings/page
*/
import { use, useState, useCallback } from 'react';
import { useRouter } from '@/lib/i18n/routing';
import { ArrowLeft, Save, Trash2, Archive, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
interface ProjectSettingsPageProps {
params: Promise<{
locale: string;
id: string;
}>;
}
// Mock project data - will be replaced with API calls
const mockProject = {
id: 'proj-001',
name: 'E-Commerce Platform Redesign',
slug: 'ecommerce-redesign',
description: 'Complete redesign of the e-commerce platform with modern UI/UX',
autonomy_level: 'milestone',
repository_url: 'https://github.com/example/ecommerce',
status: 'active',
};
export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps) {
const { id: projectId } = use(params);
const router = useRouter();
// Form state
const [name, setName] = useState(mockProject.name);
const [description, setDescription] = useState(mockProject.description);
const [autonomyLevel, setAutonomyLevel] = useState(mockProject.autonomy_level);
const [repositoryUrl, setRepositoryUrl] = useState(mockProject.repository_url);
const [isSaving, setIsSaving] = useState(false);
const handleBack = useCallback(() => {
router.push(`/projects/${projectId}`);
}, [router, projectId]);
const handleSave = useCallback(async () => {
setIsSaving(true);
// TODO: Call API to update project
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('Settings saved', {
description: 'Project settings have been updated successfully.',
});
setIsSaving(false);
}, []);
const handleArchive = useCallback(() => {
// TODO: Call API to archive project
toast.info('Project archived', {
description: 'The project has been archived and is no longer active.',
});
router.push('/projects');
}, [router]);
const handleDelete = useCallback(() => {
// TODO: Call API to delete project
toast.success('Project deleted', {
description: 'The project has been permanently deleted.',
});
router.push('/projects');
}, [router]);
return (
<div className="container mx-auto px-4 py-6">
<div className="mx-auto max-w-2xl space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Project Settings</h1>
<p className="text-muted-foreground">Configure project preferences and integrations</p>
</div>
</div>
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>General</CardTitle>
<CardDescription>Basic project information and configuration</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter project name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe your project"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="repository">Repository URL</Label>
<Input
id="repository"
type="url"
value={repositoryUrl}
onChange={(e) => setRepositoryUrl(e.target.value)}
placeholder="https://github.com/..."
/>
<p className="text-xs text-muted-foreground">
Link to the project&apos;s Git repository for code integration
</p>
</div>
</CardContent>
</Card>
{/* Autonomy Settings */}
<Card>
<CardHeader>
<CardTitle>Autonomy Level</CardTitle>
<CardDescription>Control how much oversight you want over agent actions</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="autonomy">Agent Autonomy</Label>
<Select value={autonomyLevel} onValueChange={setAutonomyLevel}>
<SelectTrigger id="autonomy">
<SelectValue placeholder="Select autonomy level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="full_control">
<div className="flex flex-col">
<span className="font-medium">Full Control</span>
<span className="text-xs text-muted-foreground">
Approve every agent action
</span>
</div>
</SelectItem>
<SelectItem value="milestone">
<div className="flex flex-col">
<span className="font-medium">Milestone</span>
<span className="text-xs text-muted-foreground">
Approve at sprint boundaries
</span>
</div>
</SelectItem>
<SelectItem value="autonomous">
<div className="flex flex-col">
<span className="font-medium">Autonomous</span>
<span className="text-xs text-muted-foreground">
Only major decisions need approval
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" aria-hidden="true" />
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
<Separator />
{/* Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Danger Zone
</CardTitle>
<CardDescription>Irreversible actions for this project</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Archive */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<h4 className="font-medium">Archive Project</h4>
<p className="text-sm text-muted-foreground">
Archive this project and stop all agent activity
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">
<Archive className="mr-2 h-4 w-4" aria-hidden="true" />
Archive
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive Project?</AlertDialogTitle>
<AlertDialogDescription>
This will stop all agent activity and mark the project as archived. You can
unarchive it later if needed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleArchive}>Archive Project</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Delete */}
<div className="flex items-center justify-between rounded-lg border border-destructive/50 p-4">
<div>
<h4 className="font-medium text-destructive">Delete Project</h4>
<p className="text-sm text-muted-foreground">
Permanently delete this project and all its data
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Project?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the project, all
agents, issues, sprints, and activity history.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -99,6 +99,8 @@ export function Header() {
<NavLink href="/dashboard" exact>
{t('home')}
</NavLink>
<NavLink href="/projects">{t('projects')}</NavLink>
<NavLink href="/agents">{t('agents')}</NavLink>
{user?.is_superuser && <NavLink href="/admin">{t('admin')}</NavLink>}
</nav>
</div>