// Detail panel — agent profile + crons // Relative-time formatter for "última execução …" const timeAgo = (iso) => { if (!iso) return null; const d = new Date(iso); if (isNaN(d.getTime())) return null; const s = Math.floor((Date.now() - d.getTime()) / 1000); if (s < 0) return d.toLocaleString('pt-BR'); if (s < 45) return 'agora mesmo'; if (s < 3600) return `há ${Math.floor(s / 60)} min`; if (s < 86400) return `há ${Math.floor(s / 3600)} h`; if (s < 604800) return `há ${Math.floor(s / 86400)} d`; return d.toLocaleDateString('pt-BR'); }; // Returns "green" | "yellow" | "red" | "gray" given an agent. const computeStatusColor = (agent) => { if (!agent || !agent.last_run_at) return "gray"; const lastMs = Date.parse(agent.last_run_at); if (isNaN(lastMs)) return "gray"; const ageMs = Date.now() - lastMs; const hours = ageMs / (1000 * 60 * 60); // Atraso vs cron: se cron_next_at já passou faz mais de 30min, está atrasado if (agent.cron_next_at) { const nextMs = Date.parse(agent.cron_next_at); if (!isNaN(nextMs) && Date.now() - nextMs > 30 * 60 * 1000) return "red"; } if (hours < 1) return "green"; if (hours < 24) return "yellow"; return "red"; }; const formatNextRun = (iso) => { if (!iso) return null; const ms = Date.parse(iso); if (isNaN(ms)) return null; const d = new Date(ms); const today = new Date(); const sameDay = d.toDateString() === today.toDateString(); const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); const isTomorrow = d.toDateString() === tomorrow.toDateString(); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); if (sameDay) return `hoje ${hh}:${mm}`; if (isTomorrow) return `amanhã ${hh}:${mm}`; return d.toLocaleDateString("pt-BR") + ` ${hh}:${mm}`; }; // ── Toast ───────────────────────────────────────────────────────────────────── const Toast = ({ msg, onDone }) => { React.useEffect(() => { const t = setTimeout(onDone, 2800); return () => clearTimeout(t); }, [onDone]); return
{msg}
; }; // ── Edit Modal ──────────────────────────────────────────────────────────────── const EditModal = ({ agent, onClose }) => { React.useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); return (
e.stopPropagation()}>

Editar agente

ID
{agent.id}
Nome
{agent.name}
Modelo
{agent.model || "—"}
Origem
{agent.origin || "—"}
Função
{agent.role || "—"}
Integrações
{(agent.integrations||[]).map(i => {i})}
Traços
{(agent.traits||[]).map(t => {t})}

Edição de metadados via UI em breve. Por enquanto edite via registry.yaml no container.

); }; // ── AgentHero ───────────────────────────────────────────────────────────────── const AgentHero = ({ agent, onRunAgent, onRequestRun, onToast, isRunning = false }) => { const [showEdit, setShowEdit] = React.useState(false); const [paused, setPaused] = React.useState(false); if (!agent) return null; const Badge = window.ModelBadge; const isOffline = agent.status === 'offline'; const lastRunDisplay = timeAgo(agent.last_run_at) || (agent.lastRun || null); const lastRunFailed = agent.last_exit_code != null && agent.last_exit_code !== 0; const statusColor = computeStatusColor(agent); const statusColorTitle = statusColor === "green" ? "Saudável (última execução < 1h)" : statusColor === "yellow" ? "Atenção (1–24h desde última execução)" : statusColor === "red" ? "Atrasado ou >24h sem execução" : "Sem dados de execução"; const nextRunLabel = formatNextRun(agent.cron_next_at); const handlePause = () => { if (paused) { setPaused(false); onToast(`${agent.name} retomado.`); } else { setPaused(true); onToast(`${agent.name} pausado localmente.`); } }; const handleRun = async () => { if (isRunning || isOffline) return; if (onRequestRun) { // Gated path: App opens ConfirmRunModal and calls handleRunAgent on confirm. onRequestRun(agent); return; } // Fallback path (AgentDrawer or any legacy caller that didn't pass onRequestRun): // preserve the original direct-dispatch behavior with toast feedback. onToast(`Disparando ${agent.name}…`); try { const result = await onRunAgent(agent); const reply = result?.reply || result?.response || result?.status || 'execução concluída'; onToast(`${agent.name}: ${String(reply).slice(0, 140)}`); } catch (err) { onToast(`${agent.name} — falha: ${err?.message || 'erro ao executar'}`); } }; return ( <>
{agent.photo ? ( {agent.name} { const node = e.currentTarget; const fallback = document.createElement('div'); fallback.className = 'avatar'; fallback.style.background = agent.avatarColor || ''; fallback.textContent = initials(agent.name); node.replaceWith(fallback); }} /> ) : (
{initials(agent.name)}
)}

{agent.name}

{agent.role} {Badge ? ( ) : ( {agent.model || 'none'} )} {lastRunDisplay ? ( última execução {lastRunDisplay} {lastRunFailed ? ` · falhou (${agent.last_exit_code})` : ''} ) : ( nunca executado )} {agent.cron_next_at && nextRunLabel && ( <> próxima: {nextRunLabel} )}
{showEdit && setShowEdit(false)} />} ); }; const StatStrip = ({ agent, onStatClick }) => { if (!agent) return null; const handleClick = onStatClick ? onStatClick : null; return (
handleClick('runs') : undefined} role={handleClick ? "button" : undefined} tabIndex={handleClick ? 0 : undefined} title={handleClick ? "Ver execuções recentes" : undefined} >
Execuções hoje
{agent.runsToday}
média 7d: {agent.avg7d != null ? agent.avg7d : Math.round((agent.runsToday || 0) * 0.85)}
handleClick('success') : undefined} role={handleClick ? "button" : undefined} tabIndex={handleClick ? 0 : undefined} title={handleClick ? "Ver execuções recentes" : undefined} >
Taxa de sucesso
{agent.successRate}%
últimos 30 dias
handleClick('pending') : undefined} role={handleClick ? "button" : undefined} tabIndex={handleClick ? 0 : undefined} title={handleClick ? "Ver execuções recentes" : undefined} >
Aguardando aprovação
0 ? "var(--d-yellow)" : null}}> {agent.pendingApprovals}
{agent.pendingApprovals > 0 ? "ação necessária" : "tudo limpo"}
{/* TODO: per-agent 30-day cost sparkline — deferred from quick 260513-u6y; needs new endpoint GET /api/v1/agents/{id}/cost-buckets returning [{date, cents}] × 30, plus a small inline SVG chart component. */}
handleClick('cost') : undefined} role={handleClick ? "button" : undefined} tabIndex={handleClick ? 0 : undefined} title={handleClick ? "Ver execuções recentes" : undefined} >
Custo · 30d
{agent.cost_30d_cents && agent.cost_30d_cents > 0 ? '$' + (agent.cost_30d_cents / 100).toFixed(2) : '—'}
últimos 30 dias
); }; const ProfileCards = ({ agent }) => { if (!agent) return null; const Badge = window.ModelBadge; const hasPersona = agent.description || agent.personality || agent.quote; return (

Função & Personalidade

{hasPersona ? ( <>

{agent.description}

Como ela escreve: {agent.personality}

"{agent.quote}" ) : (

Sem descrição configurada. Adicione os campos description, personality e quote via edição.

)}

Identidade

Modelo
{Badge && agent.model && agent.model !== 'none' ? : none}
Status
{agent.statusLabel}
Traços
{agent.traits.map(t => { if (t.startsWith('schedule:')) { const expr = t.slice(9); const human = typeof window.cronToHuman === 'function' ? window.cronToHuman(expr) : expr; return {human}; } return {t}; })}
Integrações
{agent.capabilities.map(c => {c})}
); }; // ===================================================================== // Cron section // ===================================================================== const CronItem = ({ cron, onToggle, onEdit, onDelete }) => (
); // natural-language → cron expr (heuristic) // Handles: "cada N min/h", "às HHh[:MM]", days of week, "dia útil", // "dia N" (day-of-month) optionally "de ", month name on its own. const WEEKDAYS_PT = [ ["domingo", 0], ["segunda", 1], ["terça", 2], ["terca", 2], ["quarta", 3], ["quinta", 4], ["sexta", 5], ["sábado", 6], ["sabado", 6], ]; const MONTHS_PT = [ ["janeiro", 1], ["fevereiro", 2], ["março", 3], ["marco", 3], ["abril", 4], ["maio", 5], ["junho", 6], ["julho", 7], ["agosto", 8], ["setembro", 9], ["outubro", 10], ["novembro", 11], ["dezembro", 12], ["jan", 1], ["fev", 2], ["mar", 3], ["abr", 4], ["mai", 5], ["jun", 6], ["jul", 7], ["ago", 8], ["set", 9], ["out", 10], ["nov", 11], ["dez", 12], ]; const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n | 0)); const guessCron = (txt) => { const t = (txt || "").toLowerCase().trim(); if (!t) return ""; const minM = t.match(/cada\s+(\d+)\s*min/); if (minM) return `*/${clamp(+minM[1], 1, 59)} * * * *`; const hourM = t.match(/cada\s+(\d+)\s*h(?:ora)?s?\b/); if (hourM) return `0 */${clamp(+hourM[1], 1, 23)} * * *`; // accepts "às 7", "às 7h", "às 7h30", "às 7:30", "às 14:00" const tm = t.match(/(?:às|as)\s+(\d{1,2})\s*(?:[:h]\s*(\d{2}))?\s*h?/); const time = tm ? `${tm[2] ? clamp(+tm[2], 0, 59) : 0} ${clamp(+tm[1], 0, 23)}` : "0 9"; for (const [name, dow] of WEEKDAYS_PT) { if (t.includes(name)) return `${time} * * ${dow}`; } if (/dias?\s+[úu]te(?:is|l)|seg-?\s*sex/.test(t)) return `${time} * * 1-5`; const month = (() => { for (const [name, n] of MONTHS_PT) if (t.includes(name)) return n; return null; })(); const domM = t.match(/\bdia\s+(\d{1,2})\b/); if (domM) return `${time} ${clamp(+domM[1], 1, 31)} ${month != null ? month : "*"} *`; if (month != null) return `${time} 1 ${month} *`; if (/todo\s+(?:o\s+)?dia|diariamente|todos\s+os\s+dias|cada\s+dia/.test(t)) return `${time} * * *`; if (tm) return `${time} * * *`; // fallback: if there's enough text, default to daily 9h if (t.length >= 5) return "0 9 * * *"; return ""; }; const NewCronForm = ({ onAdd }) => { const [text, setText] = React.useState(""); const expr = React.useMemo(() => guessCron(text), [text]); const canAdd = text.trim().length >= 3; return (

Novo cron