diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 4a2b15a..a451ae2 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -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) : '') + ``)); }); scroll(); + syncLast(); } // ── Offers ─────────────────────────────────────────────────────────── @@ -983,6 +1065,9 @@ vv_ai_profiles_script(); function reset() { chatEl().innerHTML = `