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 ─────────────────────────────────────────────────────────────
|
/* ── 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user