diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php
index 863917c..5aafbae 100644
--- a/Plugin/unraid/pages/ai.php
+++ b/Plugin/unraid/pages/ai.php
@@ -159,10 +159,41 @@ if (is_dir('/var/log/varaverk')) {
white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; }
.vv-ai-bug-q { font-size:10px; color:#4a4a4a; margin-top:5px; font-style:italic; }
-/* ── Repair findings ────────────────────────────────────────────────────── */
-/* Deliberately not styled like the bug reports above. A bug report is a note to send someone
+/* ── Findings & proposals ───────────────────────────────────────────────── */
+/* One card, two sources. A repair finding and a proposed memory are the same kind of object from
+ where the operator sits — the assistant asking for a decision rather than reporting a fact —
+ and two cards meant two places to check for the same errand. They keep separate stores, gates
+ and endpoints; only the presentation is shared.
+
+ Deliberately not styled like the bug reports above. A bug report is a note to send someone
else; a finding is a decision waiting on the operator, and the row carries buttons that write
conf. The left border colours by severity so a page of them is scannable without reading. */
+/* Which source a row came from, said on the row rather than inferred from its shape. Colour
+ carries it at a glance and the word carries it for anyone the colour does not reach. */
+.vv-ai-src-tag { font-size:9px; letter-spacing:.06em; text-transform:uppercase; border-radius:3px;
+ padding:1px 6px; border:1px solid; flex-shrink:0; }
+.vv-ai-src-tag.repair { color:#d8a15a; border-color:#4a3a1e; background:#1a1408; }
+.vv-ai-src-tag.memory { color:#7d9be8; border-color:#26324a; background:#0d1220; }
+
+/* Filter pills, loosely the shape the Monitor scripts card uses: a count you can click. All is an
+ explicit pill rather than "click the active one again to clear" — deselecting to get everything
+ back is a gesture you have to already know. A source with nothing in it still shows its pill, so
+ "no proposals" and "proposals are switched off" do not look like the same empty card. */
+.vv-ai-qf { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:9px; }
+.vv-ai-qf-b { font-size:10px; padding:2px 9px; border-radius:10px; cursor:pointer;
+ background:#111; border:1px solid #262626; color:#6a6a6a; font-family:inherit; }
+.vv-ai-qf-b:hover { color:#9a9a9a; border-color:#3a3a3a; }
+.vv-ai-qf-b.on { color:#c8c8c8; border-color:#4a4a4a; background:#1c1c1c; font-weight:700; }
+.vv-ai-qf-b.on.repair { color:#d8a15a; border-color:#6a4f26; background:#1a1408; }
+.vv-ai-qf-b.on.memory { color:#7d9be8; border-color:#33456a; background:#0d1220; }
+.vv-ai-qf-n { opacity:.7; }
+
+/* One gate line per source, because they are genuinely separate switches and joining them into a
+ sentence would invent a relationship that does not exist. Each hides when its source is filtered
+ out, so the line you are reading always describes the rows underneath it. */
+.vv-ai-qg { font-size:10px; color:#4a4a4a; margin-bottom:8px; line-height:1.7; }
+.vv-ai-qg > div { display:flex; gap:7px; align-items:baseline; }
+.vv-ai-qg b { font-weight:normal; color:#5a5a5a; min-width:52px; flex-shrink:0; }
.vv-ai-fnd { border-left:2px solid #3a3a3a; background:#0d0d0d; border-radius:0 3px 3px 0;
padding:8px 10px; margin-bottom:8px; }
.vv-ai-fnd.sev-error { border-left-color:#7a3040; background:#140c0e; }
@@ -241,31 +272,20 @@ if (is_dir('/var/log/varaverk')) {
Unlike the bugs card it does not hide itself when empty. "Nothing found, last swept 6
minutes ago" is the single most useful thing this card ever says, and a card that vanishes
on good news cannot say it — it just leaves a gap that reads as broken. -->
-
@@ -623,27 +643,44 @@ vv_ai_chat_markup('vv-ai', [
.then(() => loadBugs()).catch(() => {});
};
- // ── Repair findings ─────────────────────────────────────────────────────
- // The sweep files these every 15 minutes and, until this card, the only way to read one was
- // ai_repair_sweep.sh --status over SSH. Every button here is an answer the operator is giving
- // to something the system noticed — including Fix, which writes conf through the guarded path.
- let fndAll = false, fndRows = [];
+ // ── Findings & proposals ────────────────────────────────────────────────
+ // Two stores, two gates, two endpoints, one card. A repair finding and a proposed memory are
+ // the same object from where the operator sits — the assistant asking for a decision rather
+ // than reporting a fact — and keeping them in separate cards meant two places to check for the
+ // same errand, with the empty one still charging a header for saying nothing.
+ //
+ // Rows are normalised to a common shape on load and interleaved newest-first. Everything below
+ // draws from that shape, so a third source would be a loader and a row renderer, not a card.
+ // What is deliberately NOT shared: the stores, the gates, the endpoints and the confirmations.
+ // Merging the presentation of two decisions must not merge the decisions.
+ let qFilter = 'all'; // all | memory | repair
+ let qClosed = false; // include decided and closed rows
+ let qRows = []; // normalised, newest first
+ let qGates = {}; // one line per source, plus whether each reads as healthy
// Labels are the page's; meanings are not. The title on each button is the server's own
// description of that action, so the wording an operator hovers is the same wording the chat
// uses for the same row.
const FND_LABEL = { fix: 'Fix', ack: 'I know', dismiss: 'Never a problem', reopen: 'Reopen' };
- // Which actions take a second press, and what the button says while it is waiting for it.
- // Fix writes conf; dismiss is the one state the sweep will never reopen on its own however
- // many times the fault comes back. Ack and reopen are both reversible and go on one press.
- const FND_ARM = { fix: 'Confirm write', dismiss: 'Confirm — permanent' };
+ // Which presses take a second one, and what the button says while it waits. Source-aware on
+ // purpose: repair's Fix writes conf and its Dismiss is the one state the sweep will never
+ // reopen on its own, while memory's Keep is the only route by which model-written text reaches
+ // a future prompt. Memory's Dismiss writes nothing anywhere, so a confirmation there is
+ // ceremony — a single map keyed on the action name alone would have armed it by accident.
+ function qArmLabel(row, act) {
+ if (row.src === 'repair') {
+ return act === 'fix' ? 'Confirm write'
+ : act === 'dismiss' ? 'Confirm — permanent' : null;
+ }
+ return act === 'accept' ? 'Confirm keep' : null;
+ }
let fndArmTimer = null;
// Put every armed button back, optionally sparing the one being pressed right now.
function fndDisarm(keep) {
clearTimeout(fndArmTimer);
- $('vv-ai-fnd').querySelectorAll('button[data-armed="1"]').forEach(b => {
+ $('vv-ai-queue').querySelectorAll('button[data-armed="1"]').forEach(b => {
if (b === keep) return;
b.dataset.armed = '';
if (b.dataset.label) b.textContent = b.dataset.label;
@@ -656,102 +693,202 @@ vv_ai_chat_markup('vv-ai', [
});
}
- function loadFindings() {
- fetch(API + '?action=findings' + (fndAll ? '&all=1' : '')).then(r => r.json())
- .then(d => { if (d.ok) renderFindings(d); })
- .catch(() => {});
+ // Held so a filter click can redraw without refetching. The gate lines and the open counts live
+ // on the payloads rather than on the normalised rows, so the payloads are what has to be kept.
+ let qLastF = null, qLastM = null;
+
+ // Both in parallel, and a failure of either is survivable: the card renders what it got and the
+ // missing source reports that it could not be read, rather than the whole card going blank
+ // because one endpoint was slow.
+ function loadQueue() {
+ Promise.all([
+ fetch(API + '?action=findings' + (qClosed ? '&all=1' : '')).then(r => r.json()).catch(() => null),
+ fetch(API + '?action=mem_proposals').then(r => r.json()).catch(() => null),
+ ]).then(([f, m]) => { qLastF = f; qLastM = m; renderQueue(); });
}
- function renderFindings(d) {
- fndRows = d.findings || [];
- const rep = d.repair || {}, c = d.counts || {};
+ function renderQueue() {
+ const f = qLastF, m = qLastM;
+ const fOk = !!(f && f.ok), mOk = !!(m && m.ok);
+ const rep = (fOk && f.repair) || {};
+ const fc = (fOk && f.counts) || {};
+ const L = (mOk && m.learned) || {};
- // Gate state is stated on the card rather than left to be inferred from an empty list. An
- // empty list means "nothing found" when repair is on and "nothing is looking" when it is off,
- // and those are opposite pieces of news.
- const gate = [];
- gate.push(rep.enabled ? (rep.autofix ? 'repair on · autofix on'
- : 'repair on · detect only')
- : 'repair off — nothing is looking');
- if (rep.last) gate.push('swept ' + ago(rep.last));
- $('vv-ai-fnd-gate').innerHTML = rep.enabled
- ? esc(gate.join(' · '))
- : `${esc(gate.join(' · '))}`;
+ // Gate state is stated rather than left to be inferred from an empty list. An empty list means
+ // "nothing found" when the gate is on and "nothing is looking" when it is off, and those are
+ // opposite pieces of news. Kept as two lines because they are genuinely separate switches —
+ // joining them into one sentence would invent a relationship that does not exist.
+ qGates = {
+ repair: !fOk ? 'could not be read'
+ : rep.enabled ? ['repair on', rep.autofix ? 'autofix on' : 'detect only',
+ rep.last ? 'swept ' + ago(rep.last) : null].filter(Boolean).join(' · ')
+ : 'off — nothing is looking',
+ repairOk: fOk && !!rep.enabled,
+ memory: !mOk ? 'could not be read'
+ : m.enabled ? [m.auto ? 'learning on · auto-accept on' : 'learning on · proposals held for you',
+ L.max ? 'learned ' + (L.chars || 0) + '/' + L.max + ' chars' : null]
+ .filter(Boolean).join(' · ')
+ : 'off — nothing is proposing',
+ // Auto-accept reads as a warning on purpose: it is the one setting that lets model-written
+ // text reach a prompt with nobody having looked at it.
+ memoryOk: mOk && !!m.enabled && !m.auto,
+ };
- // needs_operator is counted separately from open because it is the one that is actually
- // waiting on a person — an open finding may still be repaired by the next sweep.
- const sum = $('vv-ai-fnd-sum');
- if (c.needs_operator) sum.innerHTML = `${c.needs_operator} need${c.needs_operator > 1 ? '' : 's'} you`;
- else if (c.open) sum.innerHTML = `${c.open} open`;
- else sum.innerHTML = `✓ nothing open`;
+ const fRows = (fOk && f.findings ? f.findings : []).map(x => ({
+ src: 'repair', id: x.id, ts: x.last || 0,
+ closed: ['open', 'needs_operator'].indexOf(x.state) === -1, raw: x,
+ }));
+ const mSrc = mOk ? (qClosed ? (m.recent || []) : (m.open || [])) : [];
+ const mRows = mSrc.map(x => ({
+ src: 'memory', id: x.id, ts: x.created || 0,
+ closed: x.state !== 'open', raw: x,
+ }));
- if (!fndRows.length) {
- $('vv-ai-fnd').innerHTML = fndAll
- ? '
nothing filed yet
'
- : '
nothing open — the last sweep found no new faults
';
+ // Interleaved by recency rather than grouped by source. Grouping would rebuild the two cards
+ // inside one border and put the newest thing anywhere but the top.
+ qRows = fRows.concat(mRows).sort((a, b) => (b.ts || 0) - (a.ts || 0));
+
+ // Counts describe what each pill would actually show, so a pill can be judged before it is
+ // clicked. A source with nothing in it keeps its pill — "no proposals" and "proposals are
+ // switched off" must not look like the same empty card.
+ const nRep = qRows.filter(r => r.src === 'repair').length;
+ const nMem = qRows.filter(r => r.src === 'memory').length;
+ $('vv-ai-q-filters').innerHTML =
+ qPill('all', 'All', qRows.length)
+ + qPill('memory', 'Memory', nMem)
+ + qPill('repair', 'Repair', nRep);
+
+ $('vv-ai-q-gates').innerHTML = ['repair', 'memory'].map(k => {
+ if (qFilter !== 'all' && qFilter !== k) return '';
+ const cls = qGates[k + 'Ok'] ? '' : ' class="vv-ai-warn"';
+ return `
`;
+ }).join('');
+
+ // needs_operator is counted apart from open because it is the one genuinely waiting on a
+ // person — an open finding may still be repaired by the next sweep. Every open proposal is
+ // waiting on a person by definition, so it joins that number rather than the other.
+ const needs = (fc.needs_operator || 0) + (mOk ? (m.open || []).length : 0);
+ const sum = $('vv-ai-q-sum');
+ if (needs) sum.innerHTML = `${needs} need${needs > 1 ? '' : 's'} you`;
+ else if (fc.open) sum.innerHTML = `${fc.open} open`;
+ else sum.innerHTML = `✓ nothing waiting`;
+
+ const shown = qFilter === 'all' ? qRows : qRows.filter(r => r.src === qFilter);
+ if (!shown.length) {
+ $('vv-ai-queue').innerHTML =
+ `
${esc(qEmptyText())}
`;
return;
}
-
- $('vv-ai-fnd').innerHTML = fndRows.map(f => {
- const closed = ['open', 'needs_operator'].indexOf(f.state) === -1;
- const meta = [f.id, f.seen > 1 ? 'seen ' + f.seen + '×' : null, ago(f.last),
- closed ? f.state : null].filter(Boolean).join(' · ');
-
- // Buttons come from what the server offered for this row and nothing else. cancel is the
- // exception and is dropped: "leave it alone for now" writes nothing by design, which in a
- // page is spelled "do not click anything".
- const acts = Object.keys(f.actions || {}).filter(a => a !== 'cancel').map(a =>
- ``
- ).join('');
-
- return `
`;
- }).join('');
+ $('vv-ai-queue').innerHTML = shown.map(r =>
+ r.src === 'repair' ? qFindingHtml(r) : qMemoryHtml(r)).join('');
}
- // Delegated, so the markup carries no inline handler and nothing has to be escaped into an
- // attribute that runs as code.
- $('vv-ai-fnd').addEventListener('click', e => {
- const btn = e.target.closest('button[data-act]');
- if (!btn) return;
- const id = btn.dataset.id, act = btn.dataset.act;
- const f = fndRows.find(x => x.id === id);
- if (!f) return;
+ // Says which of several different nothings this is. "Nothing here" over a filtered view that
+ // has hidden the only row is the kind of empty state that gets reported as a bug.
+ function qEmptyText() {
+ if (qFilter === 'memory') return qClosed ? 'nothing proposed yet'
+ : 'no proposals waiting on you';
+ if (qFilter === 'repair') return qClosed ? 'nothing filed yet'
+ : 'nothing open — the last sweep found no new faults';
+ return qClosed ? 'nothing filed or proposed yet' : 'nothing waiting on you';
+ }
- const msg = $('vv-ai-fnd').querySelector(`[data-msg="${id}"]`);
+ function qPill(key, label, n) {
+ const on = qFilter === key;
+ return ``;
+ }
- // Fix and dismiss are confirmed in the page, never with confirm(). Both browsers offer
- // "prevent this page from creating additional dialogs" inside the dialog itself, and once
- // that is ticked every later confirm() returns false without showing anything — so the
- // buttons silently decline and read as dead. Unraid swaps tabs by AJAX without reloading,
- // so the suppression outlives leaving the tab and only a full reload clears it. That is
- // exactly what happened here: not a broken button, a dialog answering no on its own.
- if (FND_ARM[act] && btn.dataset.armed !== '1') {
+ function qFindingHtml(row) {
+ const f = row.raw;
+ const meta = [f.id, f.seen > 1 ? 'seen ' + f.seen + '×' : null, ago(f.last),
+ row.closed ? f.state : null].filter(Boolean).join(' · ');
+
+ // Buttons come from what the server offered for this row and nothing else. cancel is the
+ // exception and is dropped: "leave it alone for now" writes nothing by design, which in a
+ // page is spelled "do not click anything".
+ const acts = Object.keys(f.actions || {}).filter(a => a !== 'cancel').map(a =>
+ ``
+ ).join('');
+
+ return `
`;
+ }
+
+ function qMemoryHtml(row) {
+ const r = row.raw;
+ const meta = [r.id, ago(r.created), r.profile || null,
+ row.closed ? r.state + (r.auto ? ' (auto)' : '') : null].filter(Boolean).join(' · ');
+ // The question is the provenance. A line read three weeks from now is judged by what was
+ // being asked when the model decided it was worth keeping.
+ const asked = r.asked ? `
asked: ${esc(r.asked)}
` : '';
+ // No severity class: a proposal is not a fault, and colouring it like one would make a card
+ // of ordinary suggestions read as a card of problems.
+ const acts = row.closed ? '' :
+ ``
+ + ``;
+
+ return `
+
+ memory
+ ${esc(meta)}
+
+
${esc(r.text)}
+ ${asked}
+
${acts}
+
`;
+ }
+
+ // One delegated listener for both sources. The markup carries no inline handler, so nothing has
+ // to be escaped into an attribute that would run as code.
+ $('vv-ai-queue').addEventListener('click', e => {
+ const fb = e.target.closest('button[data-act]');
+ if (fb) { qAct(fb, 'finding_action', fb.dataset.act); return; }
+ const mb = e.target.closest('button[data-mem-act]');
+ if (mb) { qAct(mb, 'mem_proposal_action', mb.dataset.memAct); return; }
+ });
+
+ // Both sources arm, act and fail the same way, because the shape of the interaction is the same:
+ // a press that might be irreversible, a press that confirms it, and a failure that must not be
+ // repainted away before it is read.
+ function qAct(btn, endpointAction, act) {
+ const id = btn.dataset.id;
+ const msg = btn.parentNode.querySelector('[data-msg]');
+ const row = qRows.find(x => x.id === id);
+ if (!row) return;
+
+ // Confirmed in the page, never with confirm(). Both browsers offer "prevent this page from
+ // creating additional dialogs" inside the dialog itself, and once that is ticked every later
+ // confirm() returns false without showing anything — so the buttons silently decline and read
+ // as dead. Unraid swaps tabs by AJAX without reloading, so the suppression outlives leaving
+ // the tab and only a full reload clears it.
+ const arm = qArmLabel(row, act);
+ if (arm && btn.dataset.armed !== '1') {
fndDisarm();
btn.dataset.armed = '1';
- btn.dataset.label = btn.textContent;
- btn.textContent = FND_ARM[act];
+ btn.dataset.label = btn.dataset.label || btn.textContent;
+ btn.textContent = arm;
btn.classList.add('armed');
- if (msg) msg.innerHTML = act === 'fix'
- ? `Writes the change above to ${esc(f.conf_file)}`
- + `${f.proven ? '' : ', and nothing has probed it'}. Click again to confirm.`
- : `Stays closed even when it is seen again. `
- + `Use “I know” to be told if it changes. Click again to confirm.`;
+ if (msg) msg.innerHTML = `${qArmWarning(row, act)}`;
// Arming expires on its own so a half-pressed button cannot sit there waiting to be
// completed by an unrelated click later.
fndArmTimer = setTimeout(fndDisarm, 8000);
@@ -763,11 +900,11 @@ vv_ai_chat_markup('vv-ai', [
btns.forEach(b => b.disabled = true);
if (msg) msg.textContent = 'working…';
- // Reload on success, never on failure. A refused conf write is the case that must not
- // disappear quietly: the guarded path rolled back, the finding is unchanged, and a reload
- // would repaint the row identically half a second later and take the error with it. So the
- // failed row keeps its message, and its buttons go back to reading Fix rather than sitting
- // at "Confirm write" — a second press should have to arm again, not fire again.
+ // Reload on success, never on failure. A refused conf write and a refused keep are both the
+ // case that must not disappear quietly: nothing changed, and a reload would repaint the row
+ // identically half a second later and take the reason with it. So the failed row keeps its
+ // message, and its button goes back to reading Fix or Keep rather than sitting at "Confirm" —
+ // a second press should have to arm again, not fire again.
const failed = text => {
btn.dataset.armed = '';
if (btn.dataset.label) btn.textContent = btn.dataset.label;
@@ -778,123 +915,40 @@ vv_ai_chat_markup('vv-ai', [
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
- body: new URLSearchParams({ action: 'finding_action', id, act }) })
+ body: new URLSearchParams({ action: endpointAction, id, act }) })
.then(r => r.json())
- .then(d => { if (d.ok) loadFindings(); else failed(d.error || 'failed'); })
+ .then(d => { if (d.ok) loadQueue(); else failed(d.error || 'failed'); })
.catch(e => failed(String(e)));
- });
-
- $('vv-ai-fnd-all').addEventListener('click', () => {
- fndAll = !fndAll;
- $('vv-ai-fnd-all').textContent = fndAll ? 'Open only' : 'Show closed';
- loadFindings();
- });
-
- // ── Proposed memory ───────────────────────────────────────────────────────
- // Accepting is the only route by which text the model wrote reaches a future prompt, so it is
- // the one control here that has to be deliberate. Same two-press arming the findings card uses
- // for a conf write, for the same reason.
- let memAll = false, memRows = [];
-
- function loadMemProposals() {
- fetch(API + '?action=mem_proposals').then(r => r.json())
- .then(d => { if (d.ok) renderMemProposals(d); })
- .catch(() => {});
}
- function renderMemProposals(d) {
- memRows = memAll ? (d.recent || []) : (d.open || []);
- const L = d.learned || {};
-
- // The gate is the news when the list is empty. "Nothing proposed" and "nothing is proposing"
- // look identical otherwise, and they are opposite states.
- const gate = [];
- gate.push(d.enabled ? (d.auto ? 'learning on · auto-accept on'
- : 'learning on · proposals held for you')
- : 'learning off — nothing is proposing');
- if (L.max) gate.push('learned ' + (L.chars || 0) + '/' + L.max + ' chars');
- $('vv-ai-mem-gate').innerHTML = (d.enabled && !d.auto)
- ? esc(gate.join(' · '))
- : `${esc(gate.join(' · '))}`;
-
- const openN = (d.open || []).length;
- $('vv-ai-mem-sum').innerHTML = openN
- ? `${openN} to review`
- : `✓ none waiting`;
-
- if (!memRows.length) {
- $('vv-ai-mem-list').innerHTML = memAll
- ? '
nothing proposed yet
'
- : '
nothing waiting on you
';
- return;
+ // What the second press is actually going to do, in the row's own terms.
+ function qArmWarning(row, act) {
+ const f = row.raw;
+ if (act === 'fix') {
+ return `Writes the change above to ${esc(f.conf_file)}`
+ + `${f.proven ? '' : ', and nothing has probed it'}. Click again to confirm.`;
}
-
- $('vv-ai-mem-list').innerHTML = memRows.map(r => {
- const decided = r.state !== 'open';
- const meta = [r.id, ago(r.created), r.profile || null,
- decided ? r.state + (r.auto ? ' (auto)' : '') : null].filter(Boolean).join(' · ');
- // The question is the provenance. A line read three weeks from now is judged by what was
- // being asked when the model decided it was worth keeping.
- const asked = r.asked ? `
`;
- }).join('');
+ if (act === 'dismiss' && row.src === 'repair') {
+ return 'Stays closed even when it is seen again. '
+ + 'Use “I know” to be told if it changes. Click again to confirm.';
+ }
+ return 'This text joins every future prompt. Click again to confirm.';
}
- $('vv-ai-mem-list').addEventListener('click', e => {
- const btn = e.target.closest('[data-mem-act]');
- if (!btn) return;
-
- const act = btn.dataset.memAct, id = btn.dataset.memId;
- const msg = btn.parentNode.querySelector('[data-msg]');
-
- // Keep arms first. Dismiss does not: it writes nothing to any prompt and is undone by the
- // model proposing something similar again, so a confirm there would be ceremony.
- if (act === 'accept' && btn.dataset.armed !== '1') {
- btn.dataset.armed = '1';
- btn.dataset.label = btn.dataset.label || btn.textContent;
- btn.textContent = 'Confirm keep';
- btn.classList.add('armed');
- if (msg) msg.textContent = 'this text joins every future prompt';
- return;
- }
-
- const btns = Array.from(btn.parentNode.querySelectorAll('button'));
- btns.forEach(b => b.disabled = true);
- if (msg) msg.textContent = 'working…';
-
- // Reload on success only — same rule as findings. A refused keep (learned memory full) is
- // the case that must not vanish, and a reload would repaint the row and take the reason away.
- const failed = text => {
- btn.dataset.armed = '';
- if (btn.dataset.label) btn.textContent = btn.dataset.label;
- btn.classList.remove('armed');
- btns.forEach(b => b.disabled = false);
- if (msg) msg.innerHTML = `${esc(text)}`;
- };
-
- fetch(API, { method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
- body: new URLSearchParams({ action: 'mem_proposal_action', id, act }) })
- .then(r => r.json())
- .then(d => { if (d.ok) loadMemProposals(); else failed(d.error || 'failed'); })
- .catch(e => failed(String(e)));
+ $('vv-ai-q-filters').addEventListener('click', e => {
+ const b = e.target.closest('[data-qf]');
+ if (!b) return;
+ qFilter = b.dataset.qf;
+ // Redrawn from what is already in hand — a filter is a view of rows already fetched, and a
+ // control this cheap should not cost a request.
+ renderQueue();
});
- $('vv-ai-mem-all').addEventListener('click', () => {
- memAll = !memAll;
- $('vv-ai-mem-all').textContent = memAll ? 'Waiting only' : 'Show decided';
- loadMemProposals();
+ $('vv-ai-q-closed').addEventListener('click', () => {
+ qClosed = !qClosed;
+ $('vv-ai-q-closed').textContent = qClosed ? 'Waiting only' : 'Show decided';
+ // This one does refetch: closed findings are not in hand until the endpoint is asked for them.
+ loadQueue();
});
// ── Token accounting ────────────────────────────────────────────────────
@@ -1085,14 +1139,13 @@ vv_ai_chat_markup('vv-ai', [
// The sweep runs every 15 minutes, so anything faster than this is polling for news that
// cannot have arrived. Five minutes keeps "swept N ago" honest without the card costing
// anything to leave open.
- const fndTimer = setInterval(loadFindings, 300000);
+ const fndTimer = setInterval(loadQueue, 300000);
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(fndTimer); };
loadBanner();
loadTokens();
loadBugs();
- loadFindings();
- loadMemProposals();
+ loadQueue();
chatList.reload();
})();