Teach the chat component what the Scheduler dock needs

Offers, a settable subject, runtime heights and a profile hook — everything the dock does that
the shared component could not, so it can stop being a second implementation.
This commit is contained in:
Gmer4Lfe
2026-08-09 11:50:02 -04:00
parent c431370a68
commit d0ff0c7d5c
+127 -12
View File
@@ -174,6 +174,13 @@ function vv_ai_chat_assets(): void {
.vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; flex-shrink:0; } .vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; flex-shrink:0; }
.vv-ai-meta { font-size:10px; color:#333; margin-top:6px; font-family:monospace; } .vv-ai-meta { font-size:10px; color:#333; margin-top:6px; font-family:monospace; }
/* An offer's buttons sit under the message that made them, indented to the body so they read as
part of what was said rather than as composer controls that drifted up the transcript. Once
answered the pair is replaced by the decision, which is quieter than a disabled button and
still says what happened. */
.vv-ai-offer { display:flex; gap:7px; margin-top:8px; }
.vv-ai-offer-done { font-size:10px; color:#4a4a4a; font-style:italic; margin-top:6px; }
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; } .vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
.vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; } .vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} } @keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
@@ -344,6 +351,12 @@ vv_ai_profiles_script();
const $ = sfx => document.getElementById(P + '-' + sfx); const $ = sfx => document.getElementById(P + '-' + sfx);
const onTurn = o.onTurn || function () {}; const onTurn = o.onTurn || function () {};
const onChats = o.onChats || function () {}; const onChats = o.onChats || function () {};
// Called with (kind, yes, message) when the operator answers an offer the page made.
const onOffer = o.onOffer || function () {};
// Called whenever the active profile changes, however it changed — the picker, a reopened
// conversation, or the page retargeting. A page holding its own copy of "which profile" has
// no other way to stay in step with a menu it does not own.
const onProfile = o.onProfile || function () {};
const store = o.chats !== false; const store = o.chats !== false;
const POLL_MS = 1200; const POLL_MS = 1200;
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
@@ -356,6 +369,7 @@ vv_ai_profiles_script();
let messages = []; // whole transcript — displayed and stored let messages = []; // whole transcript — displayed and stored
let sendFrom = 0; // index the model is allowed to see from let sendFrom = 0; // index the model is allowed to see from
let chatId = ''; // '' until the store mints one let chatId = ''; // '' until the store mints one
let scopeLabel = o.scopeLabel || ''; // subject shown on the chip; '' on surfaces without one
let busy = false; let busy = false;
let lastSources = []; let lastSources = [];
let pendingTimer = null; let pendingTimer = null;
@@ -459,7 +473,10 @@ vv_ai_profiles_script();
if (cite) { if (cite) {
const s = lastSources[Number(cite.dataset.cite) - 1]; const s = lastSources[Number(cite.dataset.cite) - 1];
if (s && s.path) vvAiOpen(s.path); if (s && s.path) vvAiOpen(s.path);
return;
} }
const off = e.target.closest('[data-offer]');
if (off) answerOffer(+off.dataset.offer, off.dataset.yes === '1');
}); });
// ── Ask / poll ─────────────────────────────────────────────────────── // ── Ask / poll ───────────────────────────────────────────────────────
@@ -534,7 +551,9 @@ vv_ai_profiles_script();
// that exists in both places on purpose. // that exists in both places on purpose.
function sendable() { function sendable() {
const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2); const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2);
return messages.slice(floor); // Reduced to role and content. Messages carry local bookkeeping now — an offer's state is
// ours, not something the model should be reading back as part of the conversation.
return messages.slice(floor).map(m => ({ role: m.role, content: m.content }));
} }
function finish() { function finish() {
@@ -593,14 +612,55 @@ vv_ai_profiles_script();
const c = chatEl(); const c = chatEl();
if (!messages.length) { reset(); return; } if (!messages.length) { reset(); return; }
c.innerHTML = ''; c.innerHTML = '';
messages.forEach(m => { messages.forEach((m, i) => {
if (m.role === 'user') { addUser(m.content); return; } if (m.role === 'user') { addUser(m.content); return; }
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>` 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></div>`)); + `<div class="vv-ai-body">${fmt(m.content)}</div>`
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`));
}); });
scroll(); scroll();
} }
// ── Offers ───────────────────────────────────────────────────────────
// An assistant message that carries a decision rather than only text. The answer is recorded
// on the message itself, so a conversation reopened tomorrow shows what was decided instead of
// asking again — and an offer left unanswered is still answerable, which a transient banner
// would not be.
function offerHtml(off, i) {
if (off.state !== 'open') {
return `<div class="vv-ai-offer-done" data-offer-row="${i}">`
+ (off.state === 'taken' ? 'yes' : 'no thanks') + `</div>`;
}
return `<div class="vv-ai-offer" data-offer-row="${i}">`
+ `<button class="vv-ai-btn" type="button" data-offer="${i}" data-yes="1">Yes</button>`
+ `<button class="vv-ai-btn ghost" type="button" data-offer="${i}" data-yes="0">No</button>`
+ `</div>`;
}
// Appended, not rendered. render() rebuilds the transcript as plain turns and would strip the
// sources and reasoning off the answer the operator is looking at — see the note under
// loadChat. An offer arrives after an answer, so that is exactly when it must not happen.
function offer(kind, text) {
const i = messages.length;
messages.push({ role: 'assistant', content: text, offer: { kind: kind, state: 'open' } });
chatEl().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-body">${fmt(text)}</div>`
+ offerHtml(messages[i].offer, i) + `</div>`));
scroll();
save();
}
function answerOffer(i, yes) {
const m = messages[i];
if (!m || !m.offer || m.offer.state !== 'open') return;
m.offer.state = yes ? 'taken' : 'declined';
const row = chatEl().querySelector('[data-offer-row="' + i + '"]');
if (row) row.outerHTML = offerHtml(m.offer, i);
save();
onOffer(m.offer.kind, yes, m);
}
function reset() { function reset() {
chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`; chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
} }
@@ -631,7 +691,16 @@ vv_ai_profiles_script();
function applyProfile(p) { function applyProfile(p) {
profile = p; profile = p;
const chipL = $('chip-l'); const chipL = $('chip-l');
if (chipL) chipL.textContent = PROFILES[p] ? PROFILES[p].label : p; // With a subject, the chip states both — the contract and the thing being asked about — and
// uses the profile's short name to keep the pair readable in a narrow bar. That pairing is
// load-bearing on the Scheduler: if the operator can see what it thinks it is looking at, a
// wrong inference costs a glance instead of a confidently wrong answer.
if (chipL) {
const def = PROFILES[p];
chipL.textContent = scopeLabel
? ((def && def.short ? def.short : p) + ' · ' + scopeLabel)
: (def ? def.label : p);
}
const menu = $('menu'); const menu = $('menu');
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b => if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p)); b.classList.toggle('active', b.dataset.prof === p));
@@ -641,6 +710,7 @@ vv_ai_profiles_script();
if (chip) chip.title = PROFILES[p] ? PROFILES[p].hint : ''; if (chip) chip.title = PROFILES[p] ? PROFILES[p].hint : '';
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null; const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none'; if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
onProfile(p);
} }
// Switching moves the floor rather than clearing the transcript. Carrying cited, // Switching moves the floor rather than clearing the transcript. Carrying cited,
@@ -764,20 +834,26 @@ vv_ai_profiles_script();
// Scroll position is pinned to the bottom afterwards. Growing the box leaves the transcript // Scroll position is pinned to the bottom afterwards. Growing the box leaves the transcript
// scrolled where it was, which puts the newest answer off-screen at the exact moment you // scrolled where it was, which puts the newest answer off-screen at the exact moment you
// asked for more room to read it. // asked for more room to read it.
// The two heights live on the element as data attributes rather than in a closure, so a page
// whose sizes are not knowable when the markup is written can rewrite them later — the
// Scheduler's panel takes its heights as a share of whatever room the panel has, which is a
// number that only exists after layout and changes on every resize. See setHeights().
let big = false;
const growBtn = $('grow'); const growBtn = $('grow');
if (growBtn) { function applyHeights() {
const el = chatEl(); const el = chatEl();
const base = el.dataset.h || ''; const base = el.dataset.h || '';
const tall = el.dataset.hTall || ''; const tall = el.dataset.hTall || '';
const apply = big => { if (!base || !tall) return;
if (!base || !tall) return; el.style.height = big ? tall : base;
el.style.height = big ? tall : base; if (growBtn) {
growBtn.textContent = big ? '⤡' : '⤢'; growBtn.textContent = big ? '⤡' : '⤢';
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room'; growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
growBtn.classList.toggle('vv-ai-grow-on', big); growBtn.classList.toggle('vv-ai-grow-on', big);
scroll(); }
}; }
growBtn.addEventListener('click', () => apply(el.style.height !== tall)); if (growBtn) {
growBtn.addEventListener('click', () => { big = !big; applyHeights(); scroll(); });
} }
// Surface any script error into the transcript. Without it a throw anywhere on the page is // Surface any script error into the transcript. Without it a throw anywhere on the page is
@@ -820,8 +896,47 @@ vv_ai_profiles_script();
const inst = { const inst = {
prefix: P, prefix: P,
setProfile, newChat, loadChat, send, setProfile, newChat, loadChat, send, offer,
busy: () => busy,
expanded: () => big,
currentId: () => chatId, currentId: () => chatId,
// Heights supplied after the fact, for a placement whose room is a share of a panel rather
// than a constant. Re-applies immediately at whichever of the two states is current, so a
// resize while expanded stays expanded instead of snapping back.
setHeights(base, tall) {
const el = chatEl();
el.dataset.h = base; el.dataset.hTall = tall;
applyHeights();
},
// Same contract, different subject — the Scheduler pointing the chat at another script, log
// or conf as the operator moves around the tab. Distinct from setProfile: that changes who
// is answering, this changes what about.
//
// The transcript keeps everything and only the model's floor moves, because a troubleshooting
// thread about one script must not bleed into a question about another, while hiding that the
// earlier exchange happened is worse than carrying it visibly. chatId is dropped with it: the
// scope is part of the stored record, so appending turns about a different thing to the same
// row would produce a conversation whose stored scope describes only its first half.
retarget(prof, label, note) {
if (label !== undefined) scopeLabel = label;
applyProfile(PROFILES[prof] ? prof : profile);
if (messages.length > sendFrom && note) {
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(note) + '</div>'));
scroll();
}
sendFrom = messages.length;
chatId = '';
lastSources = [];
},
// A line in the transcript that is not a turn — something the page did, said where the
// operator is already looking rather than in a banner they have to notice.
note(text) {
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(text) + '</div>'));
scroll();
},
teardown() { teardown() {
clearInterval(pendingTimer); clearInterval(pendingTimer);
window.removeEventListener('error', onErr); window.removeEventListener('error', onErr);