From c431370a68c438792c72709966efa123ea9342d2 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 9 Aug 2026 11:40:01 -0400 Subject: [PATCH] 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. --- Plugin/unraid/css/varaverk.css | 16 ++- Plugin/unraid/include/ai_chat.php | 196 ++++++++++++++++++++++-------- Plugin/unraid/pages/ai.php | 21 ++-- 3 files changed, 170 insertions(+), 63 deletions(-) diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index e1b713d..66e16ac 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -1217,7 +1217,6 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; } /* ── 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; @@ -1227,9 +1226,16 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; } .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; +/* Opens upward. Every picker in the plugin now sits on the composer's bottom row, so a menu + dropping downward would fall out of the component — off the bottom of the Monitor card and + over the help panel beneath it. Anchored to the trigger's top edge instead. */ +.vv-ai-menu { display:none; position:absolute; bottom: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; } + box-shadow:0 -6px 18px rgba(0,0,0,.55); overflow:hidden; } +/* Right-anchored variant for triggers near the right edge, where a left-anchored menu would run + off the card. Used by the saved-conversations picker, whose list is unbounded — capped so a + long history scrolls in place rather than running off the top of the viewport. */ +.vv-ai-menu-r { left:auto; right:0; max-height:min(60vh,420px); overflow-y:auto; } .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; @@ -1240,4 +1246,6 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; } .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; } +/* Loading, empty and error in the saved-conversations menu: one line in the menu's own voice, + styled so it does not read as a row you might try to click. */ +.vv-ai-hist-msg { font-size:10px; color:#4a4a4a; font-style:italic; padding:9px 10px; } diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 9c846ab..c81e632 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -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 = '
loading…
'; + fetch(API + '?action=chats').then(r => r.json()).then(d => { + const rows = (d.ok && d.chats) ? d.chats : []; + if (!rows.length) { + histMenu.innerHTML = '
no saved conversations yet
'; + 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 ``; + }).join(''); + }).catch(() => { + histMenu.innerHTML = '
could not read the store
'; + }); + }); + + 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 { ?>
- -
-
- -
- $def): ?> - - -
-
- -
-
>
+ +
- - - - - - Ctrl+Enter to send - +
+
+ +
+ $def): ?> + + +
+
+
+ +
+ + + +
+ +
+
+ + +
diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index cd73867..29a9c12 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -119,6 +119,7 @@ if (is_dir('/var/log/varaverk')) { .vv-ai-mdl-n { color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .vv-ai-mdl-m { color:#444; font-family:monospace; margin-left:auto; flex-shrink:0; font-size:10px; } .vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; } +.vv-ai-buildline { font-size:10px; color:#3a3a3a; font-family:monospace; margin:6px 2px 0; } /* ── Token accounting ───────────────────────────────────────────────────── */ /* Splits on the second banner stat rather than the third, so the host window stays narrow and @@ -227,21 +228,27 @@ if (is_dir('/var/log/varaverk')) { 'varaverk', + 'height' => '52vh', + 'tall' => '85vh', 'empty' => "Ask Varaverk about itself. Answers come only from this installation's own " . 'documentation, with sources.', 'placeholder' => 'e.g. what stops rsync and the mover running at once?', - // The build stamp and the liveness marker stay on this tab. Unraid swaps tab content by - // AJAX without tearing down the previous page's JavaScript, so "is the browser running the - // code I just deployed" is not answerable from the server — the marker answers it from the - // browser, and it is where every deploy on this tab gets checked. - 'controls' => '' - . 'build ' . $_vv_ai_build - . ' · JS NOT RUNNING', + 'controls' => '', ]); ?> + +
build · JS NOT RUNNING
+