// Sidebar + Topbar
const cronToHuman = (expr) => {
if (!expr || typeof expr !== 'string') return '';
const p = expr.trim().split(/\s+/);
if (p.length !== 5) return expr;
const [min, hour, , , dow] = p;
const t = (h, m) => m === '0' || m === '00'
? `${h}h` : `${h}:${m.padStart(2,'0')}`;
if (min.startsWith('*/')) return `a cada ${min.slice(2)} min`;
if (hour.startsWith('*/') && min === '0') return `a cada ${hour.slice(2)}h`;
const days = ['domingo','segunda','terça','quarta','quinta','sexta','sábado'];
if (dow !== '*' && /^\d$/.test(dow)) return `toda ${days[+dow]} às ${t(hour,min)}`;
if (dow === '1-5') return `dias úteis às ${t(hour,min)}`;
return `todo dia às ${t(hour,min)}`;
};
const initials = (name) =>
name.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase();
const modelFamily = (model) => {
const m = (model || '').toLowerCase();
if (!m || m === 'none') return 'none';
if (m.includes('haiku')) return 'haiku';
if (m.includes('sonnet')) return 'sonnet';
if (m.includes('opus')) return 'opus';
return 'other';
};
const modelLabel = (model) => {
const f = modelFamily(model);
if (f === 'none') return 'none';
if (f === 'other') return model || 'other';
return f;
};
const ModelBadge = ({ model, compact = false }) => {
const family = modelFamily(model);
if (family === 'none') return null;
return (
{modelLabel(model)}
);
};
// Helper: relative time from ISO or already-relative string
const relativeTime = (val) => {
if (!val) return null;
// Already a relative string (e.g., "há 2 min")
if (typeof val === 'string' && !/^\d{4}-/.test(val)) return val;
try {
const diff = Date.now() - new Date(val).getTime();
if (diff < 0) return 'agora';
if (diff < 60000) return 'agora';
if (diff < 3600000) return 'há ' + Math.floor(diff / 60000) + 'm';
if (diff < 86400000) return 'há ' + Math.floor(diff / 3600000) + 'h';
return 'há ' + Math.floor(diff / 86400000) + 'd';
} catch {
return val;
}
};
const computeHealthDots = (agent) => {
// Heurística de fallback enquanto backend não expõe agent.recent_runs:
// - dot 1: derivado de last_exit_code (0 -> ok, !=0 -> fail, null -> unknown)
// - dots 2 e 3: unknown (placeholder cinza)
// Se backend já expõe agent.recent_runs (array de 0-3 exit codes), usar.
if (Array.isArray(agent.recent_runs) && agent.recent_runs.length > 0) {
const slice = agent.recent_runs.slice(0, 3);
while (slice.length < 3) slice.push(null);
return slice.map(ec => ec == null ? "unknown" : (ec === 0 ? "ok" : "fail"));
}
const first =
agent.last_exit_code == null ? "unknown" :
agent.last_exit_code === 0 ? "ok" : "fail";
const second = (agent.last_runs_failed > 0 && agent.last_exit_code === 0) ? "fail" : "unknown";
return [first, second, "unknown"];
};
const AgentRow = ({ agent, agentCrons, active, onClick, density, draggable }) => {
const isMac = agent.origin === 'mac' || agent.origin === 'mac-launchd' || agent.origin === 'mac-hook';
const originLabel = isMac ? 'mac' : (agent.origin === 'vps' ? 'vps' : (agent.origin || null));
const originTitle = isMac ? 'Mac pode estar dormindo' : (agent.origin === 'vps' ? 'VPS — sempre online' : (agent.origin || ''));
// Cadence: pick the most-frequent enabled cron for this agent
const enabledCrons = (agentCrons || []).filter(c => c.enabled);
const cadenceText = enabledCrons.length > 0
? (enabledCrons[0].natural || enabledCrons[0].expr)
: 'on-demand';
const hasCron = enabledCrons.length > 0 || (agent.cadence && agent.cadence.length > 0);
const lastRunText = relativeTime(agent.lastRun);
const healthDots = computeHealthDots(agent);
// Derived "scheduled" status — for cron-only agents that don't heartbeat
// (launchd jobs, etc.). API returns status=offline between runs, which is
// misleading: the job is scheduled and ran recently. Promote to a green
// "Agendado" pill when there's a cadence AND last_run_at within the last
// 26 hours (covers daily/sub-daily cadences with a small grace window).
let pillStatus = agent.status;
let pillLabel = agent.statusLabel;
if (agent.status === 'offline' && hasCron && agent.last_run_at) {
const lastRunMs = Date.parse(agent.last_run_at);
if (!isNaN(lastRunMs) && (Date.now() - lastRunMs) < 26 * 60 * 60 * 1000) {
pillStatus = 'scheduled';
pillLabel = 'Agendado';
}
}
return (
{agent.photo ? (

{
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}
{density !== "compact" && (
{agent.role}
)}
{density !== "compact" && (
{' '}{cadenceText}
{lastRunText && (
{lastRunText}
)}
{healthDots.map((d, i) => (
))}
)}
{originLabel && (
{originLabel}
)}
{pillLabel}
{agent.pendingApprovals > 0 && (
{agent.pendingApprovals}
)}
);
};
const STATUS_FILTERS = [
{ key: "all", label: "Todos" },
{ key: "idle", label: "Disponível" },
{ key: "running", label: "Executando" },
{ key: "offline", label: "Offline" },
{ key: "error", label: "Erro" },
];
const MODEL_FILTERS = [
{ key: "all", label: "Modelos" },
{ key: "haiku", label: "Haiku" },
{ key: "sonnet", label: "Sonnet" },
{ key: "opus", label: "Opus" },
{ key: "none", label: "None" },
];
const Sidebar = ({ agents, crons, activeId, onSelect, density, onReorder }) => {
const [q, setQ] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [modelFilter, setModelFilter] = React.useState("all");
const inputRef = React.useRef(null);
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
const shortcutHint = isMac ? "⌘K" : "Ctrl+K";
React.useEffect(() => {
const handler = (e) => {
const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
if (cmdOrCtrl && (e.key === "k" || e.key === "K")) {
e.preventDefault();
if (inputRef.current) inputRef.current.focus();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [isMac]);
// Build crons-by-agent map
const cronsByAgent = React.useMemo(() => {
const map = {};
if (!Array.isArray(crons)) return map;
crons.forEach(c => {
const aid = c.agentId || c.agent_id;
if (!aid) return;
if (!map[aid]) map[aid] = [];
map[aid].push(c);
});
return map;
}, [crons]);
const filtersActive = q !== "" || statusFilter !== "all" || modelFilter !== "all";
const filtered = React.useMemo(() => {
const needle = q.trim().toLowerCase();
let list = agents;
if (needle) {
list = list.filter(a => {
const haystack = [
a.name || "",
a.role || "",
a.model || "",
a.description || "",
...(Array.isArray(a.traits) ? a.traits : []),
...(Array.isArray(a.integrations) ? a.integrations : []),
...(Array.isArray(a.capabilities) ? a.capabilities : []),
].join(" ").toLowerCase();
return haystack.includes(needle);
});
}
if (statusFilter !== "all") list = list.filter(a => a.status === statusFilter);
if (modelFilter !== "all") list = list.filter(a => modelFamily(a.model) === modelFilter);
// Order respects server-side display_order (set by drag-and-drop reorder).
// Errors/pending no longer "float to top" — explicit user ordering wins;
// use the status filter chip to surface errors when needed.
return list;
}, [agents, crons, q, statusFilter, modelFilter]);
// Drag-and-drop reorder via SortableJS. Disabled while any filter is active
// (reordering a filtered subset would silently shuffle the global order).
const listRef = React.useRef(null);
React.useEffect(() => {
if (!listRef.current || typeof window === 'undefined' || !window.Sortable) return;
if (!onReorder) return;
const sortable = window.Sortable.create(listRef.current, {
animation: 150,
disabled: filtersActive,
handle: '.agent-row', // whole card is the handle
ghostClass: 'agent-row-ghost', // styled in dashboard.css
chosenClass: 'agent-row-chosen',
dragClass: 'agent-row-drag',
onEnd: () => {
const ids = Array.from(listRef.current.children)
.map(el => el.getAttribute && el.getAttribute('data-agent-id'))
.filter(Boolean);
if (ids.length > 0) onReorder(ids);
},
});
return () => { try { sortable.destroy(); } catch {} };
}, [filtersActive, onReorder, filtered.length]);
return (
);
};
const BudgetGauge = () => {
const [data, setData] = React.useState(null);
React.useEffect(() => {
const fetchBudget = () => {
const tok = localStorage.getItem('hub_token');
if (!tok) return;
fetch('/api/v1/budget', { headers: { Authorization: 'Bearer ' + tok } })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setData(d); })
.catch(() => {});
};
fetchBudget();
const id = setInterval(fetchBudget, 60000);
return () => clearInterval(id);
}, []);
if (!data || !data.cap_cents || data.cap_cents <= 0) return null;
const cost = data.cost_30d_cents || 0;
const cap = data.cap_cents;
const pct = Math.min(100, Math.round((cost / cap) * 100));
const tone = pct >= 90 ? 'red' : pct >= 60 ? 'yellow' : 'green';
const fmt = (c) => '$' + (c / 100).toFixed(2);
return (
{fmt(cost)} / {fmt(cap)} ({pct}%)
);
};
window.BudgetGauge = BudgetGauge;
const NotificationsPanel = ({ onClose }) => (
e.stopPropagation()}>
Notificações
Nenhuma notificação
Falhas, alertas de custo e aprovações pendentes aparecerão aqui.
);
const Topbar = ({ pendingCount, view, onSetView }) => {
const [showNotif, setShowNotif] = React.useState(false);
const openSettings = () => {
window.postMessage({ type: '__activate_edit_mode' }, '*');
};
const handleLogout = () => {
localStorage.removeItem('hub_token');
window.location = '/login';
};
return (
setShowNotif(false)}>
Command Center · v0.3
hub online · 0 falhas
{/* D-07: view toggle */}
{onSetView && (
)}
{pendingCount > 0 ? (
{pendingCount} aprovações pendentes
) : (
0 aprovações pendentes
)}
{showNotif && setShowNotif(false)} />}
);
};
window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.cronToHuman = cronToHuman;
window.initials = initials;
window.modelFamily = modelFamily;
window.modelLabel = modelLabel;
window.ModelBadge = ModelBadge;