Separate whether the chat is expanded from how big expanded is

One is an action taken constantly and the other a preference set once, so the banner names the
size and the composer keeps the toggle.
This commit is contained in:
Gmer4Lfe
2026-08-09 12:59:30 -04:00
parent 455fb55857
commit c3ed7262cd
4 changed files with 124 additions and 44 deletions
+94 -27
View File
@@ -205,7 +205,14 @@ function vv_ai_chat_assets(): void {
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
padding:4px 7px; border-radius:3px; }
/* 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; }
.vv-ai-histbtn { min-width:30px; text-align:center; }
/* Banner. The title takes the same shape a .vv-card h3 does, so an instance dropped into a card
reads as that card's heading rather than as a second one underneath it. */
.vv-ai-head { display:flex; align-items:center; justify-content:space-between; gap:8px;
margin-bottom:8px; }
.vv-ai-head-t { display:flex; align-items:center; gap:5px; font-size:13px; color:#888;
text-transform:uppercase; letter-spacing:0.05em; min-width:0; }
/* ── 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
@@ -266,7 +273,8 @@ function vv_ai_chat_assets(): void {
.vv-ai-crow.active .vv-ai-crow-t { color:#9bd; }
.vv-ai-crow-m { font-size:9px; color:#3a3a3a; font-family:monospace; flex-shrink:0; }
.vv-ai-chat { transition:height .14s ease; }
.vv-ai-grow { font-size:13px !important; line-height:1; padding:4px 9px !important; }
.vv-ai-size { font-size:10px !important; line-height:1; padding:4px 10px !important;
letter-spacing:0.04em; flex-shrink:0; }
.vv-ai-grow-on { color:#9bd !important; border-color:#2d4a6a !important; }
.vv-ai-crow-s { font-size:9px; color:#5c7cfa; font-family:monospace; }
.vv-ai-c .vv-ai-crow-s { color:#7a9ae0; }
@@ -860,19 +868,39 @@ vv_ai_profiles_script();
// whose sizes are not knowable when the markup is written can rewrite them later — the
// Scheduler's panel takes its heights as a share of whatever room the panel has, which is a
// number that only exists after layout and changes on every resize. See setHeights().
let big = false;
const growBtn = $('grow');
// Two independent pieces of state, not one three-way. `big` is whether the conversation is
// expanded — an action, taken often. `large` is how much expanded is worth — a setting, chosen
// once. Collapsing and then expanding again returns to the size you picked rather than
// resetting it, which is the whole reason they are separate.
let big = false, large = false;
const growBtn = $('grow'), sizeBtn = $('size');
function applyHeights() {
const el = chatEl();
const base = el.dataset.h || '';
const tall = el.dataset.hTall || '';
if (!base || !tall) return;
el.style.height = big ? tall : base;
const med = el.dataset.hTall || '';
const big2 = el.dataset.hLarge || med;
if (!base || !med) return;
el.style.height = big ? (large ? big2 : med) : base;
if (growBtn) {
growBtn.textContent = big ? '⤡' : '⤢';
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
growBtn.classList.toggle('vv-ai-grow-on', big);
}
if (sizeBtn) {
// Nothing to choose between while collapsed — it would name a size that is not on screen
// and does not become so until the button beside it is pressed.
sizeBtn.style.display = big ? '' : 'none';
// Names the size in force, not the one it would switch to. A button labelled with its own
// effect reads as a statement of current state to about half of everyone who sees it, and
// the two readings disagree about what clicking does — so it states, and the title says
// what happens.
sizeBtn.textContent = large ? 'Large' : 'Medium';
sizeBtn.title = large ? 'Expanded is large — click for medium'
: 'Expanded is medium — click for large';
sizeBtn.classList.toggle('vv-ai-grow-on', large);
}
}
if (growBtn) {
// onResize fires only here, on the operator's own toggle — never from setHeights(). A page
@@ -890,6 +918,16 @@ vv_ai_profiles_script();
onResize(big);
});
}
if (sizeBtn) {
// Only meaningful while expanded, and only reachable then, so this always changes the
// height on screen — and therefore always has to be announced like the toggle does.
sizeBtn.addEventListener('click', () => {
large = !large;
applyHeights();
scroll();
onResize(big);
});
}
// Surface any script error into the transcript. Without it a throw anywhere on the page is
// invisible unless the console happens to be open, which is how a silent hang survives a
@@ -939,9 +977,10 @@ vv_ai_profiles_script();
// Heights supplied after the fact, for a placement whose room is a share of a panel rather
// than a constant. Re-applies immediately at whichever of the two states is current, so a
// resize while expanded stays expanded instead of snapping back.
setHeights(base, tall) {
setHeights(base, tallMed, tallLarge) {
const el = chatEl();
el.dataset.h = base; el.dataset.hTall = tall;
el.dataset.h = base; el.dataset.hTall = tallMed;
if (tallLarge) el.dataset.hLarge = tallLarge;
applyHeights();
},
@@ -1065,12 +1104,16 @@ vv_ai_profiles_script();
//
// 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
// height resting transcript height, the collapsed state. 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 surface that forgot to pass a number.
// tall expanded at the Medium setting. Defaults to 2x height.
// tallLarge expanded at the Large setting. Defaults to 3x height.
// Two expanded sizes because ⤢ and the banner's Medium/Large answer different
// questions: whether the conversation is expanded, and how much that is worth. The
// first is an action taken constantly, the second a preference set once.
// title banner text. Defaults to "Assistant"
// icon raw SVG for the banner, page-supplied and emitted unescaped
// empty empty-state text
// 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
@@ -1082,24 +1125,48 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
// 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.
$tall = $o['tall'] ?? '';
if ($height !== '' && $tall === '' && preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $height, $hm)) {
$n = (float)$hm[1] * 2.5;
// vh is a share of the viewport, so 2.5x runs off the bottom of the screen — an expand
// that puts the composer out of reach is worse than no expand. Clamped to something that
// still leaves the page scrollable to its own controls.
if ($hm[2] === 'vh') $n = min($n, 85);
$tall = rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $hm[2];
}
// Derived from the resting height, so a page need only state the one number it actually cares
// about. Large is 3x, capped at 85vh — vh is a share of the viewport and an expand that puts
// the composer out of reach is worse than no expand.
//
// Medium is placed between resting and large rather than at a multiple of its own, because a
// fixed multiple collapses under that cap: at 52vh resting, 2x and 3x are both 85vh after
// clamping, and the Medium/Large button would have had two settings that did the same thing.
$sizes = function (string $from): array {
if (!preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $from, $m)) return ['', ''];
$fmt = fn(float $n) => rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $m[2];
$base = (float)$m[1];
$lg = $base * 3.0;
if ($m[2] === 'vh') $lg = min($lg, 85.0);
return [$fmt($base + ($lg - $base) * 0.55), $fmt($lg)];
};
[$defMed, $defLarge] = $sizes($height);
$tall = $o['tall'] ?? $defMed;
$large = $o['tallLarge'] ?? $defLarge;
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
. ';max-height:none;"'
. ' data-h="' . htmlspecialchars($height, ENT_QUOTES) . '"'
. ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"' : '';
. ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"'
. ' data-h-large="' . htmlspecialchars($large, ENT_QUOTES) . '"' : '';
?>
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<!-- Banner. Names the thing, and carries the setting rather than the action: how big expanded
is, not whether it is expanded. ⤢ below does the expanding, which is a thing you do to the
conversation and belongs with the other things you do to it. This picks what that action
will be worth, which you set once and rarely revisit.
Hidden while collapsed, because at that point it describes nothing on screen. -->
<div class="vv-ai-head">
<span class="vv-ai-head-t">
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
<?= htmlspecialchars($o['title'] ?? 'Assistant') ?>
</span>
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
style="display:none">Medium</button>
</div>
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
</div>
+5 -1
View File
@@ -232,8 +232,12 @@ if (is_dir('/var/log/varaverk')) {
// 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',
// All three stated rather than derived. Large is at the viewport cap here, and leaving medium
// to be worked out from the resting height would land it on that same cap — two settings
// doing the same thing.
'height' => '52vh',
'tall' => '85vh',
'tall' => '68vh',
'tallLarge' => '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?',
+13 -6
View File
@@ -360,13 +360,12 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
wrapper that still carries the #vv-ai-dock id, and two elements answering to that name
on one page would be a trap the day anything queries it globally. -->
<div class="vv-card" id="vv-ai-assistant-card">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="5"/><path d="M4.4 4.6C4.4 3.7 5.1 3.1 6 3.1C6.9 3.1 7.6 3.7 7.6 4.5C7.6 5.9 6 5.7 6 7"/><circle cx="6" cy="8.8" r="0.6" fill="#888" stroke="none"/></svg></span>
Assistant
</span>
</h3>
<?php
// No <h3> of its own, unlike every other card here. The component draws its own banner now
// — it carries the size control — and a card heading above that banner would be the same
// words twice. The banner is styled as a card heading so the row still reads level with the
// cards beside it.
//
// Starts on General Chat, unlike the AI tab. This is the box you type an idle question
// into while watching the dashboard, and the worker escalates anything about this install
// to the assistant on its own — so landing on the strict profile here would refuse
@@ -374,7 +373,15 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
vv_ai_chat_markup('vv-mon-ai', [
'profile' => 'chat',
'compact' => true,
'title' => 'Assistant',
'icon' => '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="5"/><path d="M4.4 4.6C4.4 3.7 5.1 3.1 6 3.1C6.9 3.1 7.6 3.7 7.6 4.5C7.6 5.9 6 5.7 6 7"/><circle cx="6" cy="8.8" r="0.6" fill="#888" stroke="none"/></svg>',
// Deliberately unchanged. The control is wired in so the card behaves like every other
// instance, but the numbers are the ones this card has always used: 300px collapsed and
// 750px expanded, which is now Large. Medium is provisional and sits between them until
// this card gets the same re-cut the Scheduler's shares just had.
'height' => '300px',
'tall' => '500px',
'tallLarge' => '750px',
'empty' => 'Ask anything. Questions about this installation are handed to the '
. 'Varaverk assistant automatically.',
'placeholder' => 'Ask the assistant…',
+7 -5
View File
@@ -1224,10 +1224,11 @@ function vvFitRight() {
// its floor, and nothing ever raised it again.
const availH = Math.max(80, lf.getBoundingClientRect().top - 32
- toolbar.getBoundingClientRect().bottom);
// A fifth of the panel at rest, two fifths expanded. Both were double this, which made the
// assistant the largest thing in the panel before it had been asked anything — and the view
// above is what the question is usually about. Expanded still leaves the majority to the log,
// conf or script being discussed, so a follow-up does not need the panel handed back first.
// Three shares of the panel: 13% collapsed, 20% expanded at Medium, 40% at Large. Collapsed is
// deliberately small — enough to see the last exchange and type, while the log, conf or script
// being discussed keeps the panel, which is what you are usually asking about. Medium is for
// following an answer without giving up the view; Large is for sitting and reading one, and
// still leaves the majority above it so a follow-up does not need the panel handed back first.
//
// It grows upward: the panel is pinned at the bottom and everything above it is sized from what
// is left, a few lines down. So the top edge rises and the thing being read stays where it is.
@@ -1250,7 +1251,8 @@ function vvFitRight() {
// discussed on screen too; a chat that covers it entirely is not worth the room it took.
const VIEW_MIN = 90;
const room = Math.max(60, availH - VIEW_MIN - chrome);
vvSchedChat.setHeights(Math.min(room, Math.max(70, Math.round(availH * 0.20))) + 'px',
vvSchedChat.setHeights(Math.min(room, Math.max(60, Math.round(availH * 0.13))) + 'px',
Math.min(room, Math.max(100, Math.round(availH * 0.20))) + 'px',
Math.min(room, Math.max(140, Math.round(availH * 0.40))) + 'px');
}
// Measured after the heights are applied, so this reads the clamped height, not the natural one.