One profile picker for both surfaces, and open where you left off
This commit is contained in:
@@ -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">
|
||||
<?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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user