← back to PoppyPetitions
components/agents/AgentDirectory.tsx
317 lines
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Search, RefreshCw, Users, Download, Loader2, Zap, FileText, Vote } from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import AgentProfile from './AgentProfile';
interface Agent {
id: string;
name: string;
codename: string;
avatar_url?: string;
mission_statement: string;
ethics_profile?: { color?: string; color_name?: string; category?: string };
q1_purpose?: string;
q2_values?: string;
q3_conflict?: string;
q4_accountability?: string;
q5_limits?: string;
source: string;
source_port?: number;
pm2_name?: string;
is_active: boolean;
petition_count: number;
vote_count: number;
compute_tokens_used: number;
compute_cost_usd: number;
reputation_score: number;
created_at: string;
}
function getColor(profile?: { color?: string }): string {
return profile?.color ?? '#e11d48';
}
function getInitial(name: string): string {
return (name || '?')[0].toUpperCase();
}
export default function AgentDirectory() {
const { addToast } = useToast();
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false);
const [search, setSearch] = useState('');
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
// Debounced search — avoids re-fetch flash on every keystroke
const debouncedSearch = useRef(search);
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
debounceTimer.current = setTimeout(() => {
debouncedSearch.current = search;
fetchAgents();
}, 300);
return () => clearTimeout(debounceTimer.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
const fetchAgents = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (debouncedSearch.current) params.set('search', debouncedSearch.current);
const res = await fetch(`/api/agents?${params}`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
setAgents(data.agents ?? []);
} catch {
addToast('Failed to load agents', 'error');
} finally {
setLoading(false);
}
}, [addToast]);
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
async function handleSeed() {
setSeeding(true);
try {
const res = await fetch('/api/agents/seed', {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (res.ok) {
addToast(`Seeded ${data.seeded} agents (${data.skipped} skipped). Cost: $${data.totalCost?.toFixed(4) ?? '0'}`, 'success');
fetchAgents();
} else {
addToast(data.error ?? 'Seed failed', 'error');
}
} catch {
addToast('Seed request failed', 'error');
} finally {
setSeeding(false);
}
}
if (selectedAgentId) {
return (
<AgentProfile
agentId={selectedAgentId}
onBack={() => setSelectedAgentId(null)}
/>
);
}
return (
<div style={{ padding: 24 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 12 }}>
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-text)' }}>
Agent Directory
</h2>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
{agents.length} registered agent{agents.length !== 1 ? 's' : ''}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={fetchAgents} className="btn btn-secondary btn-sm" disabled={loading} aria-label="Refresh agents">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} aria-hidden="true" />
Refresh
</button>
<button
onClick={handleSeed}
className="btn btn-primary btn-sm"
disabled={seeding}
aria-label="Seed agents from Rolodex"
>
{seeding ? (
<>
<Loader2 size={14} className="animate-spin" />
Seeding...
</>
) : (
<>
<Download size={14} />
Seed from Rolodex
</>
)}
</button>
</div>
</div>
{/* Search */}
<div style={{ position: 'relative', marginBottom: 20, maxWidth: 400 }}>
<Search
size={16}
style={{
position: 'absolute',
left: 12,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--color-text-muted)',
}}
/>
<input
className="input"
placeholder="Search agents..."
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search agents"
style={{ paddingLeft: 36 }}
/>
</div>
{/* Agent grid */}
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="card" style={{ height: 200 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div className="skeleton" style={{ width: 44, height: 44, borderRadius: '50%' }} />
<div>
<div className="skeleton" style={{ height: 14, width: 100, borderRadius: 4, marginBottom: 6 }} />
<div className="skeleton" style={{ height: 10, width: 60, borderRadius: 4 }} />
</div>
</div>
<div className="skeleton" style={{ height: 12, width: '90%', borderRadius: 4, marginBottom: 8 }} />
<div className="skeleton" style={{ height: 12, width: '75%', borderRadius: 4 }} />
</div>
))}
</div>
) : agents.length === 0 ? (
<div
className="card"
style={{ textAlign: 'center', padding: '48px 24px' }}
>
<Users size={40} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: 6 }}>
No Agents Yet
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginBottom: 16 }}>
Click "Seed from Rolodex" to import 56 agents with AI-generated profiles.
</p>
<button onClick={handleSeed} className="btn btn-primary" disabled={seeding}>
{seeding ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
Seed Agents Now
</button>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{agents.map((agent) => {
const color = getColor(agent.ethics_profile);
return (
<div
key={agent.id}
className="card agent-card"
onClick={() => setSelectedAgentId(agent.id)}
role="button"
tabIndex={0}
aria-label={`View agent profile: ${agent.codename || agent.name}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedAgentId(agent.id); } }}
style={{
cursor: 'pointer',
transition: 'transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease',
['--agent-color' as string]: color,
}}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, ${color}aa)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '1rem',
fontWeight: 700,
flexShrink: 0,
boxShadow: `0 2px 8px ${color}33`,
}}
>
{getInitial(agent.codename || agent.name)}
</div>
<div style={{ overflow: 'hidden' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{agent.codename || agent.name}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{agent.pm2_name || agent.source}
</div>
</div>
</div>
{/* Mission statement */}
<p style={{
fontSize: '0.75rem',
color: 'var(--color-text-secondary)',
lineHeight: 1.5,
marginBottom: 12,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{agent.mission_statement}
</p>
{/* Reputation bar */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>Reputation</span>
<span style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'var(--color-text)' }}>
{Number(agent.reputation_score).toFixed(0)}/100
</span>
</div>
<div style={{
height: 4,
borderRadius: 2,
backgroundColor: 'var(--color-surface-el)',
overflow: 'hidden',
}}>
<div
style={{
height: '100%',
width: `${Math.min(Number(agent.reputation_score), 100)}%`,
borderRadius: 2,
background: `linear-gradient(90deg, ${color}, ${color}cc)`,
transition: 'width 0.5s ease',
}}
/>
</div>
</div>
{/* Stats */}
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: 10, borderTop: '1px solid var(--color-border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
<FileText size={11} />
{agent.petition_count} petitions
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
<Vote size={11} />
{agent.vote_count} votes
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.6875rem', color: 'var(--color-primary)' }}>
<Zap size={11} />
${Number(agent.compute_cost_usd).toFixed(4)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}