feat(ui): right-rail companion chat — streaming, tool chips, inline drafts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { route, current, navigate } from './router.js';
|
||||
import { renderSidebar } from './components/sidebar.js';
|
||||
import { renderTopbar } from './components/topbar.js';
|
||||
import { renderRightrail } from './components/rightrail.js';
|
||||
import { emit } from './state.js';
|
||||
import { emit, state } from './state.js';
|
||||
import { el, mount } from './dom.js';
|
||||
import { attachDropzone } from './components/dropzone.js';
|
||||
|
||||
@@ -25,6 +25,17 @@ const VIEWS = {
|
||||
};
|
||||
|
||||
async function renderView(ctx) {
|
||||
// Update cross-component state so the right rail knows the active Space + view.
|
||||
if (ctx.name === 'space') {
|
||||
state.spaceId = ctx.params.id || null;
|
||||
state.view = null;
|
||||
} else if (ctx.name === 'project' || ctx.name === 'page' || ctx.name === 'ref' || ctx.name === 'resource') {
|
||||
// Keep the last known spaceId; update the focused entity.
|
||||
state.view = { entityType: ctx.name, entityId: ctx.params.id || null };
|
||||
} else {
|
||||
state.view = null;
|
||||
}
|
||||
|
||||
const main = document.getElementById('main');
|
||||
const loader = VIEWS[ctx.name] || VIEWS.home;
|
||||
try {
|
||||
|
||||
@@ -1,25 +1,134 @@
|
||||
// T17 stub — collapsible rail with localStorage persistence.
|
||||
// Chat lands in Plan 5.
|
||||
import { el, mount } from '../dom.js';
|
||||
// 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 KEY = 'void_rail_collapsed';
|
||||
const COLLAPSE_KEY = 'void_rail_collapsed';
|
||||
|
||||
export function renderRightrail(root) {
|
||||
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(KEY) === 'true';
|
||||
let collapsed = localStorage.getItem(COLLAPSE_KEY) === 'true';
|
||||
if (collapsed) shell.classList.add('rail-collapsed');
|
||||
|
||||
function toggle() {
|
||||
const toggle = () => {
|
||||
collapsed = !collapsed;
|
||||
localStorage.setItem(KEY, String(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);
|
||||
}
|
||||
|
||||
mount(root,
|
||||
el('div', { class: 'rail-toggle', onclick: toggle, title: 'Toggle right rail' }, 'CRADLE'),
|
||||
el('div', { class: 'rail-body' },
|
||||
el('h3', { style: { marginTop: 0 } }, 'Companion'),
|
||||
el('p', { class: 'muted' }, 'Chat lands in Plan 5. The rail is here so the layout is honest about the empty space it will take.')
|
||||
)
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
16
public/markdown.js
Normal file
16
public/markdown.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Shared markdown → sanitized HTML helper.
|
||||
// Used by the companion chat rail and any other component that needs
|
||||
// safe rendered markdown outside the full editor.
|
||||
|
||||
import { marked } from './vendor/marked.esm.js';
|
||||
import DOMPurify from './vendor/purify.es.mjs';
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
|
||||
/**
|
||||
* Parse `src` as Markdown and return a DOMPurify-sanitized HTML string.
|
||||
* Safe to assign to element.innerHTML.
|
||||
*/
|
||||
export function renderMarkdown(src) {
|
||||
return DOMPurify.sanitize(marked.parse(src || ''));
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
// Tiny event bus for cross-component state (pending count, agent toggle, etc.).
|
||||
// No reactive framework — just publish/subscribe with last-value semantics.
|
||||
//
|
||||
// `state` is a plain mutable object for values that don't need subscriptions.
|
||||
// Set by the app router on each navigation; read by components like the rail.
|
||||
export const state = {
|
||||
spaceId: null, // string | null — ID of the currently active Space
|
||||
view: null, // { entityType, entityId } | null — entity focused in main panel
|
||||
};
|
||||
|
||||
const subs = new Map(); // event → Set<fn>
|
||||
const last = new Map(); // event → last value
|
||||
|
||||
@@ -139,3 +139,31 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
.search-group .group-h { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.18em; color: var(--accent); text-transform: uppercase; margin-bottom: 6px; }
|
||||
.search-hit { padding: 8px 10px; border: 1px solid var(--border); border-radius: 3px; margin-bottom: 6px; background: var(--panel); }
|
||||
.search-hit .hit-meta { color: var(--muted); font-size: 11px; }
|
||||
|
||||
/* ── Plan 5: right-rail companion chat ──────────────────────────────────── */
|
||||
.rail-chat { display:flex; flex-direction:column; height:100%; }
|
||||
.rail-hd { display:flex; justify-content:space-between; align-items:center; padding:9px 12px; border-bottom:1px solid var(--border,#262b38); }
|
||||
.rail-hd .who { font-weight:600; color:var(--accent,#b69cff); }
|
||||
.rail-hd .chev { cursor:pointer; color:#5b6478; }
|
||||
.rail-log { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
|
||||
.turn { display:flex; flex-direction:column; max-width:84%; }
|
||||
.turn .lbl { font-size:9.5px; letter-spacing:.12em; margin-bottom:3px; }
|
||||
.turn .msg { line-height:1.4; padding:6px 10px; border-radius:8px; background:#141826; }
|
||||
.turn.you { align-self:flex-end; align-items:flex-end; }
|
||||
.turn.you .lbl { color:#6f7ce0; } .turn.you .msg { border-right:2px solid #6f7ce0; }
|
||||
.turn.ai { align-self:flex-start; } .turn.ai .lbl { color:var(--accent,#b69cff); }
|
||||
.turn.ai .msg { border-left:2px solid var(--accent,#b69cff); }
|
||||
.tools { align-self:flex-start; }
|
||||
.tools .chip { font-family:ui-monospace,Menlo,monospace; font-size:10.5px; color:#7d869b; }
|
||||
.tools .chip.err { color:#e08a8a; }
|
||||
.draftx { align-self:flex-start; max-width:90%; border:1px solid #3a2f5e; background:#1a1530; border-radius:9px; padding:9px 11px; }
|
||||
.draftx .dh { font-size:9.5px; text-transform:uppercase; letter-spacing:.1em; color:#9b7dff; }
|
||||
.draftx .dt { color:#e3e0f5; margin:4px 0 9px; }
|
||||
.draftx .row { display:flex; gap:6px; }
|
||||
.draftx .ok { background:#2a6f4f; color:#d9ffe9; border:none; border-radius:6px; padding:4px 12px; }
|
||||
.draftx .no { background:#2a2f3d; color:#aeb6c7; border:none; border-radius:6px; padding:4px 12px; }
|
||||
.draftx.resolved { opacity:.55; } .resolved-tag { font-size:10px; text-transform:uppercase; color:#7d869b; margin-top:6px; }
|
||||
.rail-inputwrap { border-top:1px solid var(--border,#262b38); padding:9px 12px; }
|
||||
.rail-input { width:100%; resize:none; background:#0c0e14; color:#c9d1e0; border:1px solid #262b38; border-radius:8px; padding:7px 9px; }
|
||||
.err { color:#e08a8a; font-size:12px; }
|
||||
#shell.rail-collapsed .rail-chat { display:none; }
|
||||
|
||||
Reference in New Issue
Block a user