/** * Multi-agent swarm panel — animated cards, live activity, progress. */ /** @type {Record} */ export const AGENT_VISUAL = { host: { icon: '◎', hue: 168, short: 'Host' }, hot: { icon: '⚡', hue: 28, short: 'Hot' }, processes: { icon: '⧉', hue: 210, short: 'Procs' }, anomalies: { icon: '⚑', hue: 0, short: 'Alerts' }, fleet: { icon: '⬡', hue: 265, short: 'Fleet' }, storage: { icon: '▣', hue: 45, short: 'Store' }, } /** * @param {HTMLElement|null|undefined} host * @param {{ * onStop?: () => void, * }} [opts] */ export function createSwarmPanel(host, opts = {}) { /** @type {Map} */ const cards = new Map() /** @type {Array<{ t: number, text: string, kind: string }>} */ let log = [] let total = 0 let done = 0 let panelOpen = false /** @type {ReturnType|null} */ let tickTimer = null function vis(id) { return AGENT_VISUAL[id] || { icon: '◆', hue: 200, short: id } } function ensureShell() { if (!host) return null if (host.querySelector('.qvac-swarm')) return host.querySelector('.qvac-swarm') host.innerHTML = `
Multi-agent swarm Dispatching specialists…
0/0
Idle Live tool specialists · parallel
` return host.querySelector('.qvac-swarm') } function startTick() { stopTick() tickTimer = setInterval(() => { for (const [id, st] of cards) { if (st.status !== 'running' && st.status !== 'start') continue const el = st.el const timer = el?.querySelector('.qvac-agent-card-timer') if (timer && st.startedAt) { timer.textContent = `${((Date.now() - st.startedAt) / 1000).toFixed(1)}s` } } }, 120) } function stopTick() { if (tickTimer) clearInterval(tickTimer) tickTimer = null } /** * @param {Array<{ id: string, label: string, description?: string }>} agents * @param {string} [query] */ function begin(agents, query = '') { if (!host) return host.classList.remove('hidden') panelOpen = true cards.clear() log = [] total = agents.length done = 0 const shell = ensureShell() if (!shell) return shell.dataset.state = 'running' const grid = shell.querySelector('.qvac-swarm-grid') const logInner = shell.querySelector('.qvac-swarm-log-inner') if (grid) grid.innerHTML = '' if (logInner) logInner.innerHTML = '' const sub = shell.querySelector('.qvac-swarm-subtitle') if (sub) { sub.textContent = query ? `Investigating: ${String(query).slice(0, 72)}${query.length > 72 ? '…' : ''}` : 'Dispatching specialists…' } updateProgress(shell) setFoot(shell, 'Spawning agents…', true) pushLog(shell, 'swarm', `Launching ${agents.length} specialist${agents.length === 1 ? '' : 's'}`) agents.forEach((a, i) => { const v = vis(a.id) const el = document.createElement('article') el.className = 'qvac-agent-card' el.dataset.status = 'queued' el.dataset.agent = a.id el.style.setProperty('--agent-hue', String(v.hue)) el.style.setProperty('--agent-delay', `${i * 60}ms`) el.setAttribute('role', 'listitem') el.innerHTML = `
${esc(a.label)} ${esc(a.description || v.short)}
queued
Waiting in queue…
` grid?.appendChild(el) cards.set(a.id, { el, status: 'queued', label: a.label, description: a.description, activity: 'Waiting in queue…', }) // Stagger entrance requestAnimationFrame(() => { requestAnimationFrame(() => el.classList.add('is-in')) }) }) startTick() } /** * @param {{ * id: string, * label?: string, * description?: string, * status: string, * activity?: string, * summary?: string, * error?: string, * }} ev */ function update(ev) { if (!host || !panelOpen) return const shell = host.querySelector('.qvac-swarm') if (!shell) return let st = cards.get(ev.id) if (!st) { // Late card begin( [{ id: ev.id, label: ev.label || ev.id, description: ev.description }], '' ) st = cards.get(ev.id) } if (!st) return const prev = st.status st.status = ev.status if (ev.activity) st.activity = ev.activity if (ev.summary) st.summary = ev.summary if (ev.error) st.summary = ev.error const el = st.el el.dataset.status = normalizeStatus(ev.status) if (ev.status === 'start' || ev.status === 'running') { if (!st.startedAt) st.startedAt = Date.now() el.dataset.status = 'running' setCardActivity(el, ev.activity || `Running ${ev.label || st.label}…`) setBadge(el, 'running') if (prev !== 'running' && prev !== 'start') { pushLog(shell, ev.id, `${st.label} started`) } } else if (ev.status === 'done') { st.finishedAt = Date.now() done = Math.min(total, done + (prev === 'done' ? 0 : 1)) setCardActivity(el, ev.activity || 'Complete') setBadge(el, 'done') setResult(el, ev.summary || 'ok') const ms = st.startedAt ? Date.now() - st.startedAt : 0 const timer = el.querySelector('.qvac-agent-card-timer') if (timer) timer.textContent = ms ? `${(ms / 1000).toFixed(1)}s` : 'ok' el.classList.add('is-done-pop') pushLog(shell, ev.id, `${st.label} finished${ev.summary ? ` · ${clip(ev.summary, 80)}` : ''}`) } else if (ev.status === 'error' || ev.status === 'stopped') { st.finishedAt = Date.now() if (prev !== 'done' && prev !== 'error') done = Math.min(total, done + 1) setCardActivity(el, ev.error || ev.summary || 'Failed') setBadge(el, ev.status === 'stopped' ? 'stopped' : 'error') setResult(el, ev.error || ev.summary || 'error', true) pushLog( shell, ev.id, `${st.label} ${ev.status === 'stopped' ? 'stopped' : 'error'}${ev.error ? ` · ${clip(ev.error, 60)}` : ''}` ) } updateProgress(shell) const running = [...cards.values()].filter( (c) => c.status === 'start' || c.status === 'running' ).length if (done >= total && total > 0) { shell.dataset.state = 'complete' setFoot(shell, 'Swarm complete — synthesizing…', false) stopTick() } else { setFoot( shell, running ? `${running} agent${running === 1 ? '' : 's'} active` : 'Waiting…', true ) } } function complete(note = 'Synthesizing final answer…') { if (!host) return const shell = host.querySelector('.qvac-swarm') if (!shell) return shell.dataset.state = 'complete' setFoot(shell, note, false) pushLog(shell, 'swarm', note) stopTick() } function fail(note = 'Stopped') { if (!host) return const shell = host.querySelector('.qvac-swarm') if (!shell) return shell.dataset.state = 'stopped' setFoot(shell, note, false) for (const st of cards.values()) { if (st.status === 'running' || st.status === 'start' || st.status === 'queued') { update({ id: st.el.dataset.agent || '', status: 'stopped', summary: 'stopped', }) } } stopTick() } function hide() { panelOpen = false stopTick() if (!host) return host.classList.add('hidden') host.innerHTML = '' cards.clear() log = [] } function setCardActivity(el, text) { const t = el.querySelector('.qvac-agent-activity-text') if (t) t.textContent = text } function setBadge(el, status) { const b = el.querySelector('.qvac-agent-card-badge') if (!b) return b.textContent = status b.dataset.status = status } function setResult(el, text, isErr = false) { const r = el.querySelector('.qvac-agent-card-result') if (!r) return r.classList.remove('hidden') r.classList.toggle('is-error', isErr) r.textContent = clip(text, 160) } function updateProgress(shell) { const fg = shell.querySelector('.qvac-swarm-ring-fg') const count = shell.querySelector('.qvac-swarm-count') const pct = total ? Math.round((done / total) * 100) : 0 if (fg) fg.setAttribute('stroke-dasharray', `${pct} 100`) if (count) count.textContent = `${done}/${total}` } function setFoot(shell, status, live) { const s = shell.querySelector('.qvac-swarm-foot-status') if (s) { s.textContent = status s.classList.toggle('is-live', Boolean(live)) } } function pushLog(shell, kind, text) { log.push({ t: Date.now(), text, kind }) const inner = shell.querySelector('.qvac-swarm-log-inner') if (!inner) return const line = document.createElement('div') line.className = 'qvac-swarm-log-line' line.dataset.kind = kind const v = vis(kind) line.innerHTML = `${kind === 'swarm' ? '✦' : v.icon}${esc(text)}${timeNow()}` inner.appendChild(line) // Keep last ~40 lines while (inner.children.length > 40) inner.removeChild(inner.firstChild) const logEl = shell.querySelector('.qvac-swarm-log') if (logEl) logEl.scrollTop = logEl.scrollHeight line.classList.add('is-in') } return { begin, update, complete, fail, hide, isOpen: () => panelOpen } } function normalizeStatus(s) { if (s === 'start') return 'running' return s } function clip(s, n) { const t = String(s || '') return t.length > n ? t.slice(0, n - 1) + '…' : t } function timeNow() { try { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', }) } catch { return '' } } function esc(s) { return String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') }