One profile picker for both surfaces, and open where you left off

This commit is contained in:
Gmer4Lfe
2026-08-09 00:56:04 -04:00
parent d9225d06d9
commit 6cdb0d1eab
4 changed files with 185 additions and 32 deletions
+87 -27
View File
@@ -67,9 +67,6 @@
// on the dashboard.
require_once __DIR__ . '/ai_profiles.php';
// Emitted once even if two instances are rendered. A second copy of the script would re-register
// the factories harmlessly but would also install a second Escape handler and a second copy of
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
// The conversation store, on its own, with no styling and no chat widget attached.
//
// Emitted separately because the surface that most needs it is the one that does not want the
@@ -110,6 +107,9 @@ function vv_ai_chat_store_script(): void {
<?php
}
// Emitted once even if two instances are rendered. A second copy of the script would re-register
// the factories harmlessly but would also install a second Escape handler and a second copy of
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
function vv_ai_chat_assets(): void {
static $done = false;
if ($done) return;
@@ -132,13 +132,8 @@ function vv_ai_chat_assets(): void {
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
/* ── Profiles ───────────────────────────────────────────────────────────── */
.vv-ai-profiles { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
.vv-ai-prof { background:#0e0e0e; border:1px solid #262626; color:#5a5a5a; font-size:11px;
padding:5px 12px; border-radius:4px; cursor:pointer; font-family:inherit; }
.vv-ai-prof:hover { color:#8a8a8a; border-color:#333; }
.vv-ai-prof.active { background:#152238; border-color:#2d4a6a; color:#9bd; }
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; margin-left:6px; flex:1; min-width:180px; }
/* Profile picker styling lives in css/varaverk.css — the Scheduler dock uses the same control
and loads none of this stylesheet. */
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
border-top:1px dashed #1e1e1e; padding-top:8px; }
@@ -213,11 +208,24 @@ 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 { padding:3px 9px; font-size:10px; background:#2a2a2a;
border-color:#555; color:#ccc; }
.vv-ai-c .vv-ai-prof:hover { border-color:#888; color:#fff; }
.vv-ai-c .vv-ai-prof.active { background:#152238; border-color:#4a7ab0; color:#9bd; }
.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
card this size that is a third of the visible transcript spent on "You" and "Varaverk". */
.vv-ai-c .vv-ai-role { display:none; }
.vv-ai-c .vv-ai-body { font-size:12px; line-height:1.55; }
.vv-ai-c .vv-ai-msg.user .vv-ai-body { color:#8fb0c4; }
.vv-ai-c .vv-ai-msg.user .vv-ai-body::before { content:' '; color:#3a5a6a; }
.vv-ai-c .vv-ai-msg.user { margin-bottom:5px; }
.vv-ai-c .vv-ai-msg.bot { margin-bottom:11px; }
.vv-ai-c .vv-ai-body code { background:#1a1a1a; border:1px solid #333; border-radius:2px;
padding:0 4px; font-size:11px; color:#9ab; }
.vv-ai-c .vv-ai-body strong { color:#ddd; }
.vv-ai-c .vv-ai-src { font-size:10px; color:#4a4a4a; font-family:monospace; }
.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; }
@@ -618,13 +626,15 @@ vv_ai_profiles_script();
// ── Profiles ─────────────────────────────────────────────────────────
function applyProfile(p) {
profile = p;
const bar = $('profiles');
if (bar) bar.querySelectorAll('.vv-ai-prof').forEach(b =>
const chipL = $('chip-l');
if (chipL) chipL.textContent = PROFILES[p] ? PROFILES[p].label : p;
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].hint;
if (hint) hint.textContent = PROFILES[p] ? PROFILES[p].hint : '';
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
if (kindEl) kindEl.style.display = PROFILES[p].kind ? '' : 'none';
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
}
// Switching moves the floor rather than clearing the transcript. Carrying cited,
@@ -642,11 +652,29 @@ vv_ai_profiles_script();
$('input').focus();
}
const bar = $('profiles');
if (bar) bar.addEventListener('click', e => {
const b = e.target.closest('.vv-ai-prof');
if (b) setProfile(b.dataset.prof);
});
// 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(); };
if (picker) {
const chip = $('chip');
chip.addEventListener('click', e => {
e.stopPropagation();
const open = picker.classList.toggle('open');
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
});
$('menu').addEventListener('click', e => {
const b = e.target.closest('.vv-ai-opt');
if (!b) return;
closeMenu();
setProfile(b.dataset.prof);
});
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
}
// ── Wiring ───────────────────────────────────────────────────────────
$('send').addEventListener('click', send);
@@ -704,6 +732,25 @@ vv_ai_profiles_script();
applyProfile(profile);
// ── Resume ───────────────────────────────────────────────────────────
// Opens on the conversation last spoken to, rather than an empty box. Chosen by `updated`
// across the whole store, not per surface: the point of one store was that a thread started
// on the dashboard is the one you carry on in the tab, and resuming per-surface would give
// each of them its own idea of where you left off — which is the thing it was meant to stop.
//
// Silent on failure. Nothing here is worth an error message: the fallback is the empty box
// the operator would otherwise have got, and an empty box is a working chat.
if (store && o.resume !== false) {
fetch(API + '?action=chats').then(r => r.json()).then(d => {
if (!d.ok || !(d.chats || []).length) return;
// Do not clobber a conversation the operator has already started. The list arrives after
// construction, so by the time it lands they may have typed and sent something.
if (messages.length || busy) return;
const last = d.chats.slice().sort((a, b) => (b.updated || 0) - (a.updated || 0))[0];
if (last) loadChat(last.id);
}).catch(() => {});
}
const inst = {
prefix: P,
setProfile, newChat, loadChat, send,
@@ -712,6 +759,7 @@ vv_ai_profiles_script();
clearInterval(pendingTimer);
window.removeEventListener('error', onErr);
window.removeEventListener('unhandledrejection', onRej);
document.removeEventListener('click', onDocClick);
if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P];
},
};
@@ -823,11 +871,23 @@ 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">
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
<button class="vv-ai-prof" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>" type="button"
title="<?= htmlspecialchars($def['hint'], ENT_QUOTES) ?>"><?= htmlspecialchars($def['label']) ?></button>
<?php endforeach; ?>
<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>