diff --git a/public/app.js b/public/app.js index 1eb94d8..332bfc1 100644 --- a/public/app.js +++ b/public/app.js @@ -6,7 +6,7 @@ import { api } from './api.js'; 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 { renderDrossBubble } from './components/dross_bubble.js'; import { emit, state } from './state.js'; import { el, mount } from './dom.js'; import { attachDropzone } from './components/dropzone.js'; @@ -84,7 +84,7 @@ async function init() { await loadTheme(); // apply saved palette overrides before rendering chrome renderTopbar(document.getElementById('topbar')); renderSidebar(document.getElementById('sidebar')); - renderRightrail(document.getElementById('rightrail')); + renderDrossBubble(); initChrome(); attachDropzone(document.getElementById('main')); route(renderView); diff --git a/public/components/dross_bubble.js b/public/components/dross_bubble.js new file mode 100644 index 0000000..6f9a50a --- /dev/null +++ b/public/components/dross_bubble.js @@ -0,0 +1,88 @@ +// public/components/dross_bubble.js +// Global floating Dross companion. Replaces the per-Space right rail. +import { el, mount } from '../dom.js'; +import { api } from '../api.js'; +import { state } from '../state.js'; +import { wireAgentChat } from './agent_chat.js'; +import { drossAvatar } from './dross_avatar.js'; + +const TOOL_LABELS = { search: 'πŸ” searching', read: 'πŸ“„ reading', context: '🧭 looking at this view', propose_change: 'πŸ“ drafting a change' }; +let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' }; + +function applyAccent(node, hex) { + node.style.setProperty('--dross', hex); +} + +export async function renderDrossBubble() { + try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ } + + const host = el('div', { class: 'dross-host' }); + document.getElementById('shell').appendChild(host); + + const fab = el('div', { class: 'dross-fab', title: 'Dross' }, + el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60)); + const log = el('div', { class: 'dross-log' }); + const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' }); + const sendBtn = el('button', { class: 'dross-send', title: 'Send' }, + el('span', { html: '' })); + const mic = el('button', { class: 'dross-mic', disabled: true, title: 'Voice arrives in Phase 2' }, + el('span', { html: '' }), 'Hold to talk'); + const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '‬'); + const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30), + el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn); + const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' }, + el('span', { class: 'grip' }), el('span', {}, 'βŒ„ collapse'), el('span', { class: 'grip' })); + const panel = el('div', { class: 'dross-panel' }, header, log, + el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse); + + host.append(fab, panel); + applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent); + + const chat = wireAgentChat({ + logEl: log, inputEl: input, sendBtnEl: sendBtn, + historyUrl: '/api/dross', turnUrl: '/api/dross/turn', + agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS, + turnBody: (text) => ({ text, view: state.view || null }) + }); + let loaded = false; + + function openPanel() { + const r = fab.getBoundingClientRect(); + panel.classList.add('open'); fab.style.display = 'none'; + const pr = panel.getBoundingClientRect(); + const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8)); + const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8)); + panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px'; + if (!loaded) { loaded = true; chat.load(); } + input.focus(); + } + function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; } + fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); }); + closeBtn.addEventListener('click', closePanel); + collapse.addEventListener('click', closePanel); + + drag(fab, fab, true); drag(header, panel, false); + + window.addEventListener('dross-settings-changed', async () => { + try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; } + applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent); + mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60)); + header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild); + }); +} + +function drag(handle, target, isFab) { + handle.addEventListener('pointerdown', (e) => { + if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return; + e.preventDefault(); + const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false; + target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px'; + const mv = (ev) => { + const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true; + target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px'; + target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px'; + }; + const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; }; + document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up); + }); +} diff --git a/public/index.html b/public/index.html index a029a7d..7ac0580 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,6 @@
-