Soft keyboards have no reliable Enter-to-send, so chat was unsendable on mobile browsers. Add an optional themed Send button wired through wireAgentChat (Enter-to-send kept for desktop), applied to the Companion rail, Yerin, and Little Blue composers. Blackflame-styled, flex-row layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
4.9 KiB
JavaScript
113 lines
4.9 KiB
JavaScript
// 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<void>, send: () => Promise<void> }}
|
|
*/
|
|
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 };
|
|
}
|