feat: the assistant's chat - the name clear of the close button, typing dots while the model starts, thinking that opens without reflow; asking before a command: «Выполнить команду на [машина]?», the command in mono, a red «Отмена»

This commit is contained in:
redtea committed 2026-10-16 10:48:05 +03:00
1 parent 3641a3627c
commit fcf9417f5a
3 files changed
+55 -10

No files matched your search

+13 -5
View File
@@ -356,10 +356,16 @@
}
// ask first for what changes something: commands and writes.
function needsYes(a) { return a.kind === 'run' || a.kind === 'write'; }
function describe(a) {
const where = a.machine ? a.machine + ': ' : '';
return a.kind === 'run' ? 'Выполнить на ' + (a.machine || 'активной машине') + ':\n\n' + a.body
: 'Записать файл ' + where + a.path + ' (' + a.body.length + ' символов)?';
// ask what a command or a write is about to do: what, where, the command
// (or the start of the text) in mono.
function confirmAction(a) {
const m = machineOf(a.machine), where = m ? m.name : (a.machine || 'активная машина');
if (a.kind === 'run') return UI.confirm('Выполнить команду на {chip}?', {ok: 'Выполнить', chip: where, code: a.body,
note: 'Уйдёт в терминал этой машины с вашими правами.'});
const lines = a.body.split('\n');
return UI.confirm('Записать файл {chip}?', {ok: 'Записать', chip: where + ':' + a.path,
code: lines.slice(0, 12).join('\n') + (lines.length > 12 ? '\n…' : ''),
note: 'Файл будет заменён целиком (' + a.body.length + ' символов).'});
}
// ---------- messages ----------
@@ -463,6 +469,8 @@
if (res) showResult(res);
const el = add('assistant', '');
el.classList.add('wait');
// While the model starts: a typing mark, gone with the first words.
el.querySelector('.ai-body').innerHTML = '<span class="ai-typing" aria-label="печатает"><i></i><i></i><i></i></span>';
ac = new AbortController();
send.innerHTML = STOP; send.title = 'Остановить';
let answer = '';
@@ -521,7 +529,7 @@
// Without «Агент» only searches go on by themselves, with «Поиск» on.
if (!agentOn() && !(a.kind === 'search' && searchOn())) break;
const b = turn.el.querySelector('.ai-act');
if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) {
if (needsYes(a) && !await confirmAction(a)) {
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
continue;
+20 -4
View File
@@ -856,7 +856,11 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-msg { max-width:92%; padding:8px 11px; border-radius:10px; font-size:14px; line-height:1.45; overflow-wrap:anywhere; }
.ai-msg.user { align-self:flex-end; background:#16324f; white-space:pre-wrap; }
.ai-msg.assistant { align-self:flex-start; background:#151a21; border:1px solid var(--border); }
.ai-msg.wait::after { content:'…'; color:var(--muted); }
/* The model starting: three dots rising in turn. */
.ai-typing { display:inline-flex; gap:4px; padding:4px 2px; }
.ai-typing i { width:6px; height:6px; border-radius:50%; background:var(--muted); animation:ai-typing 1.2s ease-in-out infinite; }
.ai-typing i:nth-child(2) { animation-delay:.15s; } .ai-typing i:nth-child(3) { animation-delay:.3s; }
@keyframes ai-typing { 0%, 60%, 100% { transform:translateY(0); opacity:.4; } 30% { transform:translateY(-5px); opacity:1; } }
.ai-code { margin:6px 0; }
.ai-code pre { margin:0; padding:8px; background:#0c0f13; border:1px solid var(--border); border-radius:7px; overflow:auto; font-size:12.5px; white-space:pre-wrap; }
.ai-code .ai-run { margin-top:6px; }
@@ -950,8 +954,12 @@ main#app > .view:not([hidden]) { animation:view-in .22s ease; } /* no fill: a la
@keyframes ai-shine { to { background-position:-200% 0; } }
.ai-think-b { display:grid; grid-template-rows:0fr; transition:grid-template-rows .22s ease, opacity .2s ease; opacity:0; }
.ai-think.open .ai-think-b { grid-template-rows:1fr; opacity:1; }
.ai-think-in { min-height:0; overflow:hidden; font-size:12px; color:var(--muted); line-height:1.45; }
.ai-think.open .ai-think-in { margin-top:4px; padding-left:10px; border-left:2px solid var(--border); max-height:220px; overflow:auto; }
.ai-think-in { min-height:0; overflow:hidden; font-size:12px; color:var(--muted); line-height:1.45;
margin-top:4px; padding-left:10px; border-left:2px solid var(--border); }
.ai-think.open .ai-think-in { max-height:220px; overflow:auto; }
/* With thinking, the answer keeps its width: the thinking opens without
reflowing (short answers made the bubble narrow, the words then moved). */
.ai-msg.assistant:has(> .ai-think:not([hidden])) { width:92%; }
.ai-think-in p { margin:0 0 4px; }
/* Folding parts (Сменить пароль, PIN-код, more settings, results) open and
shut smoothly, their arrow turning, as the + menu's groups do. */
@@ -966,7 +974,7 @@ details::details-content { block-size: 0; overflow: hidden; opacity: 0;
details[open]::details-content { block-size: auto; opacity: 1; }
/* The assistant's chats: the name in the header, the list under it. */
.ai-head { position:relative; }
.ai-chatsel { display:inline-flex; align-items:center; gap:6px; min-width:0; max-width:65%; padding:4px 6px; margin-left:-6px; border-radius:7px;
.ai-chatsel { display:inline-flex; align-items:center; gap:6px; min-width:0; max-width:calc(100% - 64px); padding:4px 6px; margin-left:-6px; border-radius:7px;
background:none; border:0; color:var(--text); font-weight:600; font-size:14px; cursor:pointer; }
.ai-chatsel:hover { background:#151a21; }
.ai-chatname { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -1029,3 +1037,11 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot
.ai-form .ai-sub { margin:10px 0 0; font-size:15px; }
.ai-form:not([data-search="searxng"]) .s-url { display:none; }
.ai-form:not([data-search="brave"]):not([data-search="tavily"]) .s-key { display:none; }
/* A question about an action: where, the command in mono, a note. */
.ui-extra { display:grid; gap:8px; margin-top:-4px; min-width:0; }
.ui-chip { justify-self:start; display:inline-block; vertical-align:middle; margin:-2px 2px 0; max-width:100%; padding:1px 8px; line-height:18px; border-radius:999px; background:#12202f; color:#9db8ff;
font:12px ui-monospace, SFMono-Regular, Menlo, monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ui-code { margin:0; padding:9px 11px; background:#0c0f13; border:1px solid var(--border); border-radius:8px; max-height:220px; overflow:auto;
font:12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space:pre; color:var(--text); }
/* A command is shown as it is: no line broken inside it, a long one scrolls. */
.ui-note { margin:0; font-size:12px; color:var(--muted); }
+22 -1
View File
@@ -9,7 +9,7 @@
dlg = document.createElement('dialog');
dlg.className = 'uidlg';
dlg.innerHTML = '<form method="dialog"><p class="ui-text"></p><input class="ui-input" autocomplete="off" spellcheck="false">' +
'<div class="row end"><button type="button" class="btn ghost ui-no">Отмена</button><button type="submit" class="ui-yes">OK</button></div></form>';
'<div class="row end"><button type="button" class="btn ghost close ui-no">Отмена</button><button type="submit" class="ui-yes">OK</button></div></form>';
document.body.appendChild(dlg);
}
function ask(text, o) {
@@ -20,6 +20,9 @@
// is as wide as its words, up to the screen's room.
const t = dlg.querySelector('.ui-text');
t.textContent = '';
// "{chip}" in the text: the chip stands there, inside the sentence.
const parts = String(text).split('{chip}');
text = parts.shift();
String(text).split(/(\s+)/).forEach(w => {
if (w.length > 28 && !/\s/.test(w)) {
const s = document.createElement('span');
@@ -27,10 +30,28 @@
t.appendChild(s);
} else t.appendChild(document.createTextNode(w));
});
// Extras of a question about an action: where (a chip), the command in
// mono, a note under it.
if (parts.length && o.chip) {
const c = document.createElement('span'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip;
t.append(c, parts.join(''));
o = Object.assign({}, o, {chip: ''});
}
dlg.querySelectorAll('.ui-extra').forEach(e => e.remove());
if (o.chip || o.code || o.note) {
const box = document.createElement('div');
box.className = 'ui-extra';
if (o.chip) { const c = document.createElement('div'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip; box.appendChild(c); }
if (o.code) { const p = document.createElement('pre'); p.className = 'ui-code'; p.textContent = o.code; box.appendChild(p); }
if (o.note) { const n = document.createElement('p'); n.className = 'ui-note'; n.textContent = o.note; box.appendChild(n); }
t.after(box);
}
input.hidden = !o.input;
input.value = o.value || '';
yes.textContent = o.ok || 'OK';
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : '');
// «Отмена» red, unless «да» is the red one (a delete): never two reds.
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
return new Promise(done => {
const end = v => { dlg.onclose = null; done(v); };
dlg.querySelector('.ui-no').onclick = () => dlg.close('');