diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 5cb3082..aa479ee 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -15,10 +15,9 @@ fab.hidden = true; fab.title = 'ИИ-ассистент'; fab.setAttribute('aria-label', 'ИИ-ассистент'); - fab.innerHTML = ''; + fab.innerHTML = ''; const box = document.createElement('div'); box.className = 'ai-box'; - box.hidden = true; box.innerHTML = '
Ассистент' + '' + @@ -32,11 +31,27 @@ const talk = []; // {role, content} let ac = null; - function setOn(on) { fab.hidden = !on; if (!on) box.hidden = true; } + // Open and shut with a short slide. On a phone the chat takes the screen + // and is one step of history: «назад» shuts it. + const phone = () => matchMedia('(max-width: 640px)').matches; + const isOpen = () => box.classList.contains('on'); + function show() { + box.classList.add('on'); + if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href); + where(); + if (!phone()) input.focus(); + } + function hide() { + if (!isOpen()) return; + if (history.state && history.state.aiChat) { history.back(); return; } + box.classList.remove('on'); + } + window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) box.classList.remove('on'); }); + 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 = () => { box.hidden = !box.hidden; if (!box.hidden) { where(); input.focus(); } }; - q('.ai-close').onclick = () => { box.hidden = true; }; + fab.onclick = () => (isOpen() ? hide() : show()); + q('.ai-close').onclick = hide; q('.ai-clear').onclick = () => { if (ac) ac.abort(); talk.length = 0; diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index cb70f08..4885f31 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -824,10 +824,14 @@ dialog.machinedlg { max-width:1000px; width:94vw; max-height:90vh; padding:0; ov 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:400px; max-width:calc(100vw - 24px); height:min(600px, calc(100vh - 120px)); +.ai-box { position:fixed; right:18px; bottom:82px; 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; } -.ai-box[hidden] { display:none; } + 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); + transition:opacity .22s ease, transform .22s ease, visibility 0s linear .22s; } +.ai-box.on { opacity:1; visibility:visible; pointer-events:auto; transform:none; transition:opacity .22s ease, transform .22s ease; } +.ai-fab { transition:transform .2s ease, box-shadow .2s ease; background:linear-gradient(135deg, #5b8cff, #8b5cf6); border:0; color:#fff; } +.ai-fab:hover { transform:scale(1.06); box-shadow:0 10px 28px rgba(91,140,255,.45); } .ai-head { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--border); } .ai-head .ai-where { font-size:12px; } .ai-list { flex:1; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; } @@ -843,8 +847,9 @@ body.touch.term-active .ai-fab { bottom:104px; } background:#0c0f13; border:1px solid var(--border); border-radius:8px; } .ai-form-in textarea:focus { outline:none; border-color:var(--accent); } @media (max-width: 640px) { - .ai-box { right:8px; left:8px; width:auto; max-width:none; bottom:78px; height:calc(100dvh - 150px); } - body.touch.term-active .ai-box { bottom:164px; height:calc(100dvh - 236px); } + /* A phone: the chat is a screen of its own, sliding up. */ + .ai-box { inset:0; width:auto; max-width:none; height:auto; border-radius:0; border:0; z-index:60; transform:translateY(40px); } + .ai-box.on { transform:none; } } .ai-send { width:40px; height:40px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:8px; } /* The panel's own confirm and prompt. */ diff --git a/internal/server/web/static/app.js b/internal/server/web/static/app.js index 11a3565..6c7a9fb 100644 --- a/internal/server/web/static/app.js +++ b/internal/server/web/static/app.js @@ -61,11 +61,15 @@ $('main#app').dataset.view = view; document.dispatchEvent(new CustomEvent('app:view', {detail: view})); } + // The address the shell shows; a history step that keeps it (a chat on a + // phone closing by «назад») moves no view. + let shown = location.pathname + location.search; async function go(url, push = true) { const u = new URL(url, location.href); const view = viewOf(u.pathname); if (!view || u.origin !== location.origin) { location.href = u.href; return; } if (push && u.pathname + u.search !== location.pathname + location.search) history.pushState({}, '', u.pathname + u.search + u.hash); + shown = u.pathname + u.search; show(view); if (view !== 'terminal') await load($('.view[data-view="' + view + '"]'), u.pathname + u.search); } @@ -77,7 +81,7 @@ const u = new URL(a.href, location.href); if (u.origin === location.origin && viewOf(u.pathname)) { ev.preventDefault(); go(u.href); } }); - window.addEventListener('popstate', () => go(location.href, false)); + window.addEventListener('popstate', () => { if (location.pathname + location.search !== shown) go(location.href, false); }); // A form in a fragment is sent from here: the answer's address says what // to draw. /logout and forms outside the views go the usual way; a form