Let the last exchange be retried, edited or dropped, and stop losing drafts to a tab swap

This commit is contained in:
Gmer4Lfe
2026-08-10 21:22:06 -04:00
parent 33affc5a59
commit b9b2a59915
+107
View File
@@ -162,6 +162,13 @@ function vv_ai_chat_assets(): void {
/* ── Code cards ───────────────────────────────────────────────────────────── /* ── Code cards ─────────────────────────────────────────────────────────────
Controls are always visible, never hover-revealed. The Monitor and Scheduler run full-time on Controls are always visible, never hover-revealed. The Monitor and Scheduler run full-time on
15" panels with no pointer near them, so a control that only exists on hover does not exist. */ 15" panels with no pointer near them, so a control that only exists on hover does not exist. */
/* Sits directly above the composer, so what it acts on — the exchange just above — reads left to
right into the box you would retype it in. */
.vv-ai-last { display:flex; gap:10px; padding:0 2px 4px; }
.vv-ai-lnk { background:none; border:none; padding:0; font-size:10px; color:#4a4a4a;
cursor:pointer; text-decoration:underline; text-underline-offset:2px; }
.vv-ai-lnk:hover { color:#8a8a8a; }
.vv-ai-code { margin:8px 0; border:1px solid #222; border-radius:4px; overflow:hidden; } .vv-ai-code { margin:8px 0; border:1px solid #222; border-radius:4px; overflow:hidden; }
.vv-ai-code pre { margin:0; border:none; border-radius:0; } .vv-ai-code pre { margin:0; border:none; border-radius:0; }
.vv-ai-code-h { display:flex; align-items:center; gap:8px; padding:3px 8px; .vv-ai-code-h { display:flex; align-items:center; gap:8px; padding:3px 8px;
@@ -714,6 +721,77 @@ vv_ai_profiles_script();
setTimeout(() => { btn.textContent = was; btn.classList.remove('ok'); }, 1100); setTimeout(() => { btn.textContent = was; btn.classList.remove('ok'); }, 1100);
} }
// ── Last-exchange controls ───────────────────────────────────────────
// Shown only when there is a completed exchange and nothing in flight. Hidden rather than
// disabled: a disabled control still occupies a line on a 15" panel to advertise something
// that cannot be done.
function syncLast() {
const box = $('last');
if (!box) return;
box.hidden = busy || lastUserIndex() < 0;
}
// Index of the most recent user message. Everything from it to the end is one exchange —
// the question, its answer, and any offer that answer carried.
function lastUserIndex() {
for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'user') return i;
return -1;
}
// Truncates to just before the last question and returns what it was. The DOM is rebuilt from
// messages rather than patched, because an error bubble renders as a message but was never
// stored — so anything counting elements would drift the first time a turn failed.
function dropLastExchange() {
const i = lastUserIndex();
if (i < 0) return '';
const q = messages[i].content;
messages.length = i;
render();
save();
syncLast();
return q;
}
function lastAction(what) {
if (busy) return;
if (what === 'edit') {
const q = dropLastExchange();
if (!q) return;
const inp = $('input');
if (inp) { inp.value = q; inp.focus(); inp.setSelectionRange(q.length, q.length); saveDraft(); }
return;
}
if (what === 'drop') { dropLastExchange(); return; }
if (what === 'retry') {
const q = dropLastExchange();
if (!q) return;
const inp = $('input');
if (inp) inp.value = q;
send();
}
}
// ── Draft persistence ────────────────────────────────────────────────
// Unraid swaps tabs by replacing the DOM, which destroys anything typed and not sent. Losing a
// carefully worded question to a stray tab click is the kind of small loss that stops people
// using a tool. Keyed per instance so the three surfaces do not share one draft.
const DRAFT_KEY = 'vvAiDraft:' + P;
function saveDraft() {
try {
const v = ($('input') || {}).value || '';
if (v.trim()) localStorage.setItem(DRAFT_KEY, v); else localStorage.removeItem(DRAFT_KEY);
} catch (_) {}
}
function restoreDraft() {
try {
const v = localStorage.getItem(DRAFT_KEY);
const inp = $('input');
// Never over an existing value: resume may have put something there first.
if (v && inp && !inp.value) inp.value = v;
} catch (_) {}
}
function clearDraft() { try { localStorage.removeItem(DRAFT_KEY); } catch (_) {} }
// ── Ask / poll ─────────────────────────────────────────────────────── // ── Ask / poll ───────────────────────────────────────────────────────
function send() { function send() {
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll // Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
@@ -741,6 +819,8 @@ vv_ai_profiles_script();
setSendMode('stop'); setSendMode('stop');
addUser(q); addUser(q);
$('input').value = ''; $('input').value = '';
clearDraft();
syncLast();
addPending(); addPending();
// Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper // Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper
@@ -819,6 +899,7 @@ vv_ai_profiles_script();
curToken = null; curToken = null;
setSendMode('ask'); setSendMode('ask');
clearInterval(pendingTimer); clearInterval(pendingTimer);
syncLast();
} }
// The token of the turn in flight. Held so Stop knows what to cancel, and cleared by finish() // The token of the turn in flight. Held so Stop knows what to cancel, and cleared by finish()
@@ -939,6 +1020,7 @@ vv_ai_profiles_script();
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`)); + (m.offer ? offerHtml(m.offer, i) : '') + `</div>`));
}); });
scroll(); scroll();
syncLast();
} }
// ── Offers ─────────────────────────────────────────────────────────── // ── Offers ───────────────────────────────────────────────────────────
@@ -983,6 +1065,9 @@ vv_ai_profiles_script();
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>`;
// An emptied transcript has no last exchange. render() funnels here when messages run out,
// and New Chat calls it directly, so both paths are covered by putting it here.
syncLast();
} }
function loadChat(id) { function loadChat(id) {
@@ -1141,6 +1226,19 @@ vv_ai_profiles_script();
// One button, two jobs — which one is decided by busy rather than by what the label happens to // One button, two jobs — which one is decided by busy rather than by what the label happens to
// say, so a stale label can never send a question into a turn already running. // say, so a stale label can never send a question into a turn already running.
$('send').addEventListener('click', () => { busy ? stopTurn() : send(); }); $('send').addEventListener('click', () => { busy ? stopTurn() : send(); });
const lastBox = $('last');
if (lastBox) {
lastBox.addEventListener('click', e => {
const b = e.target.closest('[data-last]');
if (b) lastAction(b.dataset.last);
});
}
// input, not change: change only fires on blur, and a tab swap does not blur first.
$('input').addEventListener('input', saveDraft);
restoreDraft();
syncLast();
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat); const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
// ── Expand / collapse ──────────────────────────────────────────────── // ── Expand / collapse ────────────────────────────────────────────────
@@ -1613,6 +1711,15 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
</div> </div>
<div class="vv-ai-composer"> <div class="vv-ai-composer">
<!-- Acts on the last exchange only, and is hidden until there is one. Redoing the previous turn
is nearly the whole of what conversation control means in practice, and a row of buttons
under every message would spend a line each to serve the rare case. -->
<div class="vv-ai-last" id="<?= $p ?>-last" hidden>
<button type="button" class="vv-ai-lnk" data-last="retry">Retry</button>
<button type="button" class="vv-ai-lnk" data-last="edit">Edit question</button>
<button type="button" class="vv-ai-lnk" data-last="drop">Delete exchange</button>
</div>
<textarea class="vv-ai-input" id="<?= $p ?>-input" rows="<?= $compact ? 1 : 2 ?>" <textarea class="vv-ai-input" id="<?= $p ?>-input" rows="<?= $compact ? 1 : 2 ?>"
title="Ctrl+Enter to send" title="Ctrl+Enter to send"
placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea> placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea>