feat: the send button as in Telegram - a blue plane with no plate, coming in only when there is something to send; the empty chat shows a chat mark and «Спросите AI помощника»

This commit is contained in:
redtea committed 2026-10-16 17:56:53 +03:00
1 parent 37ae830cbf
commit 35ad2d435b
2 files changed
+34 -9

No files matched your search

+12 -1
View File
@@ -24,7 +24,7 @@
'<span class="muted ai-where"></span><span class="spacer"></span>' + '<span class="muted ai-where"></span><span class="spacer"></span>' +
'<button type="button" class="btn ghost small icon close x ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' + '<button type="button" class="btn ghost small icon close x ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
'<div class="ai-chats" inert><div class="ai-chats-in"><button type="button" class="ai-chatnew">+ Новый чат</button><div class="ai-chatlist"></div></div></div>' + '<div class="ai-chats" inert><div class="ai-chats-in"><button type="button" class="ai-chatnew">+ Новый чат</button><div class="ai-chatlist"></div></div></div>' +
'<div class="ai-chat-body"><div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели.</p></div>' + '<div class="ai-chat-body"><div class="ai-list"><div class="ai-hello"><span class="ai-hello-icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12.5a7.5 7.5 0 0 1-11.06 6.6L4 20.5l1.4-4.6A7.5 7.5 0 1 1 20 12.5z"/><path d="M8.6 12.5h.01M12.5 12.5h.01M16.4 12.5h.01" stroke-width="2.4"/></svg></span><span class="ai-hello-text">Спросите AI помощника</span></div></div>' +
'<button type="button" class="ai-jump" hidden title="К последним сообщениям" aria-label="К последним сообщениям"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button></div>' + '<button type="button" class="ai-jump" hidden title="К последним сообщениям" aria-label="К последним сообщениям"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button></div>' +
'<div class="ai-pills">' + '<div class="ai-pills">' +
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' + '<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
@@ -471,6 +471,7 @@
function renderAttachments() { function renderAttachments() {
attachList.innerHTML = ''; attachList.innerHTML = '';
attachList.hidden = !attachments.length; attachList.hidden = !attachments.length;
sendState();
attachments.forEach((a, i) => { attachments.forEach((a, i) => {
const chip = document.createElement('span'); chip.className = 'ai-file-chip'; const chip = document.createElement('span'); chip.className = 'ai-file-chip';
const name = document.createElement('span'); name.textContent = a.name; name.title = a.name; const name = document.createElement('span'); name.textContent = a.name; name.title = a.name;
@@ -627,6 +628,7 @@
el.querySelector('.ai-body').innerHTML = '<span class="ai-typing" aria-label="печатает"><i></i><i></i><i></i></span>'; el.querySelector('.ai-body').innerHTML = '<span class="ai-typing" aria-label="печатает"><i></i><i></i><i></i></span>';
ac = new AbortController(); ac = new AbortController();
send.innerHTML = STOP; send.title = 'Остановить'; send.innerHTML = STOP; send.title = 'Остановить';
sendState();
let answer = ''; let answer = '';
try { try {
await loadMachines(); await loadMachines();
@@ -701,6 +703,7 @@
} }
ac = null; ac = null;
send.innerHTML = PLANE; send.title = 'Отправить'; send.innerHTML = PLANE; send.title = 'Отправить';
sendState();
} }
const form = q('.ai-form-in'), attachBtn = q('.ai-attach'); const form = q('.ai-form-in'), attachBtn = q('.ai-attach');
attachBtn.onclick = () => attachInput.click(); attachBtn.onclick = () => attachInput.click();
@@ -763,6 +766,14 @@
setDragging(on) { form.classList.toggle('drag', !!on); }, setDragging(on) { form.classList.toggle('drag', !!on); },
dropFiles: addPaneFiles dropFiles: addPaneFiles
}; };
// Send shows only when there is something to send, or an answer to stop.
function sendState() {
const on = !!ac || !!input.value.trim() || attachments.length > 0;
send.classList.toggle('show', on);
send.tabIndex = on ? 0 : -1;
}
input.addEventListener('input', sendState);
sendState();
// The field grows with the text, up to a few lines. // The field grows with the text, up to a few lines.
function grow() { input.style.height = '40px'; input.style.height = Math.min(140, input.scrollHeight + 2) + 'px'; } function grow() { input.style.height = '40px'; input.style.height = Math.min(140, input.scrollHeight + 2) + 'px'; }
input.addEventListener('input', grow); input.addEventListener('input', grow);
+22 -8
View File
@@ -860,6 +860,13 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-head .ai-chatname { min-width:3ch; } .ai-head .ai-chatname { min-width:3ch; }
.ai-head .ai-chatsel { flex:0 1 auto; } .ai-head .ai-chatsel { flex:0 1 auto; }
.ai-chat-body { position:relative; flex:1; min-height:0; } .ai-chat-body { position:relative; flex:1; min-height:0; }
/* The empty chat: a quiet chat mark and a word, in the middle. */
.ai-hello { margin:auto; display:flex; flex-direction:column; align-items:center; gap:12px; padding:24px 12px; text-align:center; animation:ai-hello-in .3s ease both; }
.ai-hello[hidden] { display:none; }
.ai-hello-icon { width:60px; height:60px; display:flex; align-items:center; justify-content:center; border-radius:50%;
color:var(--muted); background:#151a21; border:1px solid var(--border); }
.ai-hello-text { color:var(--muted); font-size:14px; font-weight:500; }
@keyframes ai-hello-in { from { opacity:0; transform:translateY(6px); } }
.ai-list { height:100%; box-sizing:border-box; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; } .ai-list { height:100%; box-sizing:border-box; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
.ai-jump { position:absolute; right:14px; bottom:14px; z-index:2; width:38px; height:38px; padding:0; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; color:var(--text); background:var(--panel); border:1px solid var(--border); box-shadow:0 4px 14px rgba(0,0,0,.35); } .ai-jump { position:absolute; right:14px; bottom:14px; z-index:2; width:38px; height:38px; padding:0; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; color:var(--text); background:var(--panel); border:1px solid var(--border); box-shadow:0 4px 14px rgba(0,0,0,.35); }
.ai-jump:hover { background:#202833; } .ai-jump:hover { background:#202833; }
@@ -895,6 +902,20 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-form-in textarea:focus { outline:none; border-color:transparent; } .ai-form-in textarea:focus { outline:none; border-color:transparent; }
.ai-attach { flex:none; width:36px; height:36px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; } .ai-attach { flex:none; width:36px; height:36px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; }
.ai-attach:hover { color:var(--text); background:#1b222c; } .ai-attach:hover { color:var(--text); background:#1b222c; }
/* Send, as in Telegram: no plate, a blue plane that comes in when there is
something to send (and stays as "stop" while the answer runs), and goes
away when the field is empty. A round shade under the pointer, like the
clip's. As far from the right edge as from the top and bottom. */
.ai-send { flex:none; box-sizing:border-box; width:0; height:36px; min-width:0; padding:0; margin:0 0 0 -5px; overflow:hidden;
display:inline-flex; align-items:center; justify-content:center; align-self:center; line-height:1;
color:var(--accent); background:transparent; border:0; border-radius:50%;
opacity:0; transform:scale(.4); pointer-events:none;
transition:width .2s ease, margin .2s ease, opacity .16s ease, transform .24s cubic-bezier(.3,1.45,.55,1), background-color .15s, color .15s; }
.ai-send.show { width:36px; margin:0 3px 0 0; opacity:1; transform:none; pointer-events:auto; }
.ai-send:hover { color:#7ab6ff; background:#1b222c; }
.ai-send:active { transform:scale(.9); }
.ai-send:focus-visible { outline:none; box-shadow:0 0 0 2px rgba(79,157,255,.45); }
.ai-send .ai-plane { display:block; flex:none; transform:translateX(1px); }
.ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; } .ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; }
.ai-attachments[hidden] { display:none; } .ai-attachments[hidden] { display:none; }
.ai-file-chip { display:inline-flex; align-items:center; gap:4px; max-width:100%; padding:3px 7px 3px 9px; border:1px solid var(--border); border-radius:999px; background:#151a21; color:var(--muted); font-size:11.5px; } .ai-file-chip { display:inline-flex; align-items:center; gap:4px; max-width:100%; padding:3px 7px 3px 9px; border:1px solid var(--border); border-radius:999px; background:#151a21; color:var(--muted); font-size:11.5px; }
@@ -910,8 +931,6 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-box { inset:0; width:auto; max-width:none; height:auto; border-radius:0; border:0; z-index:60; transform:translateY(40px); } .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-box.on { transform:none; }
} }
.ai-send { width:36px; height:36px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:9px; }
.ai-send .ai-plane { display:block; flex:none; transform:translateX(.5px); }
/* The panel's own confirm and prompt. */ /* The panel's own confirm and prompt. */
dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560px, 92vw); } dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560px, 92vw); }
.uidlg form { padding:18px; display:grid; gap:14px; } .uidlg form { padding:18px; display:grid; gap:14px; }
@@ -940,9 +959,7 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p
body:not(.touch) .ai-head { cursor:grab; user-select:none; -webkit-user-select:none; } body:not(.touch) .ai-head { cursor:grab; user-select:none; -webkit-user-select:none; }
body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; -webkit-user-select:none; } body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; -webkit-user-select:none; }
body:not(.touch) .ai-fab { cursor:grab; touch-action:none; } body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
/* Send: the field's height, square. */ .ai-form-in textarea, .ai-attach { box-sizing:border-box; }
.ai-form-in textarea, .ai-send, .ai-attach { box-sizing:border-box; }
.ai-send { width:36px; height:36px; align-self:center; }
@media (max-width: 640px) { @media (max-width: 640px) {
.ai-fab { width:46px; height:46px; right:22px; bottom:24px; } .ai-fab { width:46px; height:46px; right:22px; bottom:24px; }
body.touch.term-active .ai-fab { bottom:110px; } body.touch.term-active .ai-fab { bottom:110px; }
@@ -956,7 +973,6 @@ body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }
/* The field and the send button: one height on every phone, the button /* The field and the send button: one height on every phone, the button
square whatever the text size. */ square whatever the text size. */
.ai-form-in textarea { min-height:40px; } .ai-form-in textarea { min-height:40px; }
.ai-send { min-height:36px; max-height:36px; min-width:36px; max-width:36px; line-height:1; border-radius:9px; }
.ai-form-in textarea { border-radius:10px; } .ai-form-in textarea { border-radius:10px; }
/* Настройки: the key with its eye; PostgreSQL's fields only when chosen. */ /* Настройки: the key with its eye; PostgreSQL's fields only when chosen. */
.ai-form .keybox { display:flex; gap:6px; align-items:center; } .ai-form .keybox { display:flex; gap:6px; align-items:center; }
@@ -1097,5 +1113,3 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot
/* The + menu's roots (Терминал, Файловый менеджер) a little to the left of /* The + menu's roots (Терминал, Файловый менеджер) a little to the left of
what they open, so the nesting reads. */ what they open, so the nesting reads. */
.addmenu > .item { padding-left:7px; } .addmenu > .item { padding-left:7px; }
/* The send button as far from the right edge as from the top and bottom. */
.ai-entry .ai-send { margin-right:2px; }