From 2a3adebf2a45775e0174d2777646523457f6a294 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 15:03:44 +0000 Subject: [PATCH] feat: transform agents UI into team operations view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add team-wide agent runs endpoint (GET /api/devforge/v1/agents/runs) - Create TeamAgentRunsTable component showing all team agent activity - Reorganize AgentsPage with tabs: Activité (runs) and Membres (team) - Update sidebar navigation to emphasize 3 core jobs: 1. Déployer (apps, databases, services, deployments) 2. Équipe IA (activity, chat, automations) 3. Continue dev (infrastructure, settings) - Update dashboard description to focus on team activity - Agents page now shows runs table with: agent, activity, resource, trigger, status, date Inspired by Cursor Cloud Agents dashboard pattern. Co-authored-by: Mathieu JESER --- .../DevForge/AgentRunController.php | 39 ++++ backend/routes/devforge-agents.php | 1 + .../components/agents/TeamAgentRunsTable.tsx | 197 ++++++++++++++++++ frontend/src/lib/api/domain.ts | 11 + frontend/src/lib/routing/sidebar-nav.ts | 93 +++++---- frontend/src/pages/agents/_AgentsPage.tsx | 58 +++++- .../src/pages/dashboard/_OverviewPage.tsx | 1 + 7 files changed, 355 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/agents/TeamAgentRunsTable.tsx diff --git a/backend/app/Http/Controllers/DevForge/AgentRunController.php b/backend/app/Http/Controllers/DevForge/AgentRunController.php index 916eb5bb2c..6b3b5981cc 100644 --- a/backend/app/Http/Controllers/DevForge/AgentRunController.php +++ b/backend/app/Http/Controllers/DevForge/AgentRunController.php @@ -21,6 +21,30 @@ public function __construct( private readonly AgentRunCancellation $cancellation, ) {} + public function teamIndex(Request $request): JsonResponse + { + $team = $this->currentTeamContext->resolve($request->user()); + + $runs = AiAgentRun::query() + ->whereHas('agent', fn ($query) => $query->where('team_id', $team->id)) + ->with(['agent:id,uuid,name,type,avatar_color,avatar_shape']) + ->latest() + ->paginate(50); + + return response()->json([ + 'data' => $runs->items() === [] ? [] : array_map( + fn (AiAgentRun $run) => $this->presentRunWithAgent($run), + $runs->items(), + ), + 'meta' => [ + 'total' => $runs->total(), + 'per_page' => $runs->perPage(), + 'current_page' => $runs->currentPage(), + 'last_page' => $runs->lastPage(), + ], + ]); + } + public function index(Request $request, string $agentUuid): JsonResponse { $agent = $this->findAgent($request, $agentUuid); @@ -253,4 +277,19 @@ private function presentRun(AiAgentRun $run, bool $withLogs = false): array return $data; } + + /** @return array */ + private function presentRunWithAgent(AiAgentRun $run): array + { + $data = $this->presentRun($run, withLogs: false); + $data['agent'] = $run->agent ? [ + 'uuid' => $run->agent->uuid, + 'name' => $run->agent->name, + 'type' => $run->agent->type, + 'avatar_color' => $run->agent->avatar_color, + 'avatar_shape' => $run->agent->avatar_shape, + ] : null; + + return $data; + } } diff --git a/backend/routes/devforge-agents.php b/backend/routes/devforge-agents.php index 58d6fc33d2..b854596a60 100644 --- a/backend/routes/devforge-agents.php +++ b/backend/routes/devforge-agents.php @@ -23,6 +23,7 @@ Route::post('/', [AgentController::class, 'store'])->name('store'); Route::post('/reset', [AgentController::class, 'reset'])->name('reset'); Route::delete('/all', [AgentController::class, 'destroyAll'])->name('destroyAll'); + Route::get('/runs', [AgentRunController::class, 'teamIndex'])->name('runs.team'); Route::get('/{uuid}', [AgentController::class, 'show']) ->where('uuid', '[A-Za-z0-9-]{8,64}') ->name('show'); diff --git a/frontend/src/components/agents/TeamAgentRunsTable.tsx b/frontend/src/components/agents/TeamAgentRunsTable.tsx new file mode 100644 index 0000000000..cb71565ad7 --- /dev/null +++ b/frontend/src/components/agents/TeamAgentRunsTable.tsx @@ -0,0 +1,197 @@ +import { Activity, Bot, ChevronRight, Clock, Layers, Zap } from 'lucide-preact'; +import type { AgentRunWithAgent } from '../../lib/domain-api'; +import { AgentRunStatusBadge } from './AgentRunStatusBadge'; +import { AgentAvatar } from './AgentAvatar'; +import { routeHref, agentDetailPath } from '../../lib/routes'; +import { useNavigate } from '../../lib/use-navigate'; + +const triggerLabels: Record = { + scheduled: 'Planifié', + manual: 'Manuel', + event: 'Événement', + chat: 'Chat', + chat_continue: 'Chat continu', + ephemeral: 'Sous-tâche', + delegation: 'Délégation', +}; + +const triggerIcons: Record = { + scheduled: Clock, + manual: Zap, + event: Activity, + chat: Bot, + chat_continue: Bot, +}; + +function formatDuration(seconds: number | null): string { + if (seconds === null) { + return '—'; + } + if (seconds < 60) { + return `${seconds}s`; + } + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} + +function runSummary(run: AgentRunWithAgent): string { + const summary = run.summary?.trim(); + if (summary && summary.length <= 100) { + return summary.replace(/^Erreur:\s*/i, ''); + } + if (summary) { + return `${summary.slice(0, 97)}…`; + } + return triggerLabels[run.trigger] ?? run.trigger; +} + +function getResourceInfo(run: AgentRunWithAgent): { type: string; name: string } | null { + const meta = run.metadata; + if (!meta) { + return null; + } + + if (meta.application_name) { + return { type: 'App', name: meta.application_name }; + } + + if (meta.deployment_uuid) { + return { type: 'Déploiement', name: meta.deployment_uuid.substring(0, 8) }; + } + + return null; +} + +type Props = { + runs: AgentRunWithAgent[]; + loading?: boolean; +}; + +export function TeamAgentRunsTable({ runs, loading = false }: Props) { + const onNavigate = useNavigate(); + + if (loading) { + return ( +
+ +

Chargement des exécutions…

+
+ ); + } + + if (runs.length === 0) { + return ( +
+
+ +
+
+

Aucune activité pour l'instant

+

+ Les exécutions d'agents apparaîtront ici. +

+
+
+ ); + } + + return ( +
+ + + + + + + + + + + + + {runs.map((run) => { + const resource = getResourceInfo(run); + const TriggerIcon = triggerIcons[run.trigger] ?? Activity; + const agentStatus = run.status === 'failed' ? 'error' : run.status === 'completed' ? 'idle' : 'running'; + const agentPath = run.agent ? agentDetailPath(run.agent.uuid) : null; + + return ( + + + + + + + + + + + ); + })} + +
+ AgentActivitéStatutCréé +
+ {run.agent && ( + + )} + + {agentPath ? ( + onNavigate(e, agentPath)} + > + {run.agent?.name ?? '—'} + + ) : ( + {run.agent?.name ?? '—'} + )} + +

{runSummary(run)}

+
+ + + {new Date(run.created_at).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} + + {agentPath && ( + onNavigate(e, agentPath)} + class="btn btn-ghost btn-xs" + aria-label={`Voir l'agent ${run.agent?.name ?? ''}`} + > + + + )} +
+
+ ); +} diff --git a/frontend/src/lib/api/domain.ts b/frontend/src/lib/api/domain.ts index bffc933395..9eecdcfe6e 100644 --- a/frontend/src/lib/api/domain.ts +++ b/frontend/src/lib/api/domain.ts @@ -1105,6 +1105,16 @@ export type AgentRun = { logs?: string | null; }; +export type AgentRunWithAgent = AgentRun & { + agent: { + uuid: string; + name: string; + type: AgentType; + avatar_color: string; + avatar_shape?: string | null; + } | null; +}; + export type AgentTriggerMode = 'manual' | 'schedule' | 'webhook' | 'cron'; export type Agent = { @@ -4269,6 +4279,7 @@ export const domainApi = { body: JSON.stringify({ feedback }), }), agentRuns: (agentUuid: string, page = 1) => apiFetch>(`${API_BASE}/agents/${encodeURIComponent(agentUuid)}/runs?page=${page}`), + teamAgentRuns: (page = 1) => apiFetch>(`${API_BASE}/agents/runs?page=${page}`), clearAgentRuns: (agentUuid: string) => mutate>(`/agents/${encodeURIComponent(agentUuid)}/runs`, { method: 'DELETE', }), diff --git a/frontend/src/lib/routing/sidebar-nav.ts b/frontend/src/lib/routing/sidebar-nav.ts index bbb69c3405..4068e0ef9d 100644 --- a/frontend/src/lib/routing/sidebar-nav.ts +++ b/frontend/src/lib/routing/sidebar-nav.ts @@ -46,8 +46,10 @@ export type SidebarNavItem = { export type SidebarNavEntry = SidebarNavItem | SidebarNavSection; /** - * Navigation principale par sections toujours visibles. - * Les groupes accordéon rendaient les pages difficiles à trouver. + * Navigation principale organisée par les 3 jobs de DevForge : + * 1. Déployer les apps + * 2. Correction/amélioration automatique via agents + * 3. Continuer à développer sur DevForge */ export const sidebarNav: SidebarNavEntry[] = [ { @@ -60,8 +62,8 @@ export const sidebarNav: SidebarNavEntry[] = [ }, { type: 'section', - id: 'resources', - label: 'Ressources', + id: 'deploy', + label: 'Déployer', items: [ { id: 'applications', @@ -70,6 +72,13 @@ export const sidebarNav: SidebarNavEntry[] = [ pages: ['applications', 'application-detail'], icon: Boxes, }, + { + id: 'deployments', + label: 'Déploiements', + path: '/deployments', + pages: ['deployments'], + icon: Rocket, + }, { id: 'databases', label: 'Bases de données', @@ -84,27 +93,42 @@ export const sidebarNav: SidebarNavEntry[] = [ pages: ['services'], icon: Wrench, }, + ], + }, + { + type: 'section', + id: 'agents', + label: 'Équipe IA', + requiresAgents: true, + items: [ + { + id: 'agents-manage', + label: 'Activité & membres', + path: '/agents', + pages: ['agents'], + icon: Bot, + }, + { + id: 'agents-chat', + label: 'Chat', + path: '/agents/chat', + pages: ['agents-chat', 'agent-detail'], + icon: MessageSquare, + }, { - id: 'store', - label: 'Store', - path: '/store', - pages: ['store'], - icon: Store, + id: 'automation', + label: 'Automations', + path: '/automation', + pages: ['automation'], + icon: Sparkles, }, ], }, { type: 'section', id: 'operations', - label: 'Opérations', + label: 'Infrastructure', items: [ - { - id: 'deployments', - label: 'Déploiements', - path: '/deployments', - pages: ['deployments'], - icon: Rocket, - }, { id: 'docker', label: 'Docker', @@ -157,36 +181,23 @@ export const sidebarNav: SidebarNavEntry[] = [ icon: Plug, pages: ['connexions', 'github', 'sources'], }, + { + type: 'link', + id: 'store', + label: 'Store', + path: '/store', + icon: Store, + pages: ['store'], + }, { type: 'section', - id: 'agents', - label: 'Agents IA', + id: 'ai-settings', + label: 'Paramètres IA', requiresAgents: true, items: [ - { - id: 'agents-chat', - label: 'Chat', - path: '/agents/chat', - pages: ['agents-chat', 'agent-detail'], - icon: MessageSquare, - }, - { - id: 'agents-manage', - label: 'Équipe', - path: '/agents', - pages: ['agents'], - icon: Bot, - }, - { - id: 'automation', - label: 'Automations', - path: '/automation', - pages: ['automation'], - icon: Sparkles, - }, { id: 'agents-settings', - label: 'Paramètres AI', + label: 'Configuration AI', path: '/agents/settings', pages: ['agents-settings'], icon: Settings, diff --git a/frontend/src/pages/agents/_AgentsPage.tsx b/frontend/src/pages/agents/_AgentsPage.tsx index 9289adf97e..6e5702c60d 100644 --- a/frontend/src/pages/agents/_AgentsPage.tsx +++ b/frontend/src/pages/agents/_AgentsPage.tsx @@ -1,4 +1,4 @@ -import { MessageSquare, Plus, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact'; +import { MessageSquare, Plus, RefreshCw, RotateCcw, Trash2, Users, Zap } from 'lucide-preact'; import { useState } from 'preact/hooks'; import { AgentCard } from '../../components/agents/AgentCard'; import { AgentUserRequestsInbox } from '../../components/agents/AgentUserRequestsInbox'; @@ -6,6 +6,7 @@ import { BotStudio } from '../../components/agents/BotStudio'; import { CreateAgentModal } from '../../components/agents/CreateAgentModal'; import { MissionBoardPanel } from '../../components/agents/MissionBoardPanel'; import { DeployGraftButton } from '../../components/agents/DeployGraftButton'; +import { TeamAgentRunsTable } from '../../components/agents/TeamAgentRunsTable'; import { PageHeader } from '../../components/PageHeader'; import { DataState } from '../../components/ui/DataState'; import { domainApi } from '../../lib/domain-api'; @@ -20,6 +21,8 @@ type Props = { userName?: string; }; +type Tab = 'runs' | 'team' | 'board'; + export function AgentsPage({ userName = 'Vous' }: Props) { const onNavigate = useNavigate(); const { agentsEnabled } = useTeamContext(); @@ -27,9 +30,12 @@ export function AgentsPage({ userName = 'Vous' }: Props) { const [resetting, setResetting] = useState(false); const [deletingAll, setDeletingAll] = useState(false); const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('runs'); const query = useApiQuery(agentsEnabled ? 'agents' : null, () => domainApi.agents()); + const runsQuery = useApiQuery(agentsEnabled && activeTab === 'runs' ? 'team-agent-runs' : null, () => domainApi.teamAgentRuns()); const agents = query.data?.data ?? []; + const runs = runsQuery.data?.data ?? []; const isEmpty = agents.length === 0; const continueAgent = resolveContinueChatAgent(agents); const continuePath = continueAgent ? agentDetailPath(continueAgent.uuid) : null; @@ -77,8 +83,8 @@ export function AgentsPage({ userName = 'Vous' }: Props) { {error &&

{error}

} {!(isEmpty && !query.loading && !query.error) && ( @@ -94,7 +100,7 @@ export function AgentsPage({ userName = 'Vous' }: Props) { ) : ( )} - + {agents.length > 0 && ( + + + + {activeTab === 'runs' && ( +
+ +
+ )} + + {activeTab === 'team' && ( + void query.reload()} + > +
+ {agents.map((agent) => ( + void query.reload({ silent: true })} + /> + ))} +
+
+ )} + )} diff --git a/frontend/src/pages/dashboard/_OverviewPage.tsx b/frontend/src/pages/dashboard/_OverviewPage.tsx index 41444f85fe..76d732a666 100644 --- a/frontend/src/pages/dashboard/_OverviewPage.tsx +++ b/frontend/src/pages/dashboard/_OverviewPage.tsx @@ -63,6 +63,7 @@ export function OverviewPage({ userName = '' }: OverviewPageProps) { const health = overview?.health; const appHealth = summarizeResources(applications); const agentsEnabled = overview?.agents_summary !== null && overview?.agents_summary !== undefined; + const hasFailedDeploys = (overview?.recent_deployments ?? []).some((d) => d.status === 'failed'); const healthTone = !health ? 'default' : health.score >= 80