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;
display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; }
.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;
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; }
/* 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-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,
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. */
/* Reasoning in flight. Capped and self-scrolling so it cannot push the answer off screen — this is
something to glance at while waiting, not the thing being read. Visually quieter than the answer
because it is not the answer. */
.vv-ai-livethink { margin:6px 0 0; max-height:180px; overflow-y:auto; background:#0d0d0d;
border-left:2px solid #2a2a2a; border-radius:0 3px 3px 0; padding:7px 9px;
font-size:11px; line-height:1.55; color:#5f5f5f; white-space:pre-wrap;
word-break:break-word; }
/* Reasoning in flight has no styling of its own. It used to: a capped, self-scrolling window,
justified as something to glance at rather than read. That made two presentations of one thing —
the block you were watching turned into a different control the moment the answer arrived — and
put a nested scrollbar inside a scrolling transcript. It uses the ordinary toggle now, so the
compact palette and everything else that dresses .vv-ai-think applies to it for free. The cap
was also defending against reasoning pushing the answer off screen, which is what Auto Scroll
is for; that control now actually holds, so the cap was solving it twice. */
.vv-ai-stream { margin-top:6px; }
/* 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>`
+ `<span id="${P}-phase">starting…</span>`
+ `<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>`);
chatEl().appendChild(n); scroll();
// 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
// backticks, and formatting it mid-flight produces flickering code blocks that close
// 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) {
const t = $('livethink');
if (!t) return;
const on = seeThink() && text;
t.hidden = !on;
const w = $('think-w'), t = $('livethink'), n = $('think-n');
if (!w || !t) return;
const on = seeThink() && !!text;
w.hidden = !on;
if (!on) return;
// 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.
selfMove(() => {
t.textContent = text;
// Pinned to its own newest line, so watching reasoning does not require scrolling the
// transcript while the answer is still arriving underneath it.
t.scrollTop = t.scrollHeight;
if (n) n.textContent = text.length.toLocaleString();
});
scrollIfFollowing();
}
@@ -923,7 +936,7 @@ vv_ai_profiles_script();
let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`;
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>`
+ `<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.
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');
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');
// 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)
@@ -1777,9 +1797,10 @@ vv_ai_profiles_script();
seeEl.addEventListener('change', () => {
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
// usually someone deciding they have seen enough.
const t = $('livethink');
if (t) t.hidden = !seeEl.checked || !t.textContent;
// usually someone deciding they have seen enough. The wrapper is what hides, for the same
// reason thinkInto uses it: [hidden] on the pair itself loses to their own display rule.
const w = $('think-w'), t = $('livethink');
if (w) w.hidden = !seeEl.checked || !t || !t.textContent;
});
}
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);