diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index aa0760d..64c4873 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -43,6 +43,7 @@ const phone = () => matchMedia('(max-width: 640px)').matches; const isOpen = () => box.classList.contains('on'); function show() { + placeBox(); box.classList.add('on'); if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href); where(); @@ -57,7 +58,73 @@ function setOn(on) { fab.hidden = !on; if (!on) hide(); } fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {}); document.addEventListener('lpra:ai', ev => setOn(ev.detail)); - fab.onclick = () => (isOpen() ? hide() : show()); + fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); }; + + // ---------- on a computer: the ball and the chat move ---------- + // The ball goes where it is dragged and stays there (kept in this + // browser, as distances from the right and bottom edges); the chat opens + // beside it, toward the room, and moves by its title. + const desk = !matchMedia('(pointer: coarse)').matches; + let moved = false; + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + function placeFab(right, bottom) { + right = clamp(right, 8, innerWidth - fab.offsetWidth - 8); + bottom = clamp(bottom, 8, innerHeight - fab.offsetHeight - 8); + fab.style.right = right + 'px'; fab.style.bottom = bottom + 'px'; + return {right, bottom}; + } + function placeBox() { + if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; } + const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12; + const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2; + let x = toLeft ? f.right - w : f.left; + let y = up ? f.top - gap - h : f.bottom + gap; + // No room above or below: beside the ball. + if (y < 8 || y + h > innerHeight - 8) { y = clamp(f.top + f.height - h, 8, innerHeight - h - 8); x = toLeft ? f.left - gap - w : f.right + gap; } + box.style.right = box.style.bottom = 'auto'; + box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px'; + box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px'; + box.style.transformOrigin = (up ? 'bottom ' : 'top ') + (toLeft ? 'right' : 'left'); + } + if (desk) { + try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) requestAnimationFrame(() => placeFab(p.right, p.bottom)); } catch (e) { /* default */ } + const drag = (el, ev, onMove, onEnd) => { + if (ev.button !== 0) return; + const x0 = ev.clientX, y0 = ev.clientY; + let on = false; + const mv = e => { + if (!on && Math.hypot(e.clientX - x0, e.clientY - y0) < 5) return; + on = true; + onMove(e.clientX - x0, e.clientY - y0); + }; + const up = () => { + removeEventListener('pointermove', mv); removeEventListener('pointerup', up); + document.body.classList.remove('ai-dragging'); + if (on) onEnd(); + }; + addEventListener('pointermove', mv); addEventListener('pointerup', up); + document.body.classList.add('ai-dragging'); + }; + fab.addEventListener('pointerdown', ev => { + const r0 = innerWidth - fab.getBoundingClientRect().right, b0 = innerHeight - fab.getBoundingClientRect().bottom; + let pos = {right: r0, bottom: b0}; + drag(fab, ev, (dx, dy) => { moved = true; pos = placeFab(r0 - dx, b0 - dy); if (isOpen()) placeBox(); }, () => { + try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ } + }); + }); + q('.ai-head').addEventListener('pointerdown', ev => { + if (ev.target.closest('button')) return; + const r = box.getBoundingClientRect(); + drag(box, ev, (dx, dy) => { + box.style.left = clamp(r.left + dx, 8, innerWidth - r.width - 8) + 'px'; + box.style.top = clamp(r.top + dy, 8, innerHeight - r.height - 8) + 'px'; + }, () => {}); + }); + addEventListener('resize', () => { + try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) placeFab(p.right, p.bottom); } catch (e) { /* default */ } + if (isOpen()) placeBox(); + }); + } q('.ai-close').onclick = hide; q('.ai-clear').onclick = () => { if (ac) ac.abort(); diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 516dad4..97c8b5a 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -820,11 +820,11 @@ dialog.machinedlg { max-width:1000px; width:94vw; max-height:90vh; padding:0; ov .ai-form label { display:grid; gap:4px; font-size:13px; color:var(--muted); } /* The assistant: a round button, and the chat over the page's corner. */ -.ai-fab { position:fixed; right:18px; bottom:18px; z-index:40; width:52px; height:52px; padding:0; border-radius:50%; +.ai-fab { position:fixed; right:28px; bottom:28px; z-index:40; width:52px; height:52px; padding:0; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; box-shadow:0 8px 24px rgba(0,0,0,.45); } .ai-fab[hidden] { display:none; } body.touch.term-active .ai-fab { bottom:104px; } -.ai-box { position:fixed; right:18px; bottom:82px; z-index:41; width:360px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px)); +.ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:360px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px)); display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--border); border-radius:12px; box-shadow:0 18px 44px rgba(0,0,0,.55); overflow:hidden; transform-origin:bottom right; opacity:0; visibility:hidden; pointer-events:none; transform:translateY(14px) scale(.96); @@ -876,3 +876,15 @@ dialog.uidlg { max-width:420px; width:92vw; } .ai-think { margin:0 0 6px; font-size:12.5px; color:var(--muted); } .ai-think summary { cursor:pointer; } .ai-think > div { margin-top:4px; padding-left:10px; border-left:2px solid var(--border); max-height:200px; overflow:auto; } +/* The chat's title moves it on a computer. */ +body:not(.touch) .ai-head { cursor:grab; user-select:none; } +body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; } +body:not(.touch) .ai-fab { cursor:grab; touch-action:none; } +/* Send: the field's height, square. */ +.ai-form-in textarea, .ai-send { box-sizing:border-box; } +.ai-send { width:40px; height:40px; align-self:flex-end; } +@media (max-width: 640px) { + .ai-fab { width:46px; height:46px; right:22px; bottom:24px; } + body.touch.term-active .ai-fab { bottom:110px; } + .ai-form-in textarea { font-size:16px; } +}