From 1aebdfe317f15a42f907f7b8b51f1c9363c0ae78 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 21:11:11 +1000 Subject: [PATCH] feat(ui): extract reusable agent_chat panel Co-Authored-By: Claude Opus 4.8 --- public/components/agent_chat.js | 102 ++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 public/components/agent_chat.js diff --git a/public/components/agent_chat.js b/public/components/agent_chat.js new file mode 100644 index 0000000..4b4fe67 --- /dev/null +++ b/public/components/agent_chat.js @@ -0,0 +1,102 @@ +// 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 + * @returns {{ load: () => Promise }} + */ +export function wireAgentChat({ logEl, inputEl, historyUrl, turnUrl, agentName, showDrafts = false, toolLabels = {}, turnBody = (text) => ({ text }) }) { + 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; + } + } + + 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); + + return { load }; +}