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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user