From 423cbd342a1060beea6045b40f10c37d4bc3bc1b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 21:11:11 +1000 Subject: [PATCH] refactor(ui): Dross rail uses agent_chat Co-Authored-By: Claude Opus 4.8 --- public/components/rightrail.js | 118 ++++++--------------------------- 1 file changed, 20 insertions(+), 98 deletions(-) diff --git a/public/components/rightrail.js b/public/components/rightrail.js index fb2c7fa..03d5669 100644 --- a/public/components/rightrail.js +++ b/public/components/rightrail.js @@ -1,35 +1,14 @@ -// 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'; +// Plan 5: per-Space companion chat. Chat mechanics live in agent_chat.js; this +// file owns only the collapsible rail chrome + Space-change re-init. +import { el, mount } from '../dom.js'; import { state, on } from '../state.js'; +import { wireAgentChat } from './agent_chat.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)); -} - -const TOOL_LABEL = { search: 'searching', read: 'reading', context: 'looking at this view', propose_change: 'drafting a change' }; -function chipEl(tool, status) { - const name = String(tool || '').replace(/^mcp__void__/, ''); // strip MCP prefix for display - const icon = name === 'search' ? '🔍' : name === 'read' ? '📄' : name === 'context' ? '🧭' : '📝'; - return el('div', { class: 'tools' }, - el('span', { class: 'chip' + (status === 'error' ? ' err' : '') }, `${icon} ${TOOL_LABEL[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; -} +const COMPANION_LABELS = { + search: '🔍 searching', read: '📄 reading', + context: '🧭 looking at this view', propose_change: '📝 drafting a change' +}; export async function renderRightrail(root) { const shell = document.getElementById('shell'); @@ -50,82 +29,25 @@ export async function renderRightrail(root) { 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. + // (Re)wire the chat 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); + const chat = wireAgentChat({ + logEl: log, inputEl: input, + historyUrl: `/api/spaces/${spaceId}/companion`, + turnUrl: `/api/spaces/${spaceId}/companion/turn`, + agentName: 'Companion', showDrafts: true, toolLabels: COMPANION_LABELS, + turnBody: (text) => ({ text, view: state.view || null }) + }); + await chat.load(); } - // 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. + // The state bus replays its last value on subscribe, so this fires for the + // initial route() call and every later navigation. Only re-init when the id + // actually changes so navigating within a Space doesn't wipe the conversation. let lastSpaceId; let inited = false; on('space-active', (spaceId) => { if (inited && spaceId === lastSpaceId) return;