// Reusable agent chat panel — the chat mechanics shared by Dross's right rail // and Yerin's Sentinel view. Safe-DOM only; markdown via sanitized html:. import { el, clear } from '../dom.js'; import { api } from '../api.js'; import { streamTurn } from '../sse.js'; import { renderMarkdown } from '../markdown.js'; function turnEl(role, agentName, bodyNode) { return el('div', { class: 'turn ' + (role === 'user' ? 'you' : 'ai') }, el('span', { class: 'lbl' }, role === 'user' ? 'YOU' : (agentName || 'AGENT').toUpperCase()), el('span', { class: 'msg' }, bodyNode)); } function chipEl(tool, status, toolLabels) { const name = String(tool || '').replace(/^mcp__void__/, ''); return el('div', { class: 'tools' }, el('span', { class: 'chip' + (status === 'error' ? ' err' : '') }, toolLabels[name] || name)); } function draftCardEl(d, onResolve) { const card = el('div', { class: 'draftx', dataset: { pc: d.pending_change_id } }, el('div', { class: 'dh' }, 'Proposed change'), el('div', { class: 'dt' }, d.summary || 'a change'), el('div', { class: 'row' }, el('button', { class: 'ok', onclick: () => onResolve(d.pending_change_id, 'approved', card) }, 'Approve'), el('button', { class: 'no', onclick: () => onResolve(d.pending_change_id, 'rejected', card) }, 'Reject'))); return card; } /** * Wire an agent chat into pre-built log + input elements. * @param {object} o * @param {HTMLElement} o.logEl scrolling message log * @param {HTMLElement} o.inputEl textarea * @param {string} o.historyUrl GET → { messages } * @param {string} o.turnUrl POST (SSE) * @param {string} o.agentName label shown on assistant turns * @param {boolean} [o.showDrafts] render propose_change draft cards (Dross only) * @param {object} [o.toolLabels] tool-name → display string * @param {(text:string)=>object} [o.turnBody] POST body builder * @param {HTMLElement} [o.sendBtnEl] optional Send button (needed on touch/mobile where there's no Enter key) * @returns {{ load: () => Promise, send: () => Promise }} */ export function wireAgentChat({ logEl, inputEl, historyUrl, turnUrl, agentName, showDrafts = false, toolLabels = {}, turnBody = (text) => ({ text }), sendBtnEl = null }) { async function resolveDraft(id, status, cardNode) { try { await api.post(`/api/pending-changes/${id}/${status === 'approved' ? 'approve' : 'reject'}`); cardNode.classList.add('resolved'); cardNode.appendChild(el('div', { class: 'resolved-tag' }, status)); } catch (e) { cardNode.appendChild(el('div', { class: 'err' }, 'failed: ' + e.message)); } } function addTurn(role, text) { const body = role === 'assistant' ? el('span', { html: renderMarkdown(text) }) : el('span', {}, text); logEl.appendChild(turnEl(role, agentName, body)); logEl.scrollTop = logEl.scrollHeight; return body; } async function load() { try { const { messages } = await api.get(historyUrl); clear(logEl); for (const m of messages) { addTurn(m.role, m.body); if (showDrafts) for (const d of (m.metadata?.draft_ids || [])) logEl.appendChild(draftCardEl({ pending_change_id: d, summary: 'a change' }, resolveDraft)); } } catch (e) { clear(logEl); logEl.appendChild(el('p', { class: 'muted' }, 'Could not load history: ' + e.message)); } } async function send() { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ''; addTurn('user', text); let assistantBody = null, acc = ''; try { await streamTurn(turnUrl, turnBody(text), (ev) => { if (ev.type === 'tool') logEl.appendChild(chipEl(ev.tool, ev.status, toolLabels)); else if (ev.type === 'delta') { if (!assistantBody) assistantBody = addTurn('assistant', ''); acc += ev.text; assistantBody.innerHTML = renderMarkdown(acc); } else if (ev.type === 'draft' && showDrafts) logEl.appendChild(draftCardEl(ev, resolveDraft)); else if (ev.type === 'error') logEl.appendChild(el('div', { class: 'err' }, ev.message)); logEl.scrollTop = logEl.scrollHeight; }); } catch (e) { logEl.appendChild(el('div', { class: 'err' }, 'Stream error: ' + e.message)); logEl.scrollTop = logEl.scrollHeight; } } // Desktop: Enter sends (Shift+Enter = newline). Mobile soft keyboards have no // reliable Enter-to-send, so callers also pass a tappable Send button. if (inputEl._sendHandler) inputEl.removeEventListener('keydown', inputEl._sendHandler); const handler = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; inputEl._sendHandler = handler; inputEl.addEventListener('keydown', handler); if (sendBtnEl) { if (sendBtnEl._sendHandler) sendBtnEl.removeEventListener('click', sendBtnEl._sendHandler); const click = () => { send(); inputEl.focus(); }; sendBtnEl._sendHandler = click; sendBtnEl.addEventListener('click', click); } return { load, send }; }