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 = '