Files
Void-Homelab/public/components/rightrail.js
2026-06-01 19:34:27 +10:00

135 lines
5.4 KiB
JavaScript

// 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 } 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);
}
// Initial render — state.spaceId may be null if route hasn't fired yet.
await initChat(state.spaceId);
// Re-init when navigation brings a new Space into focus.
let lastSpaceId = state.spaceId;
window.addEventListener('hashchange', async () => {
// Wait a tick so app.js's renderView can update state first.
await Promise.resolve();
if (state.spaceId !== lastSpaceId) {
lastSpaceId = state.spaceId;
await initChat(state.spaceId);
}
});
}