Make the Scheduler panel an instance, not a second chat

It kept its own bar, send loop and poll, which is why a thread there died on reload while the
other two were saved and why its controls had drifted from the same controls everywhere else.
This commit is contained in:
Gmer4Lfe
2026-08-09 12:22:07 -04:00
parent d0ff0c7d5c
commit 8eeb4c3d6c
3 changed files with 203 additions and 352 deletions
+15 -37
View File
@@ -667,37 +667,19 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
.vv-sug-configured { color: #4caf50; font-size: 11px; } .vv-sug-configured { color: #4caf50; font-size: 11px; }
/* Info sections inside Scheduler Information panel */ /* Info sections inside Scheduler Information panel */
/* ── Assistant dock (scheduler right panel) ────────────────────────────────────────────────── */ /* ── Assistant dock (scheduler right panel) ────────────────────────────────────────────────── */
#vv-ai-dock { border: 1px solid #262626; border-radius: 6px; background: #0e0e0e; /* The frame only. Everything the dock used to style for itself — its bar, input, transcript,
margin-top: 6px; flex-shrink: 0; } question and answer rows, sources, separators, its own grow button — is the shared chat
#vv-ai-dock-bar { display: flex; align-items: center; gap: 7px; padding: 6px 8px; } component's now, drawn from the same rules as the AI tab and the Monitor card. What is left is
#vv-ai-dock-chip { white-space: nowrap; max-width: 240px; overflow: hidden; the box the panel puts it in. */
text-overflow: ellipsis; } #vv-ai-dock { border: 1px solid #262626; border-radius: 6px; background: #0e0e0e;
/* The button around it is .vv-ai-chip and carries the colour, border and padding. */ margin-top: 6px; flex-shrink: 0; padding: 6px 8px; }
/* The chip states profile and subject together here, and a long script name would otherwise push
the action group off the end of a narrow panel. */
#vv-ai-dock .vv-ai-chip { max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
/* Flashed when the subject moves under text already typed — see vvAiDockScope(). */
.vv-ai-chip-flash { animation: vvAiChipFlash .9s ease-out 2; } .vv-ai-chip-flash { animation: vvAiChipFlash .9s ease-out 2; }
@keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; } @keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; }
50% { background:#2a2312; color:#d8b25a; } } 50% { background:#2a2312; color:#d8b25a; } }
#vv-ai-dock-input { flex: 1; min-width: 0; background: #0a0a0a; border: 1px solid #262626;
border-radius: 4px; color: #c8c8c8; font-family: inherit; font-size: 12px;
padding: 5px 9px; }
#vv-ai-dock-input:focus { outline: none; border-color: #2d4a6a; }
#vv-ai-dock-body { border-bottom: 1px solid #1c1c1c; padding: 9px 10px; overflow-y: auto;
font-size: 12px; line-height: 1.55; }
/* Always present, unlike the collapse button next to it — the size of the conversation is worth
changing before there is one, when you already know the answer will be long. */
.vv-ai-dock-grow { flex-shrink: 0; min-width: 26px; }
.vv-ai-dock-grow-on { color: #8fb0c4; border-color: #2d4a6a; background: #12191d; }
.vv-ai-dock-q { color: #8fb0c4; margin: 0 0 5px; }
.vv-ai-dock-q::before { content: ' '; color: #3a5a6a; }
.vv-ai-dock-a { color: #b8b8b8; margin: 0 0 11px; white-space: normal; }
.vv-ai-dock-a code { background: #1a1a1a; border: 1px solid #333; border-radius: 2px;
padding: 0 4px; font-size: 11px; color: #9ab; }
.vv-ai-dock-a strong { color: #ddd; }
.vv-ai-dock-wait { color: #5a5a5a; font-style: italic; }
.vv-ai-dock-err { color: #e57; }
.vv-ai-dock-src { margin-top: 6px; padding-top: 5px; border-top: 1px solid #1c1c1c;
font-size: 10px; color: #4a4a4a; font-family: monospace; }
.vv-ai-dock-sep { margin: 4px 0 9px; font-size: 10px; color: #4a4a4a; text-align: center;
border-top: 1px solid #1c1c1c; padding-top: 6px; }
.vv-info-block .vv-sug-title { color: #9ab; } .vv-info-block .vv-sug-title { color: #9ab; }
/* Depth switch on the help header. Outlined rather than filled — it changes what you are /* Depth switch on the help header. Outlined rather than filled — it changes what you are
@@ -1208,15 +1190,11 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; } .vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
.vv-rsync-save-btn:hover { background: #22382a; } .vv-rsync-save-btn:hover { background: #22382a; }
/* ── AI profile picker ──────────────────────────────────────────────────────── /* ── Profile picker ───────────────────────────────────────────────────────────
Global rather than emitted with the chat widget, because both surfaces that show it are Global rather than emitted with the chat component. It was shared out here when the Scheduler
styled differently in every other respect: the Monitor card renders a full transcript from drew its own bar and loaded none of the component's stylesheet; that is no longer true — all
include/ai_chat.php, the Scheduler dock renders its own one-line bar and loads none of that three surfaces render the same markup now — but the picker is also used by the menus around it
stylesheet. The picker is the one control they share, so it lives where .vv-ai-dock-* already and reads more naturally beside them than inside the component's own block. */
does instead of being emitted twice. */
/* ── Profile picker ─────────────────────────────────────────────────────── */
/* The chip borrows the Scheduler dock's language deliberately — same muted blue, same weight,
same 10px — so the two surfaces read as one control that happens to appear in two places. */
.vv-ai-picker { position:relative; flex-shrink:0; } .vv-ai-picker { position:relative; flex-shrink:0; }
.vv-ai-chip { display:inline-flex; align-items:center; gap:5px; font-size:10px; color:#5a7a8a; .vv-ai-chip { display:inline-flex; align-items:center; gap:5px; font-size:10px; color:#5a7a8a;
background:#12191d; border:1px solid #24343d; border-radius:3px; padding:3px 8px; background:#12191d; border:1px solid #24343d; border-radius:3px; padding:3px 8px;
+38 -19
View File
@@ -1,9 +1,9 @@
<?php <?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE // PURPOSE
// The conversation surface itself — profile bar, transcript, composer, source viewer and the // The conversation surface itself — transcript, composer, profile and history pickers, source
// stored-chat list — rendered wherever a full chat belongs. Currently the AI tab and the // viewer and the stored-chat list — rendered wherever a chat belongs. All three surfaces use
// Monitor tab's AI row. // it: the AI tab, the Monitor tab's AI row, and the Scheduler's right-hand panel.
// //
// WHY THIS IS AN INCLUDE // WHY THIS IS AN INCLUDE
// There were two places that needed a real transcript and one implementation, which meant the // There were two places that needed a real transcript and one implementation, which meant the
@@ -12,10 +12,18 @@
// teardown of a stale instance across an Unraid tab swap — is subtle because each of them // teardown of a stale instance across an Unraid tab swap — is subtle because each of them
// already cost a diagnosis session once. A copy inherits none of the fixes that come after it. // already cost a diagnosis session once. A copy inherits none of the fixes that come after it.
// //
// The Scheduler tab's dock is deliberately NOT built on this. It is a one-line bar that follows // The Scheduler panel used to be exactly that copy: its own bar, send loop, poll and store
// the view you have open, with its own scope chip, fix flow and incident capture; it is a // handling. It proved the point — a thread there died on reload while the other two were saved,
// different component that happens to talk to the same endpoint. Folding it in here would mean // and its bar drifted into a different shape from the same control everywhere else. It is an
// one widget with two personalities and a mode flag deciding which. // instance now. What was genuinely particular to it turned into options rather than a second
// implementation: `scope` as a function for a subject that follows the open view, `beforeSend`
// for the one reply that is recorded rather than asked, `think` for reasoning on diagnosis only,
// `setHeights()` for a placement whose size is a share of a panel and not knowable until layout.
//
// WHAT A PLACEMENT MAY CHOOSE
// How much room it gets, and which extra controls belong to that surface. Not the shape: the
// control row, its order, and what the buttons do are fixed. A chat that rearranges itself per
// tab is three components wearing one name, which is the state this replaced.
// //
// INSTANCES // INSTANCES
// Every id is composed from a prefix, so two chats can coexist on one page. Each instance // Every id is composed from a prefix, so two chats can coexist on one page. Each instance
@@ -69,10 +77,10 @@ require_once __DIR__ . '/ai_profiles.php';
// The conversation store, on its own, with no styling and no chat widget attached. // The conversation store, on its own, with no styling and no chat widget attached.
// //
// Emitted separately because the surface that most needs it is the one that does not want the // Emitted separately because a page may want the store without the component — a list of saved
// rest: the Scheduler dock draws its own one-line bar, with its own scope chip and fix flow, and // conversations, or a save, with no transcript rendered. Every surface currently takes both, but
// pulling in the full transcript stylesheet to reach a save function would restyle a component // the two are not the same dependency and pulling in a stylesheet to reach a save function is how
// that was deliberately built to look different. // they would become one.
// //
// Where a conversation lives is not a presentation decision, so it gets one answer for every // Where a conversation lives is not a presentation decision, so it gets one answer for every
// surface. Anything added to the store — a new field, a new cap, a changed prune rule — arrives // surface. Anything added to the store — a new field, a new cap, a changed prune rule — arrives
@@ -132,8 +140,8 @@ function vv_ai_chat_assets(): void {
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; } .vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; } .vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
/* Profile picker styling lives in css/varaverk.css — the Scheduler dock uses the same control /* Profile picker styling lives in css/varaverk.css, alongside the menus it shares its shape
and loads none of this stylesheet. */ with, rather than here. */
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0; .vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
border-top:1px dashed #1e1e1e; padding-top:8px; } border-top:1px dashed #1e1e1e; padding-top:8px; }
@@ -222,9 +230,9 @@ function vv_ai_chat_assets(): void {
background:#161616; border-color:#333; } background:#161616; border-color:#333; }
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; } .vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
/* Styled after the Scheduler dock: 12px, tighter leading, questions marked with a chevron rather /* 12px, tighter leading, questions marked with a chevron rather than a role label. The role
than a role label. The role labels cost a line each and say the same two things forever — on a labels cost a line each and say the same two things forever — in a space this size that is a
card this size that is a third of the visible transcript spent on "You" and "Varaverk". */ third of the visible transcript spent on "You" and "Varaverk". */
.vv-ai-c .vv-ai-role { display:none; } .vv-ai-c .vv-ai-role { display:none; }
.vv-ai-c .vv-ai-body { font-size:12px; line-height:1.55; } .vv-ai-c .vv-ai-body { font-size:12px; line-height:1.55; }
.vv-ai-c .vv-ai-msg.user .vv-ai-body { color:#8fb0c4; } .vv-ai-c .vv-ai-msg.user .vv-ai-body { color:#8fb0c4; }
@@ -300,7 +308,7 @@ function vv_ai_chat_assets(): void {
<?php <?php
// The profile registry, served rather than restated. This used to be a literal table in the // The profile registry, served rather than restated. This used to be a literal table in the
// script below that had already drifted from the PHP — it knew three profiles where the server // script below that had already drifted from the PHP — it knew three profiles where the server
// knew four, which is why troubleshoot could not be offered here and the Scheduler dock had to // knew four, which is why troubleshoot could not be offered here and the Scheduler had to
// hand-roll its own labels. // hand-roll its own labels.
vv_ai_profiles_script(); vv_ai_profiles_script();
?> ?>
@@ -493,6 +501,12 @@ vv_ai_profiles_script();
const q = $('input').value.trim(); const q = $('input').value.trim();
if (!q) return; if (!q) return;
// A page may claim what was typed instead of asking it. The Scheduler does this once, after
// an offer is accepted, when the next thing typed is the fix being recorded rather than a
// question. Checked here rather than in a wrapper the page calls, because Ask and Ctrl+Enter
// both land here directly and a wrapper would only catch the ones routed through it.
if (o.beforeSend && o.beforeSend(q)) { $('input').value = ''; return; }
busy = true; busy = true;
$('send').disabled = true; $('send').disabled = true;
addUser(q); addUser(q);
@@ -516,7 +530,12 @@ vv_ai_profiles_script();
question: q, question: q,
history: JSON.stringify(sendable()), history: JSON.stringify(sendable()),
kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '', kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '',
think: (thinkEl ? thinkEl.checked : true) ? '1' : '0', // A checkbox where the page offers one, otherwise whatever the page decides from the
// profile, otherwise on. The Scheduler reasons only when diagnosing: working out what a
// log means is worth waiting ~15s for, and a lookup like "what does this setting do" is
// not — an inline answer that stalls reads as broken.
think: (typeof o.think === 'function' ? o.think(profile)
: thinkEl ? thinkEl.checked : true) ? '1' : '0',
}); });
res = fetch(API, { method: 'POST', headers: POST_HEAD, body }); res = fetch(API, { method: 'POST', headers: POST_HEAD, body });
} catch (e) { } catch (e) {
@@ -966,7 +985,7 @@ vv_ai_profiles_script();
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>'; box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
return; return;
} }
// The scope is shown, not just stored. A Scheduler dock thread is about one script or log, // The scope is shown, not just stored. A Scheduler thread is about one script or log,
// and a list of titles alone makes "why does this one talk about a log I never opened" // and a list of titles alone makes "why does this one talk about a log I never opened"
// an unanswerable question. // an unanswerable question.
// Tagged only when the tag says something. A scope always does; a profile does unless it // Tagged only when the tag says something. A scope always does; a profile does unless it
+150 -296
View File
@@ -65,8 +65,8 @@
require_once dirname(__DIR__) . '/include/scheduler.php'; require_once dirname(__DIR__) . '/include/scheduler.php';
require_once dirname(__DIR__) . '/include/docs.php'; require_once dirname(__DIR__) . '/include/docs.php';
require_once dirname(__DIR__) . '/include/ai_profiles.php'; require_once dirname(__DIR__) . '/include/ai_profiles.php';
// For vv_ai_chat_store_script() only — this page renders no chat widget. The store is shared; // The chat component itself now, not only the store: the assistant panel in the right-hand pane
// the presentation deliberately is not. // is an instance of it rather than a second implementation.
require_once dirname(__DIR__) . '/include/ai_chat.php'; require_once dirname(__DIR__) . '/include/ai_chat.php';
// Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived // Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived
@@ -1060,49 +1060,36 @@ Still the same two servers, two households, the same media stack running itself.
them: the Scheduler Info card is only on screen in the suggestions view, so a chat box them: the Scheduler Info card is only on screen in the suggestions view, so a chat box
living there would vanish exactly when the context — a conf, a log, a script — is most living there would vanish exactly when the context — a conf, a log, a script — is most
worth asking about. worth asking about.
At rest it is one input row. Answers expand it upward and the views above shrink to It takes a share of the panel and the views above shrink to suit, which is why
suit, which is why vvFitRight() subtracts its height. It pushes rather than overlays vvFitRight() subtracts its height. It pushes rather than overlays, so the thing being
so the thing you are asking about stays on screen. Collapsing keeps the conversation. --> asked about stays on screen; ⤢ trades more of the panel for the conversation and back. -->
<?php <?php
// The registry and the conversation store — not the chat widget. This dock draws its own // The registry, the store, and now the component itself. vv_ai_chat_assets() is what
// bar and must keep looking like itself; what it must not have is its own answer to where // defines VvAiChat and carries the transcript and composer styling — without it the markup
// a conversation lives. // below renders as inert boxes with no factory to bring them to life. It was absent while
// this panel drew its own bar.
vv_ai_profiles_script(); vv_ai_profiles_script();
vv_ai_chat_store_script(); vv_ai_chat_store_script();
vv_ai_chat_assets();
?> ?>
<!-- The same component the AI tab and the Monitor card render, at this panel's size. It
used to be a second implementation with its own bar, send loop and poll — which is how
a thread here died on reload while the other two were stored, and how this bar drifted
into a different shape from the same control everywhere else.
The wrapper keeps the #vv-ai-dock id: vvAiDockOn() reads its presence to decide whether
to draw the "why?" buttons on the activity rows, and vvFitRight() measures it.
Heights are set at runtime, not here — see vvFitRight(). This panel's share of the page
is not a number that exists until layout has run. -->
<div id="vv-ai-dock"> <div id="vv-ai-dock">
<div id="vv-ai-dock-body" style="display:none"></div> <?php vv_ai_chat_markup('vv-sched-ai', [
<div id="vv-ai-dock-bar"> 'profile' => 'varaverk',
<!-- The chip is now the trigger for the profile picker as well as the scope readout. 'compact' => true,
#vv-ai-dock-chip stays the label span it always was, so every existing write to 'scopeLabel' => 'Scheduler',
its textContent still lands; the button around it carries the decoration. --> 'empty' => 'Ask about what is on screen.',
<div class="vv-ai-picker" id="vv-ai-dock-picker"> 'placeholder' => 'Ask about what is on screen…',
<button class="vv-ai-chip" id="vv-ai-dock-chipbtn" type="button" ]); ?>
aria-haspopup="listbox" aria-expanded="false"
title="What the assistant will answer about — follows the view you have open. Click to change profile.">
<span id="vv-ai-dock-chip">Assistant · Scheduler</span><span class="vv-ai-chip-c">▾</span>
</button>
<div class="vv-ai-menu" id="vv-ai-dock-menu" role="listbox">
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
<button class="vv-ai-opt" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>"
type="button" role="option">
<span class="vv-ai-opt-l"><?= htmlspecialchars($def['label']) ?></span>
<span class="vv-ai-opt-h"><?= htmlspecialchars($def['hint']) ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<input type="text" id="vv-ai-dock-input" autocomplete="off"
placeholder="Ask about what is on screen…"
onkeydown="if(event.key==='Enter'){event.preventDefault();vvAiDockSend();}">
<button class="vv-btn-sm" id="vv-ai-dock-send" onclick="vvAiDockSend()">Ask</button>
<button class="vv-btn-sm" id="vv-ai-dock-fix" onclick="vvAiFixStart()"
style="display:none" title="Record what actually fixed this, against this script">Save fix</button>
<button class="vv-btn-sm" id="vv-ai-dock-hide" onclick="vvAiDockCollapse()"
style="display:none" title="Collapse — the conversation is kept">▾</button>
<button class="vv-btn-sm vv-ai-dock-grow" id="vv-ai-dock-grow" onclick="vvAiDockGrow()"
title="Give the conversation more of the panel">⤢</button>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="vv-sched-footer vv-sched-info vv-snap-footer" id="vv-sched-info-footer"> <div class="vv-sched-footer vv-sched-info vv-snap-footer" id="vv-sched-info-footer">
@@ -1205,11 +1192,6 @@ function vvPost(url, data) {
}).then(r => r.json()); }).then(r => r.json());
} }
// Whether the assistant dock is holding the larger share of the panel. Persisted: someone working
// through a long answer wants it to still be big after a reload, not to re-expand every visit.
// Declared here rather than with the other dock state because vvFitRight() below reads it.
let vvAiDockBig = localStorage.getItem('vv-ai-dock-big') === '1';
function vvFitRight() { function vvFitRight() {
const right = document.getElementById('vv-sched-right'); const right = document.getElementById('vv-sched-right');
if (!right.classList.contains('vv-panel-visible')) return; if (!right.classList.contains('vv-panel-visible')) return;
@@ -1245,10 +1227,14 @@ function vvFitRight() {
// Expanded takes most of the panel rather than all of it. The view above is what the question // Expanded takes most of the panel rather than all of it. The view above is what the question
// is usually about — a log, a conf, a script — and a chat that covers it entirely turns every // is usually about — a log, a conf, a script — and a chat that covers it entirely turns every
// follow-up into a round trip through the collapse button. // follow-up into a round trip through the collapse button.
const dockBody = document.getElementById('vv-ai-dock-body'); // Both heights handed to the chat rather than one cap applied to it, because the component owns
const dockCap = vvAiDockBig ? 0.82 : 0.4; // which of the two it is currently at. Recomputed on every fit so a resize while expanded stays
if (dockBody) dockBody.style.maxHeight = Math.max(90, Math.round(availH * dockCap)) + 'px'; // expanded at the new panel size instead of snapping back to the resting share.
// Measured after the cap is applied, so this reads the clamped height, not the natural one. if (vvSchedChat) {
vvSchedChat.setHeights(Math.max(90, Math.round(availH * 0.40)) + 'px',
Math.max(90, Math.round(availH * 0.82)) + 'px');
}
// Measured after the heights are applied, so this reads the clamped height, not the natural one.
const dock = document.getElementById('vv-ai-dock'); const dock = document.getElementById('vv-ai-dock');
const dockH = dock ? dock.offsetHeight : 0; const dockH = dock ? dock.offsetHeight : 0;
const contentH = Math.max(80, availH - dockH); const contentH = Math.max(80, availH - dockH);
@@ -2377,48 +2363,42 @@ function vvClickCog(el) {
let vvAiProfile = 'varaverk'; let vvAiProfile = 'varaverk';
let vvAiScope = 'Scheduler'; let vvAiScope = 'Scheduler';
let vvAiScopeLabel = 'Scheduler'; // what the chip reads; the target is the id behind it let vvAiScopeLabel = 'Scheduler'; // what the chip reads; the target is the id behind it
let vvAiHist = []; // what the model is told; reset when the scope changes let vvSchedChat = null; // the shared chat instance, once the row has rendered
let vvAiChatId = ""; // the stored conversation this thread is appending to
let vvAiBusy = false;
let vvAiPoll = null;
function vvAiDockOn() { return !!document.getElementById('vv-ai-dock'); } // Presence of the instance, not of an element. Everything that guards on this — the "why?" buttons
// on the activity rows, the panel fit — needs a working chat, and on a host without the AI row
// there is neither.
function vvAiDockOn() { return !!vvSchedChat; }
// ── Profile picker ─────────────────────────────────────────────────────────── // The profile picker, the send loop, the poll, the transcript and the store are all the component's
// The chip states the contract and the thing being asked about; clicking it changes the first // now. What stays here is the part that is genuinely this tab's: which script, log or conf the
// without moving the second. Same control as the Monitor card's, same stylesheet — the dock keeps // question is about, and how that follows the view the operator has open.
// its own bar because that is a real difference, but which profile answers is not a per-surface if (document.getElementById('vv-sched-ai-chat')) {
// idea and should not have a per-surface control. vvSchedChat = VvAiChat({
// prefix: 'vv-sched-ai',
// Routes through vvAiDockScope() rather than assigning vvAiProfile, so a profile change gets the profile: vvAiProfile,
// history reset and the on-screen marker that a scope change already gets. Picking a new contract scopeLabel: vvAiScopeLabel,
// mid-thread and silently feeding it the previous one's turns is the bug that rule exists for. // Re-read at send time rather than captured, because the operator moves around this tab
(function vvAiPickerInit() { // between asking and sending.
const picker = document.getElementById('vv-ai-dock-picker'); scope: () => vvAiScope,
if (!picker) return; // Reasoning for diagnosis only — see the note in the component's ask payload.
const btn = document.getElementById('vv-ai-dock-chipbtn'); think: p => p === 'troubleshoot',
const menu = document.getElementById('vv-ai-dock-menu'); // The picker is the component's, so this is how the page learns the contract changed.
const close = () => { picker.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); }; onProfile: p => { vvAiProfile = p; },
// Claims what was typed rather than asking it, once, after a fix offer is accepted. Also the
btn.addEventListener('click', e => { // one place that reliably sees every question, so it is where the symptom is remembered.
e.stopPropagation(); beforeSend: q => {
const open = picker.classList.toggle('open'); if (vvAiFixPending) { vvAiFixSave(q); return true; }
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); vvAiLastQ = q;
menu.querySelectorAll('.vv-ai-opt').forEach(o => return false;
o.classList.toggle('active', o.dataset.prof === vvAiProfile)); },
onTurn: () => { vvAiFixArm(); requestAnimationFrame(vvFitRight); },
onOffer: (kind, yes) => { if (kind === 'fix') vvAiFixAnswer(yes); },
}); });
menu.addEventListener('click', e => { }
const o = e.target.closest('.vv-ai-opt');
if (!o) return;
close();
vvAiDockScope(o.dataset.prof, vvAiScopeLabel, vvAiScope);
});
document.addEventListener('click', e => { if (!picker.contains(e.target)) close(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
})();
// Called by every view switch. Profile and scope are derived from what is open and shown on the // Called by every view switch. Profile and scope are derived from what is open and shown on the
// chip — never chosen, never hidden. If the user can see what it thinks it is looking at, a // chip — never chosen, never hidden. If the operator can see what it thinks it is looking at, a
// wrong inference costs a glance instead of a confidently wrong answer. // wrong inference costs a glance instead of a confidently wrong answer.
// label is what the chip shows; target is what the worker resolves. They differ for logs, where // label is what the chip shows; target is what the worker resolves. They differ for logs, where
// the chip wants "daily_sync_maintenance log" and the worker needs the script id it can turn // the chip wants "daily_sync_maintenance log" and the worker needs the script id it can turn
@@ -2428,179 +2408,107 @@ function vvAiDockScope(profile, label, target) {
target = target || label; target = target || label;
if (profile === vvAiProfile && target === vvAiScope) return; if (profile === vvAiProfile && target === vvAiScope) return;
const had = vvAiHist.length > 0; vvAiProfile = profile;
vvAiProfile = profile; vvAiScope = target;
vvAiScope = target; vvAiScopeLabel = label;
vvAiScopeLabel = label; // kept so the picker can change profile without moving the scope // One call for both, so changing profile and subject together leaves one line in the transcript
// Short label from the registry, not a literal map. The map that used to be here was the // rather than two saying nearly the same thing. The component keeps the transcript and moves
// fifth place a profile got defined, and it silently fell back to "Assistant" for any id it // only the floor the model is told about.
// had not been told about — so a new profile would have shown up in the chip as the strict one. vvSchedChat.retarget(profile, label, 'now looking at ' + label);
const _p = (window.VvAiProfiles || {})[profile]; // A fix offer belongs to the thing it was diagnosed against; moving the subject abandons it.
document.getElementById('vv-ai-dock-chip').textContent = vvAiFixWatch = null;
(_p ? _p.short : profile) + ' · ' + label;
// Transcript stays, history sent to the model resets — the same rule the AI tab's profile
// buttons already use. A troubleshooting thread carrying log excerpts must not bleed into a
// question about a conf key, but hiding that the earlier exchange happened is worse.
if (had) {
vvAiHist = [];
// A new stored conversation too, not a continuation of the last one. The scope is part of
// the record, so appending turns about a different thing to the same row would produce a
// conversation whose stored scope describes only its first half.
vvAiChatId = '';
vvAiDockAppend('<div class="vv-ai-dock-sep">now looking at ' + vvEscHtml(label) + '</div>');
}
// The scope moved under text already typed. Not blocked — just never silent. // The scope moved under text already typed. Not blocked — just never silent.
const input = document.getElementById('vv-ai-dock-input'); const input = document.getElementById('vv-sched-ai-input');
if (input.value.trim() !== '') { if (input && input.value.trim() !== '') {
// The button, not the label span inside it. The flash animates a background, and the // The button, not the label span inside it. The flash animates a background, and the
// background moved to the button when the chip became the picker's trigger. // background lives on the button that triggers the picker.
const chip = document.getElementById('vv-ai-dock-chipbtn'); const chip = document.getElementById('vv-sched-ai-chip');
chip.classList.remove('vv-ai-chip-flash'); if (chip) {
void chip.offsetWidth; chip.classList.remove('vv-ai-chip-flash');
chip.classList.add('vv-ai-chip-flash'); void chip.offsetWidth;
chip.classList.add('vv-ai-chip-flash');
}
} }
} }
// Anything that changes what is in the dock has to re-run the fit, or the dock keeps the height
// the new content wants and pushes the panel past the bottom of the page.
function vvAiDockAppend(html) {
const body = document.getElementById('vv-ai-dock-body');
body.insertAdjacentHTML('beforeend', html);
body.scrollTop = body.scrollHeight;
requestAnimationFrame(vvFitRight);
}
function vvAiDockExpand() {
document.getElementById('vv-ai-dock-body').style.display = '';
document.getElementById('vv-ai-dock-hide').style.display = '';
vvAiDockApplyGrow();
requestAnimationFrame(vvFitRight);
}
// One control, two jobs, decided by what is on screen: grow the conversation, or — once it is
// already holding the panel — close it. A dedicated shrink-back would be a third state nobody
// asked for; at full size the only thing left to want is the view above.
//
// Closing from expanded also gives the size back. Without that, 82% is a one-way door: the next
// question reopens at 82%, the button is a collapse again, and nothing ever restores the 40%.
function vvAiDockGrow() {
const open = vvAiDockBodyOpen();
vvAiDockBig = !(vvAiDockBig && open);
localStorage.setItem('vv-ai-dock-big', vvAiDockBig ? '1' : '0');
if (vvAiDockBig) vvAiDockExpand();
else vvAiDockCollapse();
}
function vvAiDockBodyOpen() {
const b = document.getElementById('vv-ai-dock-body');
return !!b && b.style.display !== 'none';
}
// Called by expand and collapse as well as on load, so the button always describes the action it
// will actually perform. A stored "expanded" preference with the conversation shut still reads as
// grow — collapsing something already closed is not an action worth offering.
function vvAiDockApplyGrow() {
const b = document.getElementById('vv-ai-dock-grow');
if (!b) return;
const collapses = vvAiDockBig && vvAiDockBodyOpen();
b.textContent = collapses ? '▾' : '⤢';
b.title = collapses ? 'Collapse — the conversation is kept'
: 'Give the conversation more of the panel';
b.classList.toggle('vv-ai-dock-grow-on', collapses);
// Two collapse buttons side by side is one too many. While this one collapses, the other hides.
const hide = document.getElementById('vv-ai-dock-hide');
if (hide && collapses) hide.style.display = 'none';
}
// Collapse is not clear. Re-expanding shows the same conversation, so you can shrink it to read
// the conf the answer was about and open it again without paying for the turn twice.
function vvAiDockCollapse() {
document.getElementById('vv-ai-dock-body').style.display = 'none';
document.getElementById('vv-ai-dock-hide').style.display = 'none';
// Closing hands the panel back, whichever button did it, so the next question opens at the
// ordinary size instead of inheriting a decision made about a different answer.
vvAiDockBig = false;
localStorage.setItem('vv-ai-dock-big', '0');
vvAiDockApplyGrow();
requestAnimationFrame(vvFitRight);
}
// ── Recording a fix ─────────────────────────────────────────────────────────── // ── Recording a fix ───────────────────────────────────────────────────────────
// The symptom is taken from the question that was being asked; the fix is typed by the operator. // The symptom is taken from the question that was being asked; the fix is typed by the operator.
// The model's diagnosis is deliberately not saved — it is a reading of evidence, and writing a // The model's diagnosis is deliberately not saved — it is a reading of evidence, and writing a
// hypothesis into institutional memory as settled fact is how a wrong answer outlives the // hypothesis into institutional memory as settled fact is how a wrong answer outlives the
// incident it came from. What gets remembered is what actually worked. // incident it came from. What gets remembered is what actually worked.
let vvAiFixMode = false; //
let vvAiLastQ = ''; // Offered, not buttoned. This used to be a "Save fix" button that appeared after any answer that
// was not an error, which meant it was on screen after nearly every question — so it read as
// furniture and was mostly ignored. The offer instead waits for the one fact that says the
// trouble is actually over: the script that was being diagnosed runs again and exits clean. That
// arrives once per episode, at the moment it is true, and it comes from the run record rather
// than from a guess about the conversation.
let vvAiFixWatch = null; // { scope, symptom, since } while a diagnosis is waiting on a good run
let vvAiFixAsk = null; // { scope, symptom } while the offer is open and awaiting an answer
function vvAiFixStart() { // Armed when a troubleshoot turn finishes. The question is the symptom; the scope is whatever was
const input = document.getElementById('vv-ai-dock-input'); // being diagnosed. `since` guards against a run that had already completed before the diagnosis
vvAiFixMode = true; // started being read as proof that the diagnosis worked.
input.placeholder = 'What actually fixed it? (saved against ' + vvAiScope + ')'; function vvAiFixArm() {
input.value = ''; if (vvAiProfile !== 'troubleshoot' || !vvAiScope) return;
input.focus(); vvAiFixWatch = {
document.getElementById('vv-ai-dock-send').textContent = 'Save'; scope: vvAiScope,
document.getElementById('vv-ai-dock-fix').style.display = 'none'; symptom: vvAiLastQ || 'diagnosed from the log',
since: Math.floor(Date.now() / 1000),
};
} }
function vvAiFixCancel() { // Called from the activity poll with each completed run. One offer per episode: the watch is
vvAiFixMode = false; // cleared as it fires.
const input = document.getElementById('vv-ai-dock-input'); function vvAiFixRunOk(id, startedAt) {
input.placeholder = 'Ask about what is on screen…'; const w = vvAiFixWatch;
document.getElementById('vv-ai-dock-send').textContent = 'Ask'; if (!w || !vvAiDockOn()) return;
if (vvAiLastQ) document.getElementById('vv-ai-dock-fix').style.display = ''; if (id !== w.scope || startedAt < w.since) return;
vvAiFixWatch = null;
vvAiFixAsk = { scope: w.scope, symptom: w.symptom };
// Raw, not escaped — offer() renders through the component's fmt(), which escapes first.
vvSchedChat.offer('fix', id + ' just ran clean. Want me to record what fixed it?');
} }
function vvAiFixAnswer(yes) {
const ask = vvAiFixAsk;
vvAiFixAsk = null;
if (!yes || !ask) return;
// The fix itself is still typed, because only the operator knows what they actually changed.
// The next thing sent is captured as the fix rather than asked as a question.
vvAiFixPending = ask;
const input = document.getElementById('vv-sched-ai-input');
if (input) {
input.placeholder = 'What actually fixed it? (saved against ' + ask.scope + ')';
input.focus();
}
}
let vvAiFixPending = null;
function vvAiFixSave(text) { function vvAiFixSave(text) {
const ask = vvAiFixPending;
vvAiFixPending = null;
const input = document.getElementById('vv-sched-ai-input');
if (input) input.placeholder = 'Ask about what is on screen…';
if (!ask) return;
fetch('/plugins/varaverk/api/ai.php', { fetch('/plugins/varaverk/api/ai.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'incident_add', scope: vvAiScope, body: new URLSearchParams({ action: 'incident_add', scope: ask.scope,
symptom: vvAiLastQ, fix: text }), symptom: ask.symptom, fix: text }),
}).then(r => r.json()).then(d => { }).then(r => r.json()).then(d => {
vvAiDockAppend(d.ok vvSchedChat.note(d.ok
? '<div class="vv-ai-dock-sep">saved against ' + vvEscHtml(vvAiScope) ? 'saved against ' + ask.scope + ' — it will be shown next time this is diagnosed'
+ ' — it will be shown next time this is diagnosed</div>' : 'could not save: ' + (d.error || 'unknown'));
: '<div class="vv-ai-dock-a vv-ai-dock-err">Could not save: ' }).catch(e => vvSchedChat.note('save failed: ' + e));
+ vvEscHtml(d.error || 'unknown') + '</div>');
}).catch(e => vvAiDockAppend('<div class="vv-ai-dock-a vv-ai-dock-err">Save failed: '
+ vvEscHtml(String(e)) + '</div>'));
vvAiFixCancel();
} }
function vvAiDockSend() { // The last question asked, kept so a fix filed later has a symptom to go with it. Written by the
if (vvAiBusy) return; // component's beforeSend hook, which is the one place every question passes through.
const input = document.getElementById('vv-ai-dock-input'); let vvAiLastQ = '';
const q = input.value.trim();
if (!q) return;
if (vvAiFixMode) { input.value = ''; vvAiFixSave(q); return; }
input.value = '';
vvAiBusy = true;
document.getElementById('vv-ai-dock-send').disabled = true;
vvAiDockExpand();
vvAiDockAppend('<div class="vv-ai-dock-q">' + vvEscHtml(q) + '</div>'
+ '<div class="vv-ai-dock-a vv-ai-dock-wait" id="vv-ai-dock-pending">thinking…</div>');
fetch('/plugins/varaverk/api/ai.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({
action: 'ask', question: q, profile: vvAiProfile, scope: vvAiScope,
// Thinking on for diagnosis only. Working out what a log means is reasoning, and it is
// the one thing here worth waiting ~15s for; a lookup like "what does this setting do"
// is not, and inline answers that stall feel broken.
think: vvAiProfile === 'troubleshoot' ? '1' : '0',
history: JSON.stringify(vvAiHist),
}),
}).then(r => r.json()).then(d => {
if (!d.ok || !d.token) return vvAiDockDone(d.error || 'Could not start.', true);
vvAiHist.push({ role: 'user', content: q });
vvAiLastQ = q;
vvAiDockPoll(d.token);
}).catch(e => vvAiDockDone('Request failed: ' + e, true));
}
// Asks about one run from the Recent Activity list, in a single click. // Asks about one run from the Recent Activity list, in a single click.
// //
@@ -2614,68 +2522,11 @@ function vvAiDockSend() {
// request, so what was asked on their behalf is never a mystery. // request, so what was asked on their behalf is never a mystery.
function vvAiAskRun(id, failed, ev) { function vvAiAskRun(id, failed, ev) {
if (ev) ev.stopPropagation(); if (ev) ev.stopPropagation();
if (!vvAiDockOn() || vvAiBusy) return; if (!vvAiDockOn() || vvSchedChat.busy()) return;
vvOpenRight(id); vvOpenRight(id);
const input = document.getElementById('vv-ai-dock-input'); const input = document.getElementById('vv-sched-ai-input');
input.value = failed ? 'Why did this run fail?' : 'How did this run go?'; input.value = failed ? 'Why did this run fail?' : 'How did this run go?';
vvAiDockSend(); vvSchedChat.send();
}
function vvAiDockPoll(token, started) {
started = started || Date.now();
clearTimeout(vvAiPoll);
if (Date.now() - started > 300000) return vvAiDockDone('Timed out waiting for a response.', true);
vvAiPoll = setTimeout(() => {
fetch('/plugins/varaverk/api/ai.php?action=poll&token=' + encodeURIComponent(token))
.then(r => r.json()).then(d => {
if (!d.ok) return vvAiDockDone(d.error || 'Poll failed', true);
const j = d.job || {};
if (j.status === 'done') {
vvAiHist.push({ role: 'assistant', content: j.answer || '' });
vvAiDockDone(j.answer || '(empty answer)', false, j.sources || []);
// Same store as the AI tab and the Monitor row, through the same writer. This dock
// kept its conversation in a JavaScript array and nothing else, so a thread died on
// reload — including the troubleshooting ones, which are the most expensive to have
// had and the most annoying to lose. The scope goes with it so reopening the thread
// restores what it was reasoned about rather than continuing it against whatever
// happens to be on screen.
if (window.VvAiChatSave) {
window.VvAiChatSave(vvAiHist, vvAiProfile, vvAiScope, vvAiChatId)
.then(id => { if (id) vvAiChatId = id; });
}
fetch('/plugins/varaverk/api/ai.php', { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
return;
}
if (j.status === 'error') return vvAiDockDone(j.error || 'Unknown error', true);
vvAiDockPoll(token, started);
}).catch(e => vvAiDockDone('Poll failed: ' + e, true));
}, 1000);
}
function vvAiDockDone(text, isErr, sources) {
const pending = document.getElementById('vv-ai-dock-pending');
let html = vvEscHtml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>');
if (sources && sources.length) {
html += '<div class="vv-ai-dock-src">' + sources.slice(0, 4).map(s =>
vvEscHtml([s.path, s.section, s.heading].filter(Boolean).join(' '))).join('<br>') + '</div>';
}
if (pending) {
pending.classList.remove('vv-ai-dock-wait');
if (isErr) pending.classList.add('vv-ai-dock-err');
pending.innerHTML = html;
pending.removeAttribute('id');
}
vvAiBusy = false;
document.getElementById('vv-ai-dock-send').disabled = false;
// Offered only once there is something to attach a fix to.
if (!isErr && vvAiLastQ) document.getElementById('vv-ai-dock-fix').style.display = '';
const body = document.getElementById('vv-ai-dock-body');
requestAnimationFrame(() => { vvFitRight(); body.scrollTop = body.scrollHeight; });
} }
// ── Advanced mode toggle ────────────────────────────────────────────────────── // ── Advanced mode toggle ──────────────────────────────────────────────────────
@@ -3315,6 +3166,10 @@ function vvLoadRecentActivity() {
const cls = r.status === 'ok' ? 'vv-stat-ok' : r.status === 'warn' ? 'vv-stat-warn' : 'vv-stat-error'; const cls = r.status === 'ok' ? 'vv-stat-ok' : r.status === 'warn' ? 'vv-stat-warn' : 'vv-stat-error';
const bad = r.status !== 'ok' && r.status !== 'skipped'; const bad = r.status !== 'ok' && r.status !== 'skipped';
if (bad) errors++; if (bad) errors++;
// The one fact that says a diagnosis worked: the script it was about ran again and exited
// clean. This poll is already watching for it, so the offer costs a comparison rather than
// a second source of truth.
if (r.status === 'ok') vvAiFixRunOk(r.id, r.start);
// The question is on the row because that is where the operator already is when they want // The question is on the row because that is where the operator already is when they want
// it. Reaching the same answer otherwise means noticing the red dot, clicking through to // it. Reaching the same answer otherwise means noticing the red dot, clicking through to
// the log, finding the dock, and typing out what the row already knows. // the log, finding the dock, and typing out what the row already knows.
@@ -5058,7 +4913,6 @@ document.querySelectorAll('.vv-sched-card').forEach(card => {
requestAnimationFrame(function() { requestAnimationFrame(function() {
vvRestoreInvert(); vvRestoreInvert();
vvAiDockApplyGrow();
vvRestoreAdvancedMode(); vvRestoreAdvancedMode();
vvRestoreSugStates(); vvRestoreSugStates();
vvApplyHighlighting(); vvApplyHighlighting();