Give the chat shortcuts worth listing, and a place to list them

Four new keys alongside Ctrl+Enter, rendered from the same list the handler reads so the panel
cannot advertise a key that stopped working.
This commit is contained in:
Gmer4Lfe
2026-08-09 13:15:58 -04:00
parent c3ed7262cd
commit d6debc5408
2 changed files with 173 additions and 44 deletions
+171 -42
View File
@@ -196,16 +196,19 @@ 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; }
/* Profile left, conversation actions centred, window control right. The outer groups take equal
flex and the middle takes only what it needs, which centres it against the composer above
rather than against whatever the left group currently measures — the Scheduler's chip changes
width every time the operator opens a different script. Wrapping is allowed because the Monitor
card is narrow, and when it wraps the three groups part cleanly instead of interleaving. */
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.vv-ai-side { flex:1 1 0; min-width:0; display:flex; align-items:center; }
.vv-ai-tail { justify-content:flex-end; }
.vv-ai-actions { display:flex; gap:8px; align-items:center; flex:0 0 auto; }
.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-histbtn { min-width:30px; text-align:center; }
.vv-ai-grow { 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. */
@@ -214,6 +217,26 @@ function vv_ai_chat_assets(): void {
.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; }
/* Shortcuts strip. Shut it reads as one quiet line; open it is a two-column list that wraps to
one on a narrow card. Nothing here competes with the transcript below it. */
.vv-ai-keys { margin-bottom:8px; }
.vv-ai-keys-t { display:flex; align-items:center; gap:5px; background:none; border:none; padding:0;
font-family:inherit; font-size:10px; color:#4a4a4a; cursor:pointer;
letter-spacing:0.04em; text-transform:uppercase; }
.vv-ai-keys-t:hover { color:#777; }
.vv-ai-keys-c { display:inline-block; font-size:8px; transition:transform .12s;
transform:rotate(-90deg); }
.vv-ai-keys.open .vv-ai-keys-c { transform:rotate(0deg); }
.vv-ai-keys-b { display:none; grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
gap:2px 14px; margin-top:6px; padding:7px 9px; background:#0d0d0d;
border:1px solid #1c1c1c; border-radius:4px; }
.vv-ai-keys.open .vv-ai-keys-b { display:grid; }
.vv-ai-key { display:flex; align-items:baseline; gap:7px; font-size:10px; min-width:0; }
.vv-ai-key-k { display:flex; gap:3px; flex-shrink:0; }
.vv-ai-key-d { color:#5a5a5a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.vv-ai-key kbd { font-family:inherit; font-size:9px; color:#8a8a8a; background:#181818;
border:1px solid #2a2a2a; border-radius:3px; padding:1px 5px; line-height:1.5; }
/* ── 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
heading and the border, so the transcript drops its own and the hint text goes away rather
@@ -845,10 +868,10 @@ vv_ai_profiles_script();
}
// ── Wiring ───────────────────────────────────────────────────────────
// Ctrl+Enter is not bound here. It lives with the rest of the shortcuts on the wrapper below,
// and a second binding on the input would send the same question twice — the second landing
// on the busy guard and reporting the first as stuck.
$('send').addEventListener('click', send);
$('input').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
});
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
// ── Expand / collapse ────────────────────────────────────────────────
@@ -902,30 +925,90 @@ vv_ai_profiles_script();
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
// whose layout depends on this height calls setHeights() from inside its fit routine, so
// announcing it there would call that routine from within itself, forever.
//
// A placement that is not simply taller than its neighbours has to be told. The Scheduler
// panel is pinned at the bottom and the views above it are sized from what is left, so
// without this the chat grows downward off the end of the panel and takes its own composer
// with it — which is precisely what it did.
growBtn.addEventListener('click', () => {
big = !big;
applyHeights();
scroll();
// Named functions, because the buttons are no longer the only way in — the shortcuts below
// drive the same two.
//
// onResize fires from these and never from setHeights(). A page whose layout depends on this
// height calls setHeights() from inside its fit routine, so announcing it there would call
// that routine from within itself, forever.
//
// A placement that is not simply taller than its neighbours has to be told. The Scheduler
// panel is pinned at the bottom and the views above it are sized from what is left, so without
// this the chat grows downward off the end of the panel and takes its own composer with it —
// which is precisely what it did.
function toggleBig() { big = !big; applyHeights(); scroll(); onResize(big); }
// Only meaningful while expanded, and the button is only reachable then. The shortcut has to
// check for itself rather than silently changing a size nobody can see.
function toggleSize() {
if (!big) return;
large = !large; applyHeights(); scroll(); onResize(big);
}
if (growBtn) growBtn.addEventListener('click', toggleBig);
if (sizeBtn) sizeBtn.addEventListener('click', toggleSize);
// ── Shortcuts panel ──────────────────────────────────────────────────
// One key for every instance, not one each. The list is identical everywhere, so remembering
// it per surface would mean closing the same panel three times to be rid of it.
//
// Absent means never seen, which is the only time it opens by itself.
const KEYS_OPEN = 'vv-ai-keys-open';
const keysWrap = $('keys'), keysT = $('keys-t');
if (keysWrap && keysT) {
const applyKeys = open => {
keysWrap.classList.toggle('open', open);
keysT.setAttribute('aria-expanded', open ? 'true' : 'false');
};
applyKeys(localStorage.getItem(KEYS_OPEN) !== '0');
keysT.addEventListener('click', () => {
const open = !keysWrap.classList.contains('open');
localStorage.setItem(KEYS_OPEN, open ? '1' : '0');
applyKeys(open);
// The panel is above the transcript, so opening it takes height the page may have to
// account for — the same reason the size controls announce themselves.
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);
// Bound to the component, not to document. Alt+N and friends are browser menu accelerators;
// claiming them page-wide would break the tab this lives on. Inside the chat they are
// unambiguous, and everything they do is visible from where the operator already is.
const wrapEl = chatEl().closest('.vv-ai-chatwrap');
if (wrapEl) {
wrapEl.addEventListener('keydown', e => {
const inp = $('input');
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); return; }
// Terminal habit: an empty box and Up gets back what you just asked, to edit rather than
// retype. Only when empty, or it would eat cursor movement in a question being written.
if (e.key === 'ArrowUp' && e.target === inp && inp.value === '') {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
e.preventDefault();
inp.value = messages[i].content;
inp.setSelectionRange(inp.value.length, inp.value.length);
break;
}
}
return;
}
if (e.key === 'Escape') {
const wasOpen = (picker && picker.classList.contains('open'))
|| (histWrap && histWrap.classList.contains('open'));
closeMenus();
// Only clears once there is no menu left to close, so one Escape never does two things.
if (!wasOpen && e.target === inp && inp.value !== '') { e.preventDefault(); inp.value = ''; }
return;
}
if (!e.altKey || e.ctrlKey || e.metaKey) return;
const k = e.key.toLowerCase();
if (k === 'n') { e.preventDefault(); newChat(); inp.focus(); }
else if (k === 'e') { e.preventDefault(); toggleBig(); }
else if (k === 's') { e.preventDefault(); toggleSize(); }
else if (k === 'h') { const hb = $('hist-b'); if (hb) { e.preventDefault(); hb.click(); } }
});
}
@@ -1095,6 +1178,24 @@ vv_ai_profiles_script();
<?php
}
// The shortcuts, in one place. Rendered into the panel by the markup below and acted on by the
// keydown handler in the script above, so a key that stops working cannot keep being advertised.
//
// Everything here fires only while focus is inside the chat. Alt combinations are browser menu
// accelerators when nothing has focus, and a chat that stole them page-wide would be a chat that
// broke the tab it lives on.
function vv_ai_chat_keys(): array {
return [
['keys' => ['Ctrl', 'Enter'], 'what' => 'Ask', 'id' => 'send'],
['keys' => ['↑'], 'what' => 'Your last question, to edit', 'id' => 'recall'],
['keys' => ['Esc'], 'what' => 'Close a menu, or clear the box', 'id' => 'esc'],
['keys' => ['Alt', 'N'], 'what' => 'New conversation', 'id' => 'new'],
['keys' => ['Alt', 'E'], 'what' => 'Expand or collapse', 'id' => 'grow'],
['keys' => ['Alt', 'S'], 'what' => 'Medium or large', 'id' => 'size'],
['keys' => ['Alt', 'H'], 'what' => 'Saved conversations', 'id' => 'hist'],
];
}
// One instance's markup. The prefix composes every id, so a page may render more than one.
//
// Every placement renders the same thing. What a page chooses is how much room it gets — width is
@@ -1167,6 +1268,27 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
style="display:none">Medium</button>
</div>
<!-- Shortcuts. Open on a browser that has never seen it and remembered from then on, which for
most people means it is shut a minute later and never thought about again — which is the
point. A panel that is closed by default is one nobody discovers; one that cannot be closed
is clutter on every load forever.
The list is data, so the keys shown and the keys handled come from one place and cannot
drift into describing a shortcut that no longer works. -->
<div class="vv-ai-keys" id="<?= $p ?>-keys">
<button class="vv-ai-keys-t" id="<?= $p ?>-keys-t" type="button" aria-expanded="true">
<span class="vv-ai-keys-c">▾</span> Shortcuts
</button>
<div class="vv-ai-keys-b" id="<?= $p ?>-keys-b">
<?php foreach (vv_ai_chat_keys() as $k): ?>
<div class="vv-ai-key">
<span class="vv-ai-key-k"><?php foreach ($k['keys'] as $cap): ?><kbd><?= htmlspecialchars($cap) ?></kbd><?php endforeach; ?></span>
<span class="vv-ai-key-d"><?= htmlspecialchars($k['what']) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
</div>
@@ -1176,15 +1298,18 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
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.
<!-- Three groups, and everything in all of them is clickable. What is answering holds the
left; what you do to the conversation sits in the middle, where the composer above it
is; what you do to the window holds the right. Grouping by what a control acts on rather
than by importance means the middle is the only part you look at while working.
Centred by giving the two outer groups equal flex, not by margins — the middle stays put
when the chip's text changes length, which on the Scheduler it does on every view switch.
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">
<div id="<?= $p ?>-profiles">
<div class="vv-ai-side" 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>
@@ -1202,19 +1327,23 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
<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>
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
<!-- Saved conversations. The same store the Conversations card reads, so a thread opened
from either is the same thread. -->
from either is the same thread. Worded, not a chevron: a bare ▾ four buttons away from
the chip's ▾ was two dropdowns wearing one glyph for unrelated jobs. -->
<div class="vv-ai-picker" id="<?= $p ?>-hist">
<button class="vv-ai-btn ghost vv-ai-histbtn" id="<?= $p ?>-hist-b" type="button"
<button class="vv-ai-btn ghost" id="<?= $p ?>-hist-b" type="button"
aria-haspopup="listbox" aria-expanded="false"
title="Saved conversations"></button>
title="Reopen a saved conversation">Saved</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 class="vv-ai-side vv-ai-tail">
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
title="Give the conversation more room">⤢</button>
</div>
</div>
</div>
</div>
+2 -2
View File
@@ -1224,7 +1224,7 @@ function vvFitRight() {
// its floor, and nothing ever raised it again.
const availH = Math.max(80, lf.getBoundingClientRect().top - 32
- toolbar.getBoundingClientRect().bottom);
// Three shares of the panel: 13% collapsed, 20% expanded at Medium, 40% at Large. Collapsed is
// Three shares of the panel: 10% 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
@@ -1251,7 +1251,7 @@ 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(60, Math.round(availH * 0.13))) + 'px',
vvSchedChat.setHeights(Math.min(room, Math.max(52, Math.round(availH * 0.10))) + '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');
}