// Plan 5: per-Space companion chat. Safe-DOM only; markdown via sanitized html:. import { el, mount, clear } from '../dom.js'; import { api } from '../api.js'; import { streamTurn } from '../sse.js'; import { renderMarkdown } from '../markdown.js'; import { state, on } from '../state.js'; const COLLAPSE_KEY = 'void_rail_collapsed'; function turnEl(role, agentName, bodyNode) { return el('div', { class: 'turn ' + (role === 'user' ? 'you' : 'ai') }, el('span', { class: 'lbl' }, role === 'user' ? 'YOU' : (agentName || 'COMPANION').toUpperCase()), el('span', { class: 'msg' }, bodyNode)); } function chipEl(tool, status) { const icon = tool === 'search' ? '🔍' : tool === 'read' ? '📄' : tool === 'context' ? '🧭' : '📝'; return el('div', { class: 'tools' }, el('span', { class: 'chip' + (status === 'error' ? ' err' : '') }, `${icon} ${tool}`)); } 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; } export async function renderRightrail(root) { const shell = document.getElementById('shell'); let collapsed = localStorage.getItem(COLLAPSE_KEY) === 'true'; if (collapsed) shell.classList.add('rail-collapsed'); const toggle = () => { collapsed = !collapsed; localStorage.setItem(COLLAPSE_KEY, String(collapsed)); shell.classList.toggle('rail-collapsed', collapsed); }; const log = el('div', { class: 'rail-log' }); const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Ask the companion…' }); const header = el('div', { class: 'rail-hd' }, el('span', { class: 'who' }, '◆ Companion'), el('span', { class: 'chev', onclick: toggle, title: 'Collapse' }, '⟩')); mount(root, el('div', { class: 'rail-toggle', onclick: toggle, title: 'Companion' }, 'CRADLE'), el('div', { class: 'rail-chat' }, header, log, el('div', { class: 'rail-inputwrap' }, input))); // initChat loads history and wires send for a given spaceId. // Called on first render and whenever the active Space changes. async function initChat(spaceId) { clear(log); input.removeEventListener('keydown', input._sendHandler); input._sendHandler = null; if (!spaceId) { mount(log, el('p', { class: 'muted' }, 'Open a Space to chat with its companion.')); return; } 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); const t = turnEl(role, 'Companion', body); log.appendChild(t); log.scrollTop = log.scrollHeight; return body; } try { const { messages } = await api.get(`/api/spaces/${spaceId}/companion`); clear(log); for (const m of messages) { addTurn(m.role, m.body); for (const d of (m.metadata?.draft_ids || [])) log.appendChild(draftCardEl({ pending_change_id: d, summary: 'a change' }, resolveDraft)); } } catch (e) { mount(log, el('p', { class: 'muted' }, 'Could not load history: ' + e.message)); } async function send() { const text = input.value.trim(); if (!text) return; input.value = ''; addTurn('user', text); let assistantBody = null, acc = ''; try { await streamTurn(`/api/spaces/${spaceId}/companion/turn`, { text, view: state.view || null }, (ev) => { if (ev.type === 'tool') log.appendChild(chipEl(ev.tool, ev.status)); else if (ev.type === 'delta') { if (!assistantBody) assistantBody = addTurn('assistant', ''); acc += ev.text; assistantBody.innerHTML = renderMarkdown(acc); } else if (ev.type === 'draft') log.appendChild(draftCardEl(ev, resolveDraft)); else if (ev.type === 'error') log.appendChild(el('div', { class: 'err' }, ev.message)); log.scrollTop = log.scrollHeight; }); } catch (e) { log.appendChild(el('div', { class: 'err' }, 'Stream error: ' + e.message)); log.scrollTop = log.scrollHeight; } } const handler = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; input._sendHandler = handler; input.addEventListener('keydown', handler); } // Load (and re-load) the chat whenever the active Space changes. The state // bus replays its last value on subscribe, so this fires for the initial // route() call (covering hard loads to #/space/) as well as every later // navigation. We only re-init when the id actually changes so navigating // within a Space (page/ref/etc.) doesn't wipe the conversation. let lastSpaceId; let inited = false; on('space-active', (spaceId) => { if (inited && spaceId === lastSpaceId) return; inited = true; lastSpaceId = spaceId; initChat(spaceId); }); }