refactor(ui): Dross rail uses agent_chat
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<id>) 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;
|
||||
|
||||
Reference in New Issue
Block a user