Fold long answers and offer a way back to the newest line
This commit is contained in:
@@ -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. */
|
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
|
/* 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. */
|
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-last { display:flex; gap:10px; padding:0 2px 4px; }
|
||||||
.vv-ai-lnk { background:none; border:none; padding:0; font-size:10px; color:#4a4a4a;
|
.vv-ai-lnk { background:none; border:none; padding:0; font-size:10px; color:#4a4a4a;
|
||||||
cursor:pointer; text-decoration:underline; text-underline-offset:2px; }
|
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;
|
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
|
// 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
|
// 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.
|
// 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.hidden = false;
|
||||||
s.innerHTML = fmt(text);
|
s.innerHTML = fmt(text);
|
||||||
if (stick) scroll();
|
if (stick) scroll();
|
||||||
|
syncJump();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paths ride in data attributes rather than an onclick. They come out of the index, and a
|
// 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 · `
|
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>`;
|
+ `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) {
|
function addError(msg) {
|
||||||
@@ -663,6 +709,14 @@ vv_ai_profiles_script();
|
|||||||
}
|
}
|
||||||
return;
|
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]');
|
const stog = e.target.closest('[data-src-toggle]');
|
||||||
if (stog) {
|
if (stog) {
|
||||||
const box = stog.parentElement;
|
const box = stog.parentElement;
|
||||||
@@ -1311,6 +1365,13 @@ vv_ai_profiles_script();
|
|||||||
$('input').addEventListener('input', saveDraft);
|
$('input').addEventListener('input', saveDraft);
|
||||||
restoreDraft();
|
restoreDraft();
|
||||||
syncLast();
|
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);
|
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
|
||||||
|
|
||||||
// ── Expand / collapse ────────────────────────────────────────────────
|
// ── Expand / collapse ────────────────────────────────────────────────
|
||||||
@@ -1786,8 +1847,13 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
|
<!-- Positioned wrapper so the jump pill can sit over the transcript's bottom edge without
|
||||||
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
|
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>
|
||||||
|
|
||||||
<div class="vv-ai-composer">
|
<div class="vv-ai-composer">
|
||||||
|
|||||||
Reference in New Issue
Block a user