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