Fold long answers and offer a way back to the newest line

This commit is contained in:
Gmer4Lfe
2026-08-10 22:16:11 -04:00
parent 2100286dbb
commit 081366a682
+67 -1
View File
@@ -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 += `<div class="vv-ai-meta">${t.tokens} tok · ${t.tok_s} tok/s · `
+ `retrieve ${t.retrieve_ms}ms · generate ${(t.generate_ms/1000).toFixed(1)}s</div>`;
}
chatEl().appendChild(el(h + '</div>')); scroll();
const node = el(h + '</div>');
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',
`<button type="button" class="vv-ai-lnk vv-ai-more" data-more>Show the rest`
+ ` (${Math.round(body.scrollHeight / 20)} lines)</button>`);
}
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,9 +1847,14 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
</div>
</div>
<!-- Positioned wrapper so the jump pill can sit over the transcript's bottom edge without
being clipped by its overflow, and without taking a row of its own when hidden. -->
<div class="vv-ai-chat-wrap">
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
</div>
<button type="button" class="vv-ai-jump" id="<?= $p ?>-jump" hidden>↓ newest</button>
</div>
<div class="vv-ai-composer">
<!-- Acts on the last exchange only, and is hidden until there is one. Redoing the previous turn