← 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 &quot;Seed from Rolodex&quot; 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>
  );
}