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:
@@ -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. */
|
||||
/* 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-actions { display:flex; gap:8px; align-items:center; margin-left:auto; }
|
||||
.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.
|
||||
// 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.
|
||||
growBtn.addEventListener('click', () => {
|
||||
big = !big;
|
||||
applyHeights();
|
||||
scroll();
|
||||
// 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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user