// 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 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 micLabel = el('span', {}, 'Tap to record'); const mic = el('button', { class: 'dross-mic', title: 'Record a voice note' }, el('span', { html: '' }), micLabel); 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); document.getElementById('shell').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(); } // NB: do NOT auto-focus the input — on mobile that pops the keyboard every // time Dross opens. The keyboard should only appear when the user taps the box. } 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); // Topbar ◆ button (and any caller) can summon/dismiss Dross. window.addEventListener('dross-toggle', () => panel.classList.contains('open') ? closePanel() : openPanel()); drag(fab, fab, true); drag(header, panel, false); // ---- voice: tap mic to record, tap again to stop → transcribe → review-and-send ---- let media = null, chunks = [], recording = false; function setMic(label, rec) { micLabel.textContent = label; mic.classList.toggle('rec', !!rec); } async function startRec() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); chunks = []; const opt = (window.MediaRecorder && MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) ? { mimeType: 'audio/webm;codecs=opus' } : {}; media = new MediaRecorder(stream, opt); media.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; media.onstop = async () => { stream.getTracks().forEach(t => t.stop()); await sendClip(new Blob(chunks, { type: media.mimeType || 'audio/webm' })); }; media.start(); recording = true; setMic('● Recording… tap to stop', true); } catch { setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800); } } function stopRec() { if (media && recording) { recording = false; setMic('Transcribing…', false); media.stop(); } } async function sendClip(blob) { try { const fd = new FormData(); fd.append('audio', blob, 'clip.webm'); const res = await fetch('/api/voice/transcribe', { method: 'POST', headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') }, body: fd }); if (!res.ok) throw new Error('stt'); const { text } = await res.json(); setMic('Tap to record', false); if (text) { input.value = input.value ? (input.value + ' ' + text) : text; input.focus(); } // voiceMode 'handsfree'/'action' (Phase 2b+) would branch here. } catch { setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000); } } mic.addEventListener('click', () => recording ? stopRec() : startRec()); 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); }); }