/** * Multi-agent swarm panel — animated cards as an in-chat message artifact. * * Mount into a host inside #qvac-messages (not a sticky chrome bar). * After agents finish, call collapse() so it shrinks into the transcript. */ /** @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} defaultHost * @param {{ * onStop?: () => void, * }} [opts] */ export function createSwarmPanel(defaultHost = null, opts = {}) { /** @type {HTMLElement|null|undefined} */ let host = defaultHost /** @type {Map} */ const cards = new Map() /** @type {Array<{ t: number, text: string, kind: string }>} */ let log = [] let total = 0 let done = 0 /** Live run (accepting updates) */ let panelOpen = false /** @type {ReturnType|null} */ let tickTimer = null /** @type {(() => void)|null} */ let expandHandler = null function vis(id) { return AGENT_VISUAL[id] || { icon: '◆', hue: 200, short: id } } /** * @param {HTMLElement} mount */ function ensureShell(mount) { // Always rebuild for a fresh turn (old hosts stay frozen in transcript) mount.innerHTML = `
Multi-agent swarm Dispatching specialists…
0/0
Idle Live tool specialists · parallel
` return mount.querySelector('.qvac-swarm') } function startTick() { stopTick() tickTimer = setInterval(() => { for (const [, 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 } function unbindExpand() { if (!expandHandler || !host) { expandHandler = null return } const shell = host.querySelector('.qvac-swarm') const btn = shell?.querySelector('[data-swarm-expand]') const head = shell?.querySelector('[data-swarm-toggle]') if (btn) btn.removeEventListener('click', expandHandler) if (head) head.removeEventListener('click', expandHandler) expandHandler = null } function bindExpand(shell) { unbindExpand() const btn = shell.querySelector('[data-swarm-expand]') const head = shell.querySelector('[data-swarm-toggle]') expandHandler = (ev) => { // Only toggle when collapsed (or expanded after collapse) if (shell.dataset.state !== 'collapsed' && shell.dataset.state !== 'expanded') return // Don't steal clicks from nested buttons except expand const t = /** @type {HTMLElement} */ (ev.target) if (t.closest('button') && !t.closest('[data-swarm-expand]')) return const next = shell.dataset.state === 'collapsed' ? 'expanded' : 'collapsed' shell.dataset.state = next if (btn) { btn.setAttribute('aria-expanded', next === 'expanded' ? 'true' : 'false') btn.textContent = next === 'expanded' ? 'Hide' : 'Details' } } btn?.addEventListener('click', expandHandler) head?.addEventListener('click', expandHandler) } /** * @param {Array<{ id: string, label: string, description?: string }>} agents * @param {string} [query] * @param {{ mount?: HTMLElement|null }} [options] */ function begin(agents, query = '', options = {}) { if (options.mount) host = options.mount if (!host) return unbindExpand() panelOpen = true cards.clear() log = [] total = agents.length done = 0 host.classList.remove('hidden') host.classList.add('qvac-swarm-slot') const shell = ensureShell(host) 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…', }) 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 without wiping an in-progress host: inject one card const v = vis(ev.id) const grid = shell.querySelector('.qvac-swarm-grid') const el = document.createElement('article') el.className = 'qvac-agent-card is-in' el.dataset.status = 'queued' el.dataset.agent = ev.id el.style.setProperty('--agent-hue', String(v.hue)) el.innerHTML = `
${esc(ev.label || ev.id)} ${esc(ev.description || v.short)}
queued
Waiting…
` grid?.appendChild(el) total += 1 st = { el, status: 'queued', label: ev.label || ev.id, description: ev.description, activity: 'Waiting…', } cards.set(ev.id, st) updateProgress(shell) } 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' && prev !== 'stopped') { 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() } /** * Shrink into a compact transcript row (stays in chat). Expandable for details. * @param {string} [note] */ function collapse(note = 'Investigation complete') { if (!host) return const shell = host.querySelector('.qvac-swarm') if (!shell) return panelOpen = false stopTick() shell.dataset.state = 'collapsed' const sub = shell.querySelector('.qvac-swarm-subtitle') if (sub) { const labels = [...cards.values()] .map((c) => c.label) .filter(Boolean) .slice(0, 4) const more = cards.size > labels.length ? ` +${cards.size - labels.length}` : '' sub.textContent = total > 0 ? `${done}/${total} specialists · ${labels.join(', ')}${more}` : note } setFoot(shell, note, false) const btn = shell.querySelector('[data-swarm-expand]') if (btn) { btn.classList.remove('hidden') btn.setAttribute('aria-expanded', 'false') btn.textContent = 'Details' } // Mini chips of agent icons for collapsed glance let chips = shell.querySelector('.qvac-swarm-mini-chips') if (!chips) { chips = document.createElement('div') chips.className = 'qvac-swarm-mini-chips' const brand = shell.querySelector('.qvac-swarm-titles') brand?.appendChild(chips) } chips.innerHTML = '' for (const [id, st] of cards) { const v = vis(id) const chip = document.createElement('span') chip.className = 'qvac-swarm-mini-chip' chip.dataset.status = normalizeStatus(st.status) chip.style.setProperty('--agent-hue', String(v.hue)) chip.title = `${st.label}: ${st.status}` chip.textContent = v.icon chips.appendChild(chip) } bindExpand(shell) } 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() // Still collapse so chat is usable collapse(note) } /** Detach live controller only — does not remove past transcript nodes. */ function release() { panelOpen = false stopTick() unbindExpand() cards.clear() log = [] host = null } /** @deprecated Prefer leave transcript nodes; release() for controller reset */ function hide() { panelOpen = false stopTick() unbindExpand() if (host && host.classList.contains('qvac-agent-bar')) { host.classList.add('hidden') host.innerHTML = '' } // In-chat slots are left in the message tree 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) 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, collapse, fail, hide, release, 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, '"') }