diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 9d7949d..febd534 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -164,6 +164,24 @@ function vv_ai_chat_assets(): void { 15" panels with no pointer near them, so a control that only exists on hover does not exist. */ /* Sits directly above the composer, so what it acts on — the exchange just above — reads left to right into the box you would retype it in. */ +/* Purely a positioning context. Deliberately NOT a flex item that grows: the parent is a column + flex container, .vv-ai-chat was flex:0 1 auto there, and it carries its own height — set inline + by setHeights() for the placements whose size is a share of a panel. A wrapper with flex:1 1 + auto would stretch past the transcript and leave a dead strip under it, which is the same shape + of bug as the grid-stretch one. Block-level, no padding, no flex: the geometry is unchanged and + only the coordinate system is new. min-height:0 because flex items default to auto and would + refuse to shrink. */ +.vv-ai-chat-wrap { position:relative; min-width:0; min-height:0; } +.vv-ai-jump { position:absolute; left:50%; transform:translateX(-50%); bottom:8px; z-index:3; + background:#242424; border:1px solid #3a3a3a; color:#ccc; font-size:10px; + padding:3px 12px; border-radius:11px; cursor:pointer; opacity:.94; } +.vv-ai-jump:hover { background:#2e2e2e; color:#fff; } + +/* Folded answers cap at a readable height with a hard bottom edge rather than a fade — a fade + over a code block reads as a rendering fault. */ +.vv-ai-fold { max-height:420px; overflow:hidden; } +.vv-ai-more { display:block; margin:4px 0 2px; } + .vv-ai-last { display:flex; gap:10px; padding:0 2px 4px; } .vv-ai-lnk { background:none; border:none; padding:0; font-size:10px; color:#4a4a4a; cursor:pointer; text-decoration:underline; text-underline-offset:2px; } @@ -550,6 +568,15 @@ vv_ai_profiles_script(); return (c.scrollHeight - c.scrollTop - c.clientHeight) < 40; }; + // The other half of stick-to-bottom. Scrolling up during a stream deliberately stops the + // transcript chasing you, which means text is now arriving off-screen with nothing saying so. + // The pill is that acknowledgement, and the way back. + function syncJump() { + const p = $('jump'); + if (!p) return; + p.hidden = nearBottom(); + } + // Rendered through the same fmt() as a finished answer, so a fence that is still being written // formats as it arrives rather than snapping from plain text to a code block at the end. fmt // escapes, so a half-written tag cannot break out of the bubble mid-stream. @@ -561,6 +588,7 @@ vv_ai_profiles_script(); s.hidden = false; s.innerHTML = fmt(text); if (stick) scroll(); + syncJump(); } // Paths ride in data attributes rather than an onclick. They come out of the index, and a @@ -616,7 +644,25 @@ vv_ai_profiles_script(); h += `
${t.tokens} tok · ${t.tok_s} tok/s · ` + `retrieve ${t.retrieve_ms}ms · generate ${(t.generate_ms/1000).toFixed(1)}s
`; } - chatEl().appendChild(el(h + '')); scroll(); + const node = el(h + ''); + chatEl().appendChild(node); + // Measured after it is in the DOM — scrollHeight is 0 on a detached node, so a fold decided + // before appending would either never fire or fire on everything. + foldIfLong(node); + scroll(); + } + + // A long answer buries the composer on a 15" panel, and the composer is where the next thing + // happens. Folded to a readable height with the control always visible — never hover-revealed, + // because the surfaces this runs on have no pointer near them. + const FOLD_PX = 420; + function foldIfLong(node) { + const body = node.querySelector('.vv-ai-body'); + if (!body || body.scrollHeight <= FOLD_PX) return; + body.classList.add('vv-ai-fold'); + body.insertAdjacentHTML('afterend', + ``); } function addError(msg) { @@ -663,6 +709,14 @@ vv_ai_profiles_script(); } return; } + const more = e.target.closest('[data-more]'); + if (more) { + const body = more.previousElementSibling; + if (body) body.classList.remove('vv-ai-fold'); + more.remove(); + return; + } + const stog = e.target.closest('[data-src-toggle]'); if (stog) { const box = stog.parentElement; @@ -1311,6 +1365,13 @@ vv_ai_profiles_script(); $('input').addEventListener('input', saveDraft); restoreDraft(); syncLast(); + + const jumpEl = $('jump'); + if (jumpEl) { + jumpEl.addEventListener('click', () => { scroll(); syncJump(); }); + // passive: this only reads scroll position, so it must never delay the scroll itself. + chatEl().addEventListener('scroll', syncJump, { passive: true }); + } const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat); // ── Expand / collapse ──────────────────────────────────────────────── @@ -1786,8 +1847,13 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void { -
> -
+ +
+
> +
+
+