Watch reasoning in the same control you read it in afterwards

A capped window of its own made the block you were watching turn into a
different control the moment the answer arrived.
This commit is contained in:
Gmer4Lfe
2026-08-11 17:37:39 -04:00
parent e3ec54f213
commit 6855dcb8fd
+42 -21
View File
@@ -242,9 +242,16 @@ function vv_ai_chat_assets(): void {
.vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px; .vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px;
display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; } display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; }
.vv-ai-think-t:hover { color:#777; border-color:#333; } .vv-ai-think-t:hover { color:#777; border-color:#333; }
/* Same caret the sources block uses, and for the same reason: the control has to say which way it
is pointing, or "reasoning (1,284 chars)" reads as a label rather than as something to click. */
.vv-ai-think-car { display:inline-block; width:10px; }
.vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d; .vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d;
border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px; white-space:pre-wrap; } border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px;
white-space:pre-wrap; word-break:break-word; }
.vv-ai-think.open { display:block; } .vv-ai-think.open { display:block; }
/* The live pair is hidden by its wrapper, never by [hidden] on the pair itself: both carry an
author display rule, and an author rule beats the UA stylesheet's [hidden] — so hiding them
directly would do nothing at all. The wrapper has no display of its own, so [hidden] holds. */
.vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; } .vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; }
.vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a; .vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a;
@@ -269,13 +276,13 @@ function vv_ai_chat_assets(): void {
/* The streaming body sits in the pending bubble and is replaced wholesale by the finished message, /* The streaming body sits in the pending bubble and is replaced wholesale by the finished message,
so it has to match .vv-ai-body or the answer visibly reflows the moment it completes. The caret so it has to match .vv-ai-body or the answer visibly reflows the moment it completes. The caret
marks text as still arriving — without it a stream that pauses mid-sentence reads as finished. */ marks text as still arriving — without it a stream that pauses mid-sentence reads as finished. */
/* Reasoning in flight. Capped and self-scrolling so it cannot push the answer off screen — this is /* Reasoning in flight has no styling of its own. It used to: a capped, self-scrolling window,
something to glance at while waiting, not the thing being read. Visually quieter than the answer justified as something to glance at rather than read. That made two presentations of one thing —
because it is not the answer. */ the block you were watching turned into a different control the moment the answer arrived — and
.vv-ai-livethink { margin:6px 0 0; max-height:180px; overflow-y:auto; background:#0d0d0d; put a nested scrollbar inside a scrolling transcript. It uses the ordinary toggle now, so the
border-left:2px solid #2a2a2a; border-radius:0 3px 3px 0; padding:7px 9px; compact palette and everything else that dresses .vv-ai-think applies to it for free. The cap
font-size:11px; line-height:1.55; color:#5f5f5f; white-space:pre-wrap; was also defending against reasoning pushing the answer off screen, which is what Auto Scroll
word-break:break-word; } is for; that control now actually holds, so the cap was solving it twice. */
.vv-ai-stream { margin-top:6px; } .vv-ai-stream { margin-top:6px; }
/* Stop reads as the destructive-ish action it is, and the colour change is what tells the operator /* Stop reads as the destructive-ish action it is, and the colour change is what tells the operator
@@ -788,7 +795,12 @@ vv_ai_profiles_script();
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>` + `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
+ `<span id="${P}-phase">starting…</span>` + `<span id="${P}-phase">starting…</span>`
+ `<span id="${P}-elapsed" style="color:#333;font-family:monospace"></span></div>` + `<span id="${P}-elapsed" style="color:#333;font-family:monospace"></span></div>`
+ `<pre class="vv-ai-livethink" id="${P}-livethink" hidden></pre>` // The same toggle a finished answer carries, open while it is being written.
// Wrapped, because [hidden] on either of these loses to their own display rule.
+ `<div id="${P}-think-w" hidden>`
+ `<div class="vv-ai-think-t"><span class="vv-ai-think-car">▾</span>`
+ `reasoning (<span id="${P}-think-n">0</span> chars)</div>`
+ `<div class="vv-ai-think open" id="${P}-livethink"></div></div>`
+ `<div class="vv-ai-body vv-ai-stream" id="${P}-stream" hidden></div></div>`); + `<div class="vv-ai-body vv-ai-stream" id="${P}-stream" hidden></div></div>`);
chatEl().appendChild(n); scroll(); chatEl().appendChild(n); scroll();
// An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a // An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a
@@ -868,19 +880,20 @@ vv_ai_profiles_script();
// through fmt(): reasoning is a stream of consciousness full of half-written fences and stray // through fmt(): reasoning is a stream of consciousness full of half-written fences and stray
// backticks, and formatting it mid-flight produces flickering code blocks that close // backticks, and formatting it mid-flight produces flickering code blocks that close
// themselves a second later. // themselves a second later.
//
// Only the count is rewritten, never the toggle around it — collapsing the block mid-answer is
// a decision, and redrawing the caret three times a second would undo it.
function thinkInto(text) { function thinkInto(text) {
const t = $('livethink'); const w = $('think-w'), t = $('livethink'), n = $('think-n');
if (!t) return; if (!w || !t) return;
const on = seeThink() && text; const on = seeThink() && !!text;
t.hidden = !on; w.hidden = !on;
if (!on) return; if (!on) return;
// Wrapped for the same reason streamInto's rewrite is: growing this block changes the // Wrapped for the same reason streamInto's rewrite is: growing this block changes the
// transcript's scroll height, and the resulting event is ours rather than the operator's. // transcript's scroll height, and the resulting event is ours rather than the operator's.
selfMove(() => { selfMove(() => {
t.textContent = text; t.textContent = text;
// Pinned to its own newest line, so watching reasoning does not require scrolling the if (n) n.textContent = text.length.toLocaleString();
// transcript while the answer is still arriving underneath it.
t.scrollTop = t.scrollHeight;
}); });
scrollIfFollowing(); scrollIfFollowing();
} }
@@ -923,7 +936,7 @@ vv_ai_profiles_script();
let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`; let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`;
if (job.thinking) { if (job.thinking) {
h += `<div class="vv-ai-think-t" data-think="1">` h += `<div class="vv-ai-think-t" data-think="1"><span class="vv-ai-think-car">▸</span>`
+ `reasoning (${job.thinking.length.toLocaleString()} chars)</div>` + `reasoning (${job.thinking.length.toLocaleString()} chars)</div>`
+ `<div class="vv-ai-think">${esc(job.thinking)}</div>`; + `<div class="vv-ai-think">${esc(job.thinking)}</div>`;
} }
@@ -975,8 +988,15 @@ vv_ai_profiles_script();
// One listener for the whole transcript, covering nodes that do not exist yet. // One listener for the whole transcript, covering nodes that do not exist yet.
chatEl().addEventListener('click', e => { chatEl().addEventListener('click', e => {
// Drives both the live block and a finished answer's — they are the same control now, so
// one handler owns the caret and neither can drift into disagreeing about which way it points.
const think = e.target.closest('.vv-ai-think-t'); const think = e.target.closest('.vv-ai-think-t');
if (think) { think.nextElementSibling.classList.toggle('open'); return; } if (think) {
const open = think.nextElementSibling.classList.toggle('open');
const car = think.querySelector('.vv-ai-think-car');
if (car) car.textContent = open ? '▾' : '▸';
return;
}
const src = e.target.closest('.vv-ai-src-i'); const src = e.target.closest('.vv-ai-src-i');
// Through vvSafeUrl, which is the global that exists precisely so a URL from outside this // Through vvSafeUrl, which is the global that exists precisely so a URL from outside this
// machine cannot become a javascript: href. The server drops anything that is not http(s) // machine cannot become a javascript: href. The server drops anything that is not http(s)
@@ -1777,9 +1797,10 @@ vv_ai_profiles_script();
seeEl.addEventListener('change', () => { seeEl.addEventListener('change', () => {
try { localStorage.setItem(SEE_THINK_KEY, seeEl.checked ? '1' : '0'); } catch (_) {} try { localStorage.setItem(SEE_THINK_KEY, seeEl.checked ? '1' : '0'); } catch (_) {}
// Takes effect on the turn in flight, not just the next one — unticking it mid-answer is // Takes effect on the turn in flight, not just the next one — unticking it mid-answer is
// usually someone deciding they have seen enough. // usually someone deciding they have seen enough. The wrapper is what hides, for the same
const t = $('livethink'); // reason thinkInto uses it: [hidden] on the pair itself loses to their own display rule.
if (t) t.hidden = !seeEl.checked || !t.textContent; const w = $('think-w'), t = $('livethink');
if (w) w.hidden = !seeEl.checked || !t || !t.textContent;
}); });
} }
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat); const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);