fix(agents): empêcher le déplacement des cartes lors du toggle

Le tri par `updatedAt` provoquait un saut de position quand on toggait
un agent car Prisma mettait à jour `updatedAt` automatiquement.

- Tri stable par `createdAt` au lieu de `updatedAt`
- Mise à jour optimiste locale via `onToggle` au lieu d'un re-fetch complet
- Rollback automatique en cas d'erreur serveur
- Désactivation du bouton toggle pendant l'opération (anti double-clic)
- Suppression du `revalidatePath` superflu dans `toggleAgent`

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-18 19:18:49 +02:00
parent 3ef5915062
commit c5b495c03f
3 changed files with 631 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
'use client'
/**
* Agents Page Client
* Main client component for the agents page.
*/
import { useState, useCallback } from 'react'
import { Plus, Bot } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { AgentCard } from '@/components/agents/agent-card'
import { AgentForm } from '@/components/agents/agent-form'
import { AgentTemplates } from '@/components/agents/agent-templates'
import { AgentRunLog } from '@/components/agents/agent-run-log'
import {
createAgent,
updateAgent,
getAgents,
} from '@/app/actions/agent-actions'
// --- Types ---
interface Notebook {
id: string
name: string
icon?: string | null
}
interface AgentItem {
id: string
name: string
description?: string | null
type?: string | null
role: string
sourceUrls?: string | null
sourceNotebookId?: string | null
targetNotebookId?: string | null
frequency: string
isEnabled: boolean
lastRun: string | Date | null
updatedAt: string | Date
_count: { actions: number }
actions: { id: string; status: string; createdAt: string | Date }[]
notebook?: { id: string; name: string; icon?: string | null } | null
}
interface AgentsPageClientProps {
agents: AgentItem[]
notebooks: Notebook[]
}
// --- Component ---
export function AgentsPageClient({
agents: initialAgents,
notebooks,
}: AgentsPageClientProps) {
const { t } = useLanguage()
const [agents, setAgents] = useState(initialAgents)
const [showForm, setShowForm] = useState(false)
const [editingAgent, setEditingAgent] = useState<AgentItem | null>(null)
const [logAgent, setLogAgent] = useState<{ id: string; name: string } | null>(null)
const refreshAgents = useCallback(async () => {
try {
const updated = await getAgents()
setAgents(updated)
} catch {
// Silent
}
}, [])
const handleToggle = useCallback((id: string, isEnabled: boolean) => {
setAgents(prev => prev.map(a => a.id === id ? { ...a, isEnabled } : a))
}, [])
const handleCreate = useCallback(() => {
setEditingAgent(null)
setShowForm(true)
}, [])
const handleEdit = useCallback((id: string) => {
const agent = agents.find(a => a.id === id)
if (agent) {
setEditingAgent(agent)
setShowForm(true)
}
}, [agents])
const handleSave = useCallback(async (formData: FormData) => {
const data = {
name: formData.get('name') as string,
description: (formData.get('description') as string) || undefined,
type: formData.get('type') as string,
role: formData.get('role') as string,
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
sourceNotebookId: (formData.get('sourceNotebookId') as string) || undefined,
targetNotebookId: (formData.get('targetNotebookId') as string) || undefined,
frequency: formData.get('frequency') as string,
}
if (editingAgent) {
await updateAgent(editingAgent.id, data)
toast.success(t('agents.toasts.updated'))
} else {
await createAgent(data)
toast.success(t('agents.toasts.created'))
}
setShowForm(false)
setEditingAgent(null)
refreshAgents()
}, [editingAgent, refreshAgents, t])
return (
<>
{/* Header */}
<div className="flex items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/10 rounded-2xl shadow-sm border border-primary/20">
<Bot className="h-8 w-8 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('agents.title')}</h1>
<p className="text-muted-foreground text-sm">{t('agents.subtitle')}</p>
</div>
</div>
</div>
{/* Create button */}
<div className="flex justify-end mb-6">
<button
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
{t('agents.newAgent')}
</button>
</div>
{/* Agents grid */}
{agents.length > 0 && (
<div className="mb-10">
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
{t('agents.myAgents')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{agents.map(agent => (
<AgentCard
key={agent.id}
agent={agent}
onEdit={handleEdit}
onRefresh={refreshAgents}
onToggle={handleToggle}
/>
))}
</div>
</div>
)}
{/* Empty state */}
{agents.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center mb-10">
<Bot className="w-16 h-16 text-slate-300 mb-4" />
<h3 className="text-lg font-medium text-slate-500 mb-2">{t('agents.noAgents')}</h3>
<p className="text-sm text-slate-400 max-w-sm">
{t('agents.noAgentsDescription')}
</p>
</div>
)}
{/* Templates */}
<AgentTemplates onInstalled={refreshAgents} />
{/* Form modal */}
{showForm && (
<AgentForm
agent={editingAgent}
notebooks={notebooks}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditingAgent(null) }}
/>
)}
{/* Run log modal */}
{logAgent && (
<AgentRunLog
agentId={logAgent.id}
agentName={logAgent.name}
onClose={() => setLogAgent(null)}
/>
)}
</>
)
}

View File

@@ -0,0 +1,209 @@
'use server'
/**
* Agent Server Actions
* CRUD operations for agents and execution triggers.
*/
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { executeAgent } from '@/lib/ai/services/agent-executor.service'
// --- CRUD ---
export async function createAgent(data: {
name: string
description?: string
type: string
role: string
sourceUrls?: string[]
sourceNotebookId?: string
targetNotebookId?: string
frequency?: string
}) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const agent = await prisma.agent.create({
data: {
name: data.name,
description: data.description,
type: data.type,
role: data.role,
sourceUrls: data.sourceUrls ? JSON.stringify(data.sourceUrls) : null,
sourceNotebookId: data.sourceNotebookId || null,
targetNotebookId: data.targetNotebookId || null,
frequency: data.frequency || 'manual',
userId: session.user.id,
}
})
revalidatePath('/agents')
return { success: true, agent }
} catch (error) {
console.error('Error creating agent:', error)
throw new Error('Impossible de creer l\'agent')
}
}
export async function updateAgent(id: string, data: {
name?: string
description?: string
type?: string
role?: string
sourceUrls?: string[]
sourceNotebookId?: string | null
targetNotebookId?: string | null
frequency?: string
isEnabled?: boolean
}) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const existing = await prisma.agent.findUnique({ where: { id } })
if (!existing || existing.userId !== session.user.id) {
throw new Error('Agent non trouve')
}
const updateData: Record<string, unknown> = {}
if (data.name !== undefined) updateData.name = data.name
if (data.description !== undefined) updateData.description = data.description
if (data.type !== undefined) updateData.type = data.type
if (data.role !== undefined) updateData.role = data.role
if (data.sourceUrls !== undefined) updateData.sourceUrls = JSON.stringify(data.sourceUrls)
if (data.sourceNotebookId !== undefined) updateData.sourceNotebookId = data.sourceNotebookId
if (data.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId
if (data.frequency !== undefined) updateData.frequency = data.frequency
if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled
const agent = await prisma.agent.update({
where: { id },
data: updateData
})
revalidatePath('/agents')
return { success: true, agent }
} catch (error) {
console.error('Error updating agent:', error)
throw new Error('Impossible de mettre a jour l\'agent')
}
}
export async function deleteAgent(id: string) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const existing = await prisma.agent.findUnique({ where: { id } })
if (!existing || existing.userId !== session.user.id) {
throw new Error('Agent non trouve')
}
await prisma.agent.delete({ where: { id } })
revalidatePath('/agents')
return { success: true }
} catch (error) {
console.error('Error deleting agent:', error)
throw new Error('Impossible de supprimer l\'agent')
}
}
export async function getAgents() {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const agents = await prisma.agent.findMany({
where: { userId: session.user.id },
include: {
_count: { select: { actions: true } },
actions: {
orderBy: { createdAt: 'desc' },
take: 1,
},
notebook: {
select: { id: true, name: true, icon: true }
}
},
orderBy: { createdAt: 'desc' }
})
return agents
} catch (error) {
console.error('Error fetching agents:', error)
throw new Error('Impossible de charger les agents')
}
}
// --- Execution ---
export async function runAgent(id: string) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const result = await executeAgent(id, session.user.id)
revalidatePath('/agents')
revalidatePath('/')
return result
} catch (error) {
console.error('Error running agent:', error)
return {
success: false,
actionId: '',
error: error instanceof Error ? error.message : 'Erreur inconnue'
}
}
}
// --- History ---
export async function getAgentActions(agentId: string) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const actions = await prisma.agentAction.findMany({
where: { agentId },
orderBy: { createdAt: 'desc' },
take: 20,
})
return actions
} catch (error) {
console.error('Error fetching agent actions:', error)
throw new Error('Impossible de charger l\'historique')
}
}
export async function toggleAgent(id: string, isEnabled: boolean) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const agent = await prisma.agent.update({
where: { id },
data: { isEnabled }
})
return { success: true, agent }
} catch (error) {
console.error('Error toggling agent:', error)
throw new Error('Impossible de modifier l\'agent')
}
}