Put every chat control on one row, the same row everywhere

The picker sat above the transcript and the buttons below it, so the same component read
differently on each surface; size is the only thing a placement should get to choose.
This commit is contained in:
Gmer4Lfe
2026-08-09 11:40:01 -04:00
parent 4f598f7422
commit c431370a68
3 changed files with 170 additions and 63 deletions
+12 -4
View File
@@ -1217,7 +1217,6 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
/* ── 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-profiles { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.vv-ai-picker { position:relative; flex-shrink:0; }
.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;
@@ -1227,9 +1226,16 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
.vv-ai-picker.open .vv-ai-chip { color:#8fb0c4; border-color:#2d4a6a; }
.vv-ai-picker.open .vv-ai-chip-c { transform:rotate(180deg); }
.vv-ai-menu { display:none; position:absolute; top:calc(100% + 4px); left:0; z-index:60;
/* Opens upward. Every picker in the plugin now sits on the composer's bottom row, so a menu
dropping downward would fall out of the component — off the bottom of the Monitor card and
over the help panel beneath it. Anchored to the trigger's top edge instead. */
.vv-ai-menu { display:none; position:absolute; bottom:calc(100% + 4px); left:0; z-index:60;
min-width:250px; background:#0e0e0e; border:1px solid #2a2a2a; border-radius:5px;
box-shadow:0 6px 18px rgba(0,0,0,.55); overflow:hidden; }
box-shadow:0 -6px 18px rgba(0,0,0,.55); overflow:hidden; }
/* Right-anchored variant for triggers near the right edge, where a left-anchored menu would run
off the card. Used by the saved-conversations picker, whose list is unbounded — capped so a
long history scrolls in place rather than running off the top of the viewport. */
.vv-ai-menu-r { left:auto; right:0; max-height:min(60vh,420px); overflow-y:auto; }
.vv-ai-picker.open .vv-ai-menu { display:block; }
.vv-ai-opt { display:block; width:100%; text-align:left; background:none; border:none;
border-bottom:1px solid #1a1a1a; padding:7px 10px; cursor:pointer;
@@ -1240,4 +1246,6 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
.vv-ai-opt.active .vv-ai-opt-l { color:#9bd; }
.vv-ai-opt-l { display:block; font-size:11px; color:#b8b8b8; }
.vv-ai-opt-h { display:block; font-size:10px; color:#4a4a4a; line-height:1.4; margin-top:2px; }
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; flex:1; min-width:150px; }
/* Loading, empty and error in the saved-conversations menu: one line in the menu's own voice,
styled so it does not read as a row you might try to click. */
.vv-ai-hist-msg { font-size:10px; color:#4a4a4a; font-style:italic; padding:9px 10px; }
+144 -52
View File
@@ -181,10 +181,16 @@ function vv_ai_chat_assets(): void {
/* ── Composer ───────────────────────────────────────────────────────────── */
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
border-radius:6px; padding:10px; background:#0e0e0e; }
/* Picker left, actions right, nothing in between. margin-left:auto on the group rather than on
whichever control happens to be first means the right end stays anchored however many extra
controls a page passes in. Wrapping is allowed because the Monitor card is narrow, and when it
wraps the two groups part cleanly instead of interleaving. */
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.vv-ai-actions { display:flex; gap:8px; align-items:center; margin-left:auto; }
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
padding:4px 7px; border-radius:3px; }
.vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; }
/* Square-ish, so the two icon buttons read as a pair distinct from the worded ones. */
.vv-ai-grow, .vv-ai-histbtn { min-width:30px; text-align:center; }
/* ── Compact form, for a chat living inside a Monitor card ──────────────── */
/* Not a different component — the same markup with the chrome pulled in. The card supplies the
@@ -208,7 +214,6 @@ function vv_ai_chat_assets(): void {
.vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px;
background:#161616; border-color:#333; }
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
.vv-ai-c .vv-ai-prof-hint { display:none; }
/* Styled after the Scheduler dock: 12px, tighter leading, questions marked with a chevron rather
than a role label. The role labels cost a line each and say the same two things forever — on a
@@ -226,7 +231,6 @@ function vv_ai_chat_assets(): void {
.vv-ai-c .vv-ai-src-h { display:none; }
.vv-ai-c .vv-ai-meta { font-size:9px; }
.vv-ai-c .vv-ai-think-t { font-size:9px; padding:1px 6px; }
.vv-ai-c .vv-ai-hint { display:none; }
.vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; }
.vv-ai-c .vv-ai-btn.ghost { border-color:#555; color:#ccc; }
.vv-ai-c .vv-ai-msg { margin-bottom:12px; }
@@ -631,8 +635,10 @@ vv_ai_profiles_script();
const menu = $('menu');
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p));
const hint = $('prof-hint');
if (hint) hint.textContent = PROFILES[p] ? PROFILES[p].hint : '';
// The profile's description is the chip's title now. It used to be a sentence sitting beside
// the chip, which cost a line on every surface and was already hidden on the compact ones.
const chip = $('chip');
if (chip) chip.title = PROFILES[p] ? PROFILES[p].hint : '';
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
}
@@ -652,28 +658,90 @@ vv_ai_profiles_script();
$('input').focus();
}
// Picker. Closing on any outside click is registered once per instance and removed by
// teardown — a listener on document that outlives its menu is how a torn-down tab keeps
// reacting to clicks on the one that replaced it.
const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null;
const closeMenu = () => { if (picker) picker.classList.remove('open');
const c = $('chip'); if (c) c.setAttribute('aria-expanded', 'false'); };
const onDocClick = e => { if (picker && !picker.contains(e.target)) closeMenu(); };
// Two pickers now — profile on the left of the control row, saved conversations on the right.
// Both close on an outside click or Escape through one pair of document listeners rather than
// a pair each, and both are removed by teardown. A listener on document that outlives its menu
// is how a torn-down tab keeps reacting to clicks on the one that replaced it, and the count
// of them to get right should not grow with the number of menus.
const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null;
const histWrap = $('hist');
const closeMenus = () => {
if (picker) { picker.classList.remove('open');
const c = $('chip'); if (c) c.setAttribute('aria-expanded', 'false'); }
if (histWrap) { histWrap.classList.remove('open');
const h = $('hist-b'); if (h) h.setAttribute('aria-expanded', 'false'); }
};
const onDocClick = e => {
if (picker && picker.contains(e.target)) return;
if (histWrap && histWrap.contains(e.target)) return;
closeMenus();
};
const onDocKey = e => { if (e.key === 'Escape') closeMenus(); };
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onDocKey);
if (picker) {
const chip = $('chip');
chip.addEventListener('click', e => {
e.stopPropagation();
const open = picker.classList.toggle('open');
const open = !picker.classList.contains('open');
closeMenus();
picker.classList.toggle('open', open);
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
});
$('menu').addEventListener('click', e => {
const b = e.target.closest('.vv-ai-opt');
if (!b) return;
closeMenu();
closeMenus();
setProfile(b.dataset.prof);
});
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
}
// Saved conversations, off the same store the Conversations card reads — so every placement
// can reach its history whether or not it has a card beside it. Read when opened rather than
// kept in step: a list refreshed on a timer for a menu nobody has opened is work spent on
// nothing, and the moment it is looked at is the moment it must be right.
if (histWrap) {
const histBtn = $('hist-b'), histMenu = $('hist-menu');
histBtn.addEventListener('click', e => {
e.stopPropagation();
const open = !histWrap.classList.contains('open');
closeMenus();
histWrap.classList.toggle('open', open);
histBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
if (!open) return;
histMenu.innerHTML = '<div class="vv-ai-hist-msg">loading…</div>';
fetch(API + '?action=chats').then(r => r.json()).then(d => {
const rows = (d.ok && d.chats) ? d.chats : [];
if (!rows.length) {
histMenu.innerHTML = '<div class="vv-ai-hist-msg">no saved conversations yet</div>';
return;
}
// Same tagging rule as the Conversations card: a scope always says something, a profile
// says something unless it is the ordinary one.
const PR = window.VvAiProfiles || {};
histMenu.innerHTML = rows.map(c => {
const prof = (c.profile && c.profile !== 'chat' && PR[c.profile]) ? PR[c.profile].short : '';
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
return `<button class="vv-ai-opt${c.id === chatId ? ' active' : ''}" type="button"`
+ ` role="option" data-chat="${esc(c.id)}">`
+ `<span class="vv-ai-opt-l">${esc(c.title)}</span>`
+ `<span class="vv-ai-opt-h">${esc([tag, ago(c.ts)].filter(Boolean).join(' · '))}</span>`
+ `</button>`;
}).join('');
}).catch(() => {
histMenu.innerHTML = '<div class="vv-ai-hist-msg">could not read the store</div>';
});
});
histMenu.addEventListener('click', e => {
const b = e.target.closest('[data-chat]');
if (!b) return;
closeMenus();
loadChat(b.dataset.chat);
});
}
// ── Wiring ───────────────────────────────────────────────────────────
@@ -759,6 +827,9 @@ vv_ai_profiles_script();
window.removeEventListener('error', onErr);
window.removeEventListener('unhandledrejection', onRej);
document.removeEventListener('click', onDocClick);
// The keydown pair was previously registered and never removed, so every tab swap left
// another Escape handler bound to a dead menu.
document.removeEventListener('keydown', onDocKey);
if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P];
},
};
@@ -836,21 +907,30 @@ vv_ai_profiles_script();
}
// One instance's markup. The prefix composes every id, so a page may render more than one.
// profile which profile button starts active
// compact card-sized chrome, for a chat living inside a Monitor card
// height transcript height; a fixed value in compact mode, since a card in a grid row
// cannot grow with its content without dragging the row's other cards with it.
// Setting it also adds the expand control — a fixed height is exactly the situation
// where you sometimes want more room, and there is nothing to expand without one.
//
// Every placement renders the same thing. What a page chooses is how much room it gets — width is
// the container's business, the two heights are this function's — and which extra controls belong
// to that surface. Everything else is fixed on purpose: the control row, its order, what the
// buttons do. A chat that rearranges itself per tab is three components wearing one name.
//
// profile which profile starts active
// compact card-sized chrome, for a chat living inside a card
// height resting transcript height. Defaults per mode rather than being left empty: the
// expand control is standard, and a control with no second height to move to is a
// button that does nothing on the one surface that forgot to pass a number.
// tall the expanded height. Defaults to 2.5x height, which is the point where a long
// answer stops needing a scroll for most questions without the card swallowing the
// page it sits on
// empty empty-state text
// controls extra markup dropped into the composer's control strip
// scope what this conversation is about — string, or a function re-read at send time for a
// surface whose subject follows the view the operator has open
// controls extra markup dropped in at the head of the action group
function vv_ai_chat_markup(string $prefix, array $o = []): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
$compact = !empty($o['compact']);
$height = $o['height'] ?? '';
// A card-sized chat and a full-page one want very different resting heights, and neither wants
// none: see `height` above.
$height = $o['height'] ?? ($compact ? '300px' : '46vh');
$empty = $o['empty'] ?? 'Ask Varaverk about itself.';
// 2.5x by default, computed here so the page can override with a value that suits its layout
// rather than the include guessing at one it cannot see.
@@ -870,42 +950,54 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
?>
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<!-- One chip, not a row of buttons. Four profiles as four always-visible buttons spend a whole
line restating three choices you are not making, and the row only grows as profiles are
added. The chip states what is answering; clicking it offers the rest. -->
<div class="vv-ai-profiles" id="<?= $p ?>-profiles">
<div class="vv-ai-picker">
<button class="vv-ai-chip" id="<?= $p ?>-chip" type="button" aria-haspopup="listbox"
aria-expanded="false"><span id="<?= $p ?>-chip-l"></span><span class="vv-ai-chip-c">▾</span></button>
<div class="vv-ai-menu" id="<?= $p ?>-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>
<span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span>
</div>
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
</div>
<div class="vv-ai-composer">
<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>
<!-- One row, and everything on it is clickable. The profile chip states what is answering and
holds the left end; every action holds the right. Nothing else lives here — the keyboard
hint moved to the textarea's title and the profile's description to the chip's, because a
row that mixes prose with controls makes you read it to find the button.
One chip, not a row of buttons: four profiles as four always-visible buttons spend a whole
line restating three choices you are not making, and only grow as profiles are added. -->
<div class="vv-ai-ctrls">
<?= $o['controls'] ?? '' ?>
<?php if ($height !== ''): ?>
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
title="Give the conversation more room">⤢</button>
<?php endif; ?>
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
<span class="vv-ai-hint">Ctrl+Enter to send</span>
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
<div id="<?= $p ?>-profiles">
<div class="vv-ai-picker">
<button class="vv-ai-chip" id="<?= $p ?>-chip" type="button" aria-haspopup="listbox"
aria-expanded="false"><span id="<?= $p ?>-chip-l"></span><span class="vv-ai-chip-c">▾</span></button>
<div class="vv-ai-menu" id="<?= $p ?>-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>
</div>
<div class="vv-ai-actions">
<?= $o['controls'] ?? '' ?>
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
title="Give the conversation more room">⤢</button>
<!-- Saved conversations. The same store the Conversations card reads, so a thread opened
from either is the same thread. -->
<div class="vv-ai-picker" id="<?= $p ?>-hist">
<button class="vv-ai-btn ghost vv-ai-histbtn" id="<?= $p ?>-hist-b" type="button"
aria-haspopup="listbox" aria-expanded="false"
title="Saved conversations">▾</button>
<div class="vv-ai-menu vv-ai-menu-r" id="<?= $p ?>-hist-menu" role="listbox"></div>
</div>
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
</div>
</div>
</div>
</div>
+14 -7
View File
@@ -119,6 +119,7 @@ if (is_dir('/var/log/varaverk')) {
.vv-ai-mdl-n { color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.vv-ai-mdl-m { color:#444; font-family:monospace; margin-left:auto; flex-shrink:0; font-size:10px; }
.vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; }
.vv-ai-buildline { font-size:10px; color:#3a3a3a; font-family:monospace; margin:6px 2px 0; }
/* ── Token accounting ───────────────────────────────────────────────────── */
/* Splits on the second banner stat rather than the third, so the host window stays narrow and
@@ -227,21 +228,27 @@ if (is_dir('/var/log/varaverk')) {
</div>
<?php
// Heights are this tab's to choose; the component's shape is not. This is the surface with the
// most room, so it rests taller than the Monitor card and expands to most of the viewport.
vv_ai_chat_markup('vv-ai', [
'profile' => 'varaverk',
'height' => '52vh',
'tall' => '85vh',
'empty' => "Ask Varaverk about itself. Answers come only from this installation's own "
. 'documentation, with sources.',
'placeholder' => 'e.g. what stops rsync and the mover running at once?',
// The build stamp and the liveness marker stay on this tab. Unraid swaps tab content by
// AJAX without tearing down the previous page's JavaScript, so "is the browser running the
// code I just deployed" is not answerable from the server — the marker answers it from the
// browser, and it is where every deploy on this tab gets checked.
'controls' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>'
. '<span class="vv-ai-hint">build ' . $_vv_ai_build
. ' <span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>',
'controls' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>',
]);
?>
<!-- The build stamp and the liveness marker stay on this tab, below the composer rather than in
it. Unraid swaps tab content by AJAX without tearing down the previous page's JavaScript, so
"is the browser running the code I just deployed" is not answerable from the server — this
marker answers it from the browser, and it is where every deploy on this tab gets checked.
It sits here because the control row above is for controls: it was the one piece of prose in
a line of buttons, and it is read deliberately rather than glanced at. -->
<div class="vv-ai-buildline">build <?= $_vv_ai_build ?><span id="vv-ai-live" style="color:#e57"> · JS NOT RUNNING</span></div>
<!-- Settings. Below the composer and closed by default: these change how the next answer is
built, not what it is asked, so they should be reachable without being in the way. The
header carries the current values so the state is legible while collapsed. -->