Let the last exchange be retried, edited or dropped, and stop losing drafts to a tab swap
This commit is contained in:
@@ -162,6 +162,13 @@ function vv_ai_chat_assets(): void {
|
||||
/* ── Code cards ─────────────────────────────────────────────────────────────
|
||||
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. */
|
||||
/* 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 pre { margin:0; border:none; border-radius:0; }
|
||||
.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);
|
||||
}
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────
|
||||
function send() {
|
||||
// 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');
|
||||
addUser(q);
|
||||
$('input').value = '';
|
||||
clearDraft();
|
||||
syncLast();
|
||||
addPending();
|
||||
|
||||
// Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper
|
||||
@@ -819,6 +899,7 @@ vv_ai_profiles_script();
|
||||
curToken = null;
|
||||
setSendMode('ask');
|
||||
clearInterval(pendingTimer);
|
||||
syncLast();
|
||||
}
|
||||
|
||||
// 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>`));
|
||||
});
|
||||
scroll();
|
||||
syncLast();
|
||||
}
|
||||
|
||||
// ── Offers ───────────────────────────────────────────────────────────
|
||||
@@ -983,6 +1065,9 @@ vv_ai_profiles_script();
|
||||
|
||||
function reset() {
|
||||
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) {
|
||||
@@ -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
|
||||
// say, so a stale label can never send a question into a turn already running.
|
||||
$('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);
|
||||
|
||||
// ── Expand / collapse ────────────────────────────────────────────────
|
||||
@@ -1613,6 +1711,15 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
|
||||
</div>
|
||||
|
||||
<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 ?>"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user