import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api.js';
import { copyToast } from './toast.js';
import ChatPanel, { SessionLog } from './Chat.jsx';
import { GameDialog } from './GameDialog.jsx';
const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
const TAG_EMOJI = { backend: 'π οΈ', model: 'π§ ', frontend: 'π¨', ui: 'ποΈ', web: 'π', data: 'π', devops: 'βοΈ', ml: 'π€', security: 'π', api: 'π', bot: 'π€', chat: 'π¬', test: 'π§ͺ', docs: 'π', design: 'βοΈ', ops: 'π¦', research: 'π¬' };
const classEmoji = (c) => { for (const t of (c.tags || [])) if (TAG_EMOJI[t]) return TAG_EMOJI[t]; return 'π‘οΈ'; };
function avatarStyle(name) { let h = 0; for (const ch of String(name)) h = (h * 31 + ch.charCodeAt(0)) >>> 0; const a = h % 360, b = (a + 70 + h % 90) % 360; return { background: `linear-gradient(135deg, hsl(${a} 68% 46%), hsl(${b} 64% 32%))` }; }
// One agent card, used identically in Browse Agents and My Legion.
export function AgentCard({ c, onClick }) {
return (
);
}
// ---- Browse Agents: all public classes, filter by tag --------------------
export function AgentBrowse({ auth, setRoute }) {
const [classes, setClasses] = useState([]);
const [tags, setTags] = useState({});
const [tag, setTag] = useState('');
const [q, setQ] = useState('');
const [open, setOpen] = useState(null);
const [err, setErr] = useState('');
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
setErr('');
try {
const [c, s] = await Promise.all([api(`/agents/classes${tag ? `?tag=${encodeURIComponent(tag)}` : ''}`), api('/agents/stats')]);
setClasses((c.classes || []).filter((x) => !q || (x.name + x.tags.join() + x.description).toLowerCase().includes(q.toLowerCase())));
setTags(s.tags || {});
} catch (e) { setErr(e.message); } finally { setLoaded(true); }
}, [tag, q]);
useEffect(() => { load(); }, [load]);
// shareable agent URLs: #/agents/ opens that agent's modal (bookmark/share/refresh-safe)
const didInit = useRef(false);
const openClass = useCallback((c) => {
setOpen(c.id);
if (c.slug) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/agents/${c.slug}`);
}, []);
const closeClass = useCallback(() => {
setOpen(null);
if (window.location.hash !== '#/agents') window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/agents`);
load();
}, [load]);
useEffect(() => {
if (didInit.current || !loaded) return;
didInit.current = true;
const slug = window.location.hash.replace(/^#\/?/, '').split('/')[1];
if (slug) { const hit = classes.find((x) => x.slug === slug); if (hit) setOpen(hit.id); }
}, [loaded, classes]);
useEffect(() => {
const onHash = () => {
const parts = window.location.hash.replace(/^#\/?/, '').split('/');
if (parts[0] !== 'agents') return;
if (!parts[1]) { setOpen(null); return; }
const hit = classes.find((x) => x.slug === parts[1]);
if (hit) setOpen(hit.id);
};
window.addEventListener('hashchange', onHash);
window.addEventListener('popstate', onHash);
return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
}, [classes]);
return (
β The mercenary roster
Open the door and the mercenaries come down. Hire one β chat, embed, or drive its terminal. {loaded ? {classes.length} answering the call. : 'Musteringβ¦'}
setQ(e.target.value)} />
{Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, n]) => (
))}
Log in to publish agent classes, run instances, and put your mercenaries to work β as an embedded chatbox, a live web terminal, or behind a public URL.
Registered, but offline until a broker checks in. Two steps bring it to life:
Heartbeat β keeps it marked online (check in every <30s). Paste this anywhere with curl:
{hb}
Relay β bridge your agent so live sessions reach it, over the broker WebSocket:
copyToast(newInst.relayWs)}>{newInst.relayWs} β§The amerc webagent broker speaks this relay for you β point it at the accesskey below.