Files
Momento/memento-note/app/(main)/agents/agents-page-client.tsx
sepehr f0999263a0
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 42s
fix: remove nextRun recalculation from getAgents() that blocked cron
The getAgents() function was recalculating nextRun to future dates when
it found past values. This prevented the cron scheduler from ever
finding due agents (nextRun <= now was always false since getAgents
had already pushed it to the future).

Also fix toast polling: use null sentinel for agents without initial
actions so first execution is still detected. Limit cron to 3 agents
per cycle and add logging.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 13:00:55 +02:00

325 lines
11 KiB
TypeScript

'use client'
/**
* Agents Page Client
* Main client component for the agents page.
*/
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { Plus, Bot, LifeBuoy, Search } 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 { AgentHelp } from '@/components/agents/agent-help'
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
createdAt: string | Date
updatedAt: string | Date
tools?: string | null
maxSteps?: number
notifyEmail?: boolean
includeImages?: boolean
_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[]
}
const typeFilterOptions = [
{ value: '', labelKey: 'agents.filterAll' },
{ value: 'scraper', labelKey: 'agents.types.scraper' },
{ value: 'researcher', labelKey: 'agents.types.researcher' },
{ value: 'monitor', labelKey: 'agents.types.monitor' },
{ value: 'custom', labelKey: 'agents.types.custom' },
] as const
// --- 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 [showHelp, setShowHelp] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [typeFilter, setTypeFilter] = useState('')
const refreshAgents = useCallback(async () => {
try {
const updated = await getAgents()
setAgents(updated)
return updated
} catch {
return null
}
}, [])
// Track latest action per agent to detect new executions
// null = agent tracked with no initial actions, undefined = not tracked yet
const prevActionsRef = useRef<Record<string, string | null>>({})
// Poll every 30s to detect agent executions and show toast notifications
useEffect(() => {
// Initialize tracking from initial data — use null for agents without actions
// so we can still detect their FIRST execution
for (const agent of initialAgents) {
prevActionsRef.current[agent.id] = agent.actions[0]?.id ?? null
}
const interval = setInterval(async () => {
const updated = await refreshAgents()
if (!updated) return
for (const agent of updated) {
const lastAction = agent.actions[0]
if (!lastAction) continue
const prevId = prevActionsRef.current[agent.id]
// undefined = agent not in initial list (created by someone else, skip)
if (prevId === undefined) continue
if (prevId !== lastAction.id) {
// New execution detected (first action ever, or new run)
if (lastAction.status === 'success') {
toast.success(t('agents.toasts.autoRunSuccess', { name: agent.name }))
} else if (lastAction.status === 'failure') {
toast.error(t('agents.toasts.autoRunError', { name: agent.name }))
}
}
prevActionsRef.current[agent.id] = lastAction.id
}
}, 30000)
return () => clearInterval(interval)
}, [])
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,
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined,
notifyEmail: formData.get('notifyEmail') === 'true',
includeImages: formData.get('includeImages') === 'true',
scheduledTime: (formData.get('scheduledTime') as string) || undefined,
scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined,
timezone: (formData.get('timezone') as string) || undefined,
}
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)
await refreshAgents()
}, [editingAgent, refreshAgents, t])
const filteredAgents = useMemo(() => {
return agents.filter(agent => {
const matchesType = !typeFilter || (agent.type || 'scraper') === typeFilter
if (!searchQuery.trim()) return matchesType
const q = searchQuery.toLowerCase()
const matchesSearch =
(agent.name || '').toLowerCase().includes(q) ||
(agent.description && agent.description.toLowerCase().includes(q))
return matchesType && matchesSearch
})
}, [agents, searchQuery, typeFilter])
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
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>
{/* Action buttons */}
<div className="flex items-center justify-between mb-6">
<button
onClick={() => setShowHelp(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 border border-primary/20 rounded-lg hover:bg-primary/15 transition-colors"
>
<LifeBuoy className="w-4 h-4" />
{t('agents.help.btnLabel')}
</button>
<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-muted-foreground uppercase tracking-wider mb-3">
{t('agents.myAgents')}
</h3>
{/* Search and filter */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
<div className="relative flex-1 w-full sm:max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('agents.searchPlaceholder')}
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-lg outline-none focus:border-primary/40 focus:ring-2 focus:ring-primary/10 transition-all"
/>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{typeFilterOptions.map(opt => (
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
typeFilter === opt.value
? 'bg-primary text-white'
: 'bg-muted text-muted-foreground hover:bg-accent'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
{filteredAgents.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredAgents.map(agent => (
<AgentCard
key={agent.id}
agent={agent}
onEdit={handleEdit}
onRefresh={refreshAgents}
onToggle={handleToggle}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="w-10 h-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">{t('agents.noResults')}</p>
</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-muted-foreground/30 mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2">{t('agents.noAgents')}</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{t('agents.noAgentsDescription')}
</p>
</div>
)}
{/* Templates */}
<AgentTemplates onInstalled={refreshAgents} existingAgentNames={existingAgentNames} />
{/* 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)}
/>
)}
{/* Help modal */}
{showHelp && (
<AgentHelp onClose={() => setShowHelp(false)} />
)}
</>
)
}