One profile picker for both surfaces, and open where you left off
This commit is contained in:
@@ -529,9 +529,9 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
|
||||
#vv-ai-dock { border: 1px solid #262626; border-radius: 6px; background: #0e0e0e;
|
||||
margin-top: 6px; flex-shrink: 0; }
|
||||
#vv-ai-dock-bar { display: flex; align-items: center; gap: 7px; padding: 6px 8px; }
|
||||
#vv-ai-dock-chip { flex-shrink: 0; font-size: 10px; color: #5a7a8a; background: #12191d;
|
||||
border: 1px solid #24343d; border-radius: 3px; padding: 2px 7px;
|
||||
white-space: nowrap; max-width: 40%; overflow: hidden; text-overflow: ellipsis; }
|
||||
#vv-ai-dock-chip { white-space: nowrap; max-width: 240px; overflow: hidden;
|
||||
text-overflow: ellipsis; }
|
||||
/* The button around it is .vv-ai-chip and carries the colour, border and padding. */
|
||||
.vv-ai-chip-flash { animation: vvAiChipFlash .9s ease-out 2; }
|
||||
@keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; }
|
||||
50% { background:#2a2312; color:#d8b25a; } }
|
||||
@@ -1066,3 +1066,37 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
|
||||
.vv-script-args::placeholder { color: #3a3a3a; }
|
||||
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
|
||||
.vv-rsync-save-btn:hover { background: #22382a; }
|
||||
|
||||
/* ── AI profile picker ────────────────────────────────────────────────────────
|
||||
Global rather than emitted with the chat widget, because both surfaces that show it are
|
||||
styled differently in every other respect: the Monitor card renders a full transcript from
|
||||
include/ai_chat.php, the Scheduler dock renders its own one-line bar and loads none of that
|
||||
stylesheet. The picker is the one control they share, so it lives where .vv-ai-dock-* already
|
||||
does instead of being emitted twice. */
|
||||
/* ── Profile picker ─────────────────────────────────────────────────────── */
|
||||
/* The chip borrows the Scheduler dock's language deliberately — same muted blue, same weight,
|
||||
same 10px — so the two surfaces read as one control that happens to appear in two places. */
|
||||
.vv-ai-profiles { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.vv-ai-picker { position:relative; flex-shrink:0; }
|
||||
.vv-ai-chip { display:inline-flex; align-items:center; gap:5px; font-size:10px; color:#5a7a8a;
|
||||
background:#12191d; border:1px solid #24343d; border-radius:3px; padding:3px 8px;
|
||||
cursor:pointer; font-family:inherit; white-space:nowrap; }
|
||||
.vv-ai-chip:hover { color:#8fb0c4; border-color:#2d4a6a; }
|
||||
.vv-ai-chip-c { color:#3a5a6a; font-size:8px; }
|
||||
.vv-ai-picker.open .vv-ai-chip { color:#8fb0c4; border-color:#2d4a6a; }
|
||||
.vv-ai-picker.open .vv-ai-chip-c { transform:rotate(180deg); }
|
||||
|
||||
.vv-ai-menu { display:none; position:absolute; top:calc(100% + 4px); left:0; z-index:60;
|
||||
min-width:250px; background:#0e0e0e; border:1px solid #2a2a2a; border-radius:5px;
|
||||
box-shadow:0 6px 18px rgba(0,0,0,.55); overflow:hidden; }
|
||||
.vv-ai-picker.open .vv-ai-menu { display:block; }
|
||||
.vv-ai-opt { display:block; width:100%; text-align:left; background:none; border:none;
|
||||
border-bottom:1px solid #1a1a1a; padding:7px 10px; cursor:pointer;
|
||||
font-family:inherit; }
|
||||
.vv-ai-opt:last-child { border-bottom:none; }
|
||||
.vv-ai-opt:hover { background:#152238; }
|
||||
.vv-ai-opt.active { background:#12191d; }
|
||||
.vv-ai-opt.active .vv-ai-opt-l { color:#9bd; }
|
||||
.vv-ai-opt-l { display:block; font-size:11px; color:#b8b8b8; }
|
||||
.vv-ai-opt-h { display:block; font-size:10px; color:#4a4a4a; line-height:1.4; margin-top:2px; }
|
||||
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; flex:1; min-width:150px; }
|
||||
|
||||
@@ -1251,6 +1251,10 @@ function vv_ai_chats_list(): array {
|
||||
$out[] = [
|
||||
'id' => $d['id'],
|
||||
'ts' => (int)($d['ts'] ?? 0),
|
||||
// Last activity, as distinct from ts. The list is ordered by creation so the prune
|
||||
// order and the visible order agree, but "which conversation was I last in" is a
|
||||
// different question and needs the other timestamp to answer it.
|
||||
'updated' => (int)($d['updated'] ?? $d['ts'] ?? 0),
|
||||
'profile' => (string)($d['profile'] ?? 'chat'),
|
||||
'scope' => (string)($d['scope'] ?? ''),
|
||||
'title' => (string)($d['title'] ?? 'Untitled conversation'),
|
||||
|
||||
@@ -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">
|
||||
<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-prof" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>" type="button"
|
||||
title="<?= htmlspecialchars($def['hint'], ENT_QUOTES) ?>"><?= htmlspecialchars($def['label']) ?></button>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1073,7 +1073,25 @@ Still the same two servers, two households, the same media stack running itself.
|
||||
<div id="vv-ai-dock">
|
||||
<div id="vv-ai-dock-body" style="display:none"></div>
|
||||
<div id="vv-ai-dock-bar">
|
||||
<span id="vv-ai-dock-chip" title="What the assistant will answer about — follows the view you have open">Assistant · Scheduler</span>
|
||||
<!-- The chip is now the trigger for the profile picker as well as the scope readout.
|
||||
#vv-ai-dock-chip stays the label span it always was, so every existing write to
|
||||
its textContent still lands; the button around it carries the decoration. -->
|
||||
<div class="vv-ai-picker" id="vv-ai-dock-picker">
|
||||
<button class="vv-ai-chip" id="vv-ai-dock-chipbtn" type="button"
|
||||
aria-haspopup="listbox" aria-expanded="false"
|
||||
title="What the assistant will answer about — follows the view you have open. Click to change profile.">
|
||||
<span id="vv-ai-dock-chip">Assistant · Scheduler</span><span class="vv-ai-chip-c">▾</span>
|
||||
</button>
|
||||
<div class="vv-ai-menu" id="vv-ai-dock-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>
|
||||
<input type="text" id="vv-ai-dock-input" autocomplete="off"
|
||||
placeholder="Ask about what is on screen…"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();vvAiDockSend();}">
|
||||
@@ -2358,6 +2376,7 @@ function vvClickCog(el) {
|
||||
|
||||
let vvAiProfile = 'varaverk';
|
||||
let vvAiScope = 'Scheduler';
|
||||
let vvAiScopeLabel = 'Scheduler'; // what the chip reads; the target is the id behind it
|
||||
let vvAiHist = []; // what the model is told; reset when the scope changes
|
||||
let vvAiChatId = ""; // the stored conversation this thread is appending to
|
||||
let vvAiBusy = false;
|
||||
@@ -2365,6 +2384,39 @@ let vvAiPoll = null;
|
||||
|
||||
function vvAiDockOn() { return !!document.getElementById('vv-ai-dock'); }
|
||||
|
||||
// ── Profile picker ───────────────────────────────────────────────────────────
|
||||
// The chip states the contract and the thing being asked about; clicking it changes the first
|
||||
// without moving the second. Same control as the Monitor card's, same stylesheet — the dock keeps
|
||||
// its own bar because that is a real difference, but which profile answers is not a per-surface
|
||||
// idea and should not have a per-surface control.
|
||||
//
|
||||
// Routes through vvAiDockScope() rather than assigning vvAiProfile, so a profile change gets the
|
||||
// history reset and the on-screen marker that a scope change already gets. Picking a new contract
|
||||
// mid-thread and silently feeding it the previous one's turns is the bug that rule exists for.
|
||||
(function vvAiPickerInit() {
|
||||
const picker = document.getElementById('vv-ai-dock-picker');
|
||||
if (!picker) return;
|
||||
const btn = document.getElementById('vv-ai-dock-chipbtn');
|
||||
const menu = document.getElementById('vv-ai-dock-menu');
|
||||
const close = () => { picker.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
|
||||
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const open = picker.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
menu.querySelectorAll('.vv-ai-opt').forEach(o =>
|
||||
o.classList.toggle('active', o.dataset.prof === vvAiProfile));
|
||||
});
|
||||
menu.addEventListener('click', e => {
|
||||
const o = e.target.closest('.vv-ai-opt');
|
||||
if (!o) return;
|
||||
close();
|
||||
vvAiDockScope(o.dataset.prof, vvAiScopeLabel, vvAiScope);
|
||||
});
|
||||
document.addEventListener('click', e => { if (!picker.contains(e.target)) close(); });
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
||||
})();
|
||||
|
||||
// Called by every view switch. Profile and scope are derived from what is open and shown on the
|
||||
// chip — never chosen, never hidden. If the user can see what it thinks it is looking at, a
|
||||
// wrong inference costs a glance instead of a confidently wrong answer.
|
||||
@@ -2379,6 +2431,7 @@ function vvAiDockScope(profile, label, target) {
|
||||
const had = vvAiHist.length > 0;
|
||||
vvAiProfile = profile;
|
||||
vvAiScope = target;
|
||||
vvAiScopeLabel = label; // kept so the picker can change profile without moving the scope
|
||||
// Short label from the registry, not a literal map. The map that used to be here was the
|
||||
// fifth place a profile got defined, and it silently fell back to "Assistant" for any id it
|
||||
// had not been told about — so a new profile would have shown up in the chip as the strict one.
|
||||
@@ -2400,7 +2453,9 @@ function vvAiDockScope(profile, label, target) {
|
||||
// The scope moved under text already typed. Not blocked — just never silent.
|
||||
const input = document.getElementById('vv-ai-dock-input');
|
||||
if (input.value.trim() !== '') {
|
||||
const chip = document.getElementById('vv-ai-dock-chip');
|
||||
// The button, not the label span inside it. The flash animates a background, and the
|
||||
// background moved to the button when the chip became the picker's trigger.
|
||||
const chip = document.getElementById('vv-ai-dock-chipbtn');
|
||||
chip.classList.remove('vv-ai-chip-flash');
|
||||
void chip.offsetWidth;
|
||||
chip.classList.add('vv-ai-chip-flash');
|
||||
|
||||
Reference in New Issue
Block a user