Let each control actually govern the thing it names

Auto Scroll, the learning gate, the fold and the flash label each described a
behaviour they did not enforce.
This commit is contained in:
Gmer4Lfe
2026-08-11 17:25:51 -04:00
parent aa3d92360e
commit e3ec54f213
4 changed files with 162 additions and 39 deletions
+91 -17
View File
@@ -474,6 +474,20 @@ vv_ai_profiles_script();
return Math.floor(d/86400)+'d';
}
// Both timestamps, because the visible one cannot answer both questions. The store is ordered
// by creation on purpose — see vv_ai_chats_list(), which keeps the visible order in step with
// the prune order so the row about to be dropped is the one at the bottom — and the column
// therefore has to show creation age or it would run out of order. "When did I last touch
// this", which is what is actually being asked of a list of saved conversations, goes here.
function chatAges(c) {
const made = 'started ' + ago(c.ts);
// A minute of slack: every conversation is written once at creation, so updated is always a
// shade later and saying so on every row would be noise.
return (c.updated && c.updated > (c.ts || 0) + 60)
? made + ', last active ' + ago(c.updated)
: made;
}
// ── Source viewer, one per page ─────────────────────────────────────────
const $g = id => document.getElementById(id);
window.vvAiOpen = function (path) {
@@ -528,7 +542,27 @@ vv_ai_profiles_script();
let pendingTimer = null;
const chatEl = () => $('chat');
const scroll = () => { const c = chatEl(); c.scrollTop = c.scrollHeight; };
// A scroll event says the transcript moved. It does not say who moved it, and the browser
// fires an identical one either way — so syncFollow(), which answers for the operator on
// every scroll event, could answer on the strength of a scroll this component performed.
// Auto Scroll was reported as un-untickable during a stream, and this is the class of cause:
// the control cannot be authoritative while anything else is allowed to write to it.
//
// Held here rather than proven: the exact event that re-ticked it was not reproduced from the
// source, only the requirement that our own movement must never count as the operator's.
// If it turns out something else re-ticks the box, this guard is still correct and the real
// cause is still open — do not read this comment as saying the bug was diagnosed.
//
// A count, not a flag, because these nest: scroll() runs inside wrapped writes. Released on
// the next animation frame, because scroll events are dispatched in the rendering step ahead
// of requestAnimationFrame callbacks — so the guard is still up when ours arrives.
let selfMoves = 0;
function selfMove(fn) {
selfMoves++;
try { fn(); } finally { requestAnimationFrame(() => { if (selfMoves > 0) selfMoves--; }); }
}
const scroll = () => selfMove(() => { const c = chatEl(); c.scrollTop = c.scrollHeight; });
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); }
@@ -794,8 +828,12 @@ vv_ai_profiles_script();
const following = () => { const f = $('follow'); return !f || f.checked; };
function syncFollow() {
const f = $('follow');
if (f) f.checked = nearBottom();
// Only a scroll the operator actually performed may answer for them. Ours are ignored, or
// the control cannot be switched off while the thing it controls is running.
if (selfMoves === 0) {
const f = $('follow');
if (f) f.checked = nearBottom();
}
syncJump();
}
@@ -818,8 +856,10 @@ vv_ai_profiles_script();
const s = $('stream');
if (!s) return;
if (!text) { s.hidden = true; return; }
s.hidden = false;
s.innerHTML = fmt(text);
// Wrapped because replacing the bubble's content changes the transcript's scroll height,
// and any scroll event that results is this component's doing rather than the operator's.
// See selfMove().
selfMove(() => { s.hidden = false; s.innerHTML = fmt(text); });
scrollIfFollowing();
syncJump();
}
@@ -834,10 +874,14 @@ vv_ai_profiles_script();
const on = seeThink() && text;
t.hidden = !on;
if (!on) return;
t.textContent = text;
// Pinned to its own newest line, so watching reasoning does not require scrolling the
// transcript while the answer is still arriving underneath it.
t.scrollTop = t.scrollHeight;
// Wrapped for the same reason streamInto's rewrite is: growing this block changes the
// transcript's scroll height, and the resulting event is ours rather than the operator's.
selfMove(() => {
t.textContent = text;
// Pinned to its own newest line, so watching reasoning does not require scrolling the
// transcript while the answer is still arriving underneath it.
t.scrollTop = t.scrollHeight;
});
scrollIfFollowing();
}
@@ -902,7 +946,11 @@ vv_ai_profiles_script();
// Measured after it is in the DOM — scrollHeight is 0 on a detached node, so a fold decided
// before appending would either never fire or fire on everything.
foldIfLong(node);
scroll();
// Obeys Follow like every other write. This was the one place that did not, and it was the
// worst place for it: unticking the box to hold your place while an answer arrives, only to
// be dragged to the bottom the instant it lands.
scrollIfFollowing();
syncJump();
}
// A long answer buries the composer on a 15" panel, and the composer is where the next thing
@@ -1051,11 +1099,20 @@ vv_ai_profiles_script();
t.remove();
}
// The label is stashed on the element the first time and never re-read. Capturing it per
// press meant a second click inside the 1100ms window recorded "Copied" as the text to
// restore, and the button then read "Copied" for the rest of the page's life. The pending
// timer is cleared for the same reason: two in flight restore in the wrong order.
function flashBtn(btn, msg) {
const was = btn.textContent;
if (btn.dataset.label === undefined) btn.dataset.label = btn.textContent;
clearTimeout(+btn.dataset.flash || 0);
btn.textContent = msg;
btn.classList.add('ok');
setTimeout(() => { btn.textContent = was; btn.classList.remove('ok'); }, 1100);
btn.dataset.flash = setTimeout(() => {
btn.textContent = btn.dataset.label;
btn.classList.remove('ok');
delete btn.dataset.flash;
}, 1100);
}
// ── Last-exchange controls ───────────────────────────────────────────
@@ -1226,6 +1283,10 @@ vv_ai_profiles_script();
// 25-75s is exactly when the operator most wants a control, and the composer row has no
// room for a fourth button that is dead 95% of the time.
setSendMode('stop');
// Asking is an act of attention: a new turn returns to the newest line and follows it,
// whatever was left unticked from reading back through the previous one. Without this, a
// question asked after scrolling up would stream in entirely off screen.
const followBox = $('follow'); if (followBox) followBox.checked = true;
addUser(q);
$('input').value = '';
clearDraft();
@@ -1427,9 +1488,14 @@ vv_ai_profiles_script();
c.innerHTML = '';
messages.forEach((m, i) => {
if (m.role === 'user') { addUser(m.content); return; }
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-body">${fmt(m.content)}</div>`
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`));
const node = el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-body">${fmt(m.content)}</div>`
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`);
c.appendChild(node);
// Folded here too, not only on arrival. A reopened thread carrying several long answers
// is exactly the case the fold exists for — it is what buries the composer on a 15"
// panel. Measured after the append, because scrollHeight is 0 on a detached node.
foldIfLong(node);
});
scroll();
syncLast();
@@ -1616,7 +1682,7 @@ vv_ai_profiles_script();
const prof = (c.profile && c.profile !== 'chat' && PR[c.profile]) ? PR[c.profile].short : '';
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
return `<button class="vv-ai-opt${c.id === chatId ? ' active' : ''}" type="button"`
+ ` role="option" data-chat="${esc(c.id)}">`
+ ` role="option" data-chat="${esc(c.id)}" title="${esc(chatAges(c))}">`
+ `<span class="vv-ai-opt-l">${esc(c.title)}</span>`
+ `<span class="vv-ai-opt-h">${esc([tag, ago(c.ts)].filter(Boolean).join(' · '))}</span>`
+ `</button>`;
@@ -1696,6 +1762,13 @@ vv_ai_profiles_script();
// passive: this only reads scroll position, so it must never delay the scroll itself.
chatEl().addEventListener('scroll', syncFollow, { passive: true });
const followEl = $('follow');
if (followEl) {
// Ticking it by hand is a request to be at the newest line now, not merely to be taken
// there by the next delta — which on a finished conversation is never.
followEl.addEventListener('change', () => { if (followEl.checked) scroll(); syncJump(); });
}
const seeEl = $('see-think');
if (seeEl) {
// A display preference, so it is remembered per instance — the Monitor card and the AI tab
@@ -2023,7 +2096,8 @@ vv_ai_profiles_script();
const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : '';
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
return `<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
+ `<span class="vv-ai-crow-t" title="${esc(tag ? tag + ' — ' + c.title : c.title)}">`
+ `<span class="vv-ai-crow-t" title="${esc((tag ? tag + ' — ' : '') + c.title
+ ' · ' + chatAges(c))}">`
+ (tag ? `<span class="vv-ai-crow-s">${esc(tag)}</span> ` : '')
+ `${esc(c.title)}</span>`
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`