Watch the reasoning as it is written, and hold the view where you put it

This commit is contained in:
Gmer4Lfe
2026-08-10 22:33:57 -04:00
parent 25361fafdb
commit 66f427dd2d
2 changed files with 116 additions and 19 deletions
+5 -2
View File
@@ -936,10 +936,13 @@ while (($line = fgets($fh)) !== false) {
$now = microtime(true);
if ($now - $lastFlush >= $FLUSH_SEC) {
$lastFlush = $now;
// thinking_chars rather than the reasoning itself: with think on, nothing lands in
// content for ~15s, and a page that only watched partial would look hung.
// The reasoning rides along as well as its length. The count alone is what keeps the
// phase line honest during the ~15s before any content lands; the text is what the
// page shows when the operator has asked to watch it happen. Both are cheap here —
// the job file is tmpfs and the poll is a loopback request.
jw($jobFile, ['status' => 'generating',
'partial' => $answer,
'thinking' => $thinking,
'thinking_chars' => strlen($thinking),
'sources' => $sources]);
}
+111 -17
View File
@@ -248,6 +248,14 @@ 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; }
.vv-ai-stream { margin-top:6px; }
/* Stop reads as the destructive-ish action it is, and the colour change is what tells the operator
the button's job swapped — the label alone is easy to miss mid-answer. */
@@ -276,9 +284,23 @@ function vv_ai_chat_assets(): void {
/* Banner. The title takes the same shape a .vv-card h3 does, so an instance dropped into a card
reads as that card's heading rather than as a second one underneath it. */
.vv-ai-head { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
/* The title takes the slack, so the two controls group together at the right rather than being
spread apart — with three children, space-between would strand the size button in the middle. */
.vv-ai-head-t { display:flex; align-items:center; gap:5px; flex:1 1 auto; min-width:0;
/* flex:1 1 0 on both outer groups, not 1 1 auto — with auto, the basis is the content, so a long
title on one side and two buttons on the other take different shares and the middle drifts off
centre. A zero basis makes the two claims equal whatever they contain. */
.vv-ai-head-c { display:flex; align-items:center; gap:10px; flex:0 0 auto; }
.vv-ai-head-r { display:flex; align-items:center; gap:6px; flex:1 1 0; min-width:0;
justify-content:flex-end; }
/* Checkbox and label move together, and the label is clickable — a 13px box is a poor target on a
panel being used from across a room. */
.vv-ai-cb { display:flex; align-items:center; gap:4px; font-size:10px; color:#5a5a5a;
cursor:pointer; user-select:none; white-space:nowrap; text-transform:none;
letter-spacing:0; }
.vv-ai-cb input { margin:0; cursor:pointer; }
.vv-ai-cb:hover { color:#8a8a8a; }
/* Narrow placements drop the labels rather than wrapping the banner onto a second line, which
would change the measured chrome height the Scheduler subtracts. */
@media (max-width:560px) { .vv-ai-cb span { display:none; } }
.vv-ai-head-t { display:flex; align-items:center; gap:5px; flex:1 1 0; min-width:0;
font-size:13px; color:#888; text-transform:uppercase; letter-spacing:0.05em;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -543,6 +565,7 @@ 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>`
+ `<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
@@ -568,13 +591,28 @@ vv_ai_profiles_script();
// Streaming is only worth having if it does not fight the reader. Answers routinely outrun the
// panel, and the moment someone scrolls up to re-read a line, an unconditional scroll() on
// every delta drags them back down eight times a second. Stick to the bottom only while they
// are already there.
// every delta drags them back down three times a second.
const nearBottom = () => {
const c = chatEl();
return (c.scrollHeight - c.scrollTop - c.clientHeight) < 40;
};
// Follow is the visible form of the same rule, and works exactly as the Scheduler's log panel
// does: scrolling up unticks it, returning to the bottom ticks it again, and it can be unticked
// by hand to pin the view while an answer is still arriving. Making it a control rather than
// an invisible heuristic matters because the heuristic is otherwise indistinguishable from the
// page having stopped updating.
const following = () => { const f = $('follow'); return !f || f.checked; };
function syncFollow() {
const f = $('follow');
if (f) f.checked = nearBottom();
syncJump();
}
// Only when following. A plain scroll() here would defeat the control it is meant to obey.
function scrollIfFollowing() { if (following()) scroll(); }
// 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.
@@ -591,13 +629,32 @@ vv_ai_profiles_script();
const s = $('stream');
if (!s) return;
if (!text) { s.hidden = true; return; }
const stick = nearBottom();
s.hidden = false;
s.innerHTML = fmt(text);
if (stick) scroll();
scrollIfFollowing();
syncJump();
}
// Live reasoning, shown only when asked for. Rendered as plain escaped text rather than
// 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.
function thinkInto(text) {
const t = $('livethink');
if (!t) return;
const on = seeThink() && text;
t.hidden = !on;
if (!on) return;
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;
scrollIfFollowing();
}
const SEE_THINK_KEY = 'vvAiSeeThink:' + P;
const seeThink = () => { const c = $('see-think'); return !!c && c.checked; };
// Paths ride in data attributes rather than an onclick. They come out of the index, and a
// path carrying a quote interpolated into an attribute is code execution, not a display bug.
function sourcesHtml(sources) {
@@ -1107,7 +1164,10 @@ vv_ai_profiles_script();
// Partial text arrives on the same envelope as the status, so the transcript fills in
// without a second channel. Empty while the model is still reasoning — thinking_chars is
// what moves then, and a page watching only partial would look wedged for ~15s.
if (j.status === 'generating') streamInto(j.partial || '');
if (j.status === 'generating') {
streamInto(j.partial || '');
thinkInto(j.thinking || '');
}
phase(j.status === 'generating'
? (j.partial
@@ -1411,9 +1471,24 @@ vv_ai_profiles_script();
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 });
// Ticks Follow again on the way down, because arriving at the bottom is what Follow means.
jumpEl.addEventListener('click', () => { scroll(); syncFollow(); });
}
// passive: this only reads scroll position, so it must never delay the scroll itself.
chatEl().addEventListener('scroll', syncFollow, { passive: true });
const seeEl = $('see-think');
if (seeEl) {
// A display preference, so it is remembered per instance — the Monitor card and the AI tab
// are watched in different circumstances and should not share one answer.
try { seeEl.checked = localStorage.getItem(SEE_THINK_KEY) === '1'; } catch (_) {}
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;
});
}
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
@@ -1859,12 +1934,31 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
<?= htmlspecialchars($o['title'] ?? 'Assistant') ?>
</span>
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
style="display:none">Medium</button>
<!-- Far right, and there whatever the conversation is doing — unlike the size button beside
it, which has nothing to say while collapsed. -->
<button class="vv-ai-btn ghost vv-ai-keysbtn" id="<?= $p ?>-keys-t" type="button"
aria-expanded="true" title="Keyboard shortcuts">Shortcuts</button>
<!-- Centred by giving the title and the right-hand group equal flex, not by margins — the
title changes width per placement and the buttons wrap on a narrow panel, and either
would drag a margin-centred group off centre. Same reasoning as the composer row.
Both are view controls: neither changes what is asked, only what you watch while it
happens. That is why they sit here with the window controls rather than in the composer
beside the things that shape the question. -->
<span class="vv-ai-head-c">
<label class="vv-ai-cb" title="Show the model's reasoning as it is written, not after">
<input type="checkbox" id="<?= $p ?>-see-think"> <span>Reasoning</span>
</label>
<label class="vv-ai-cb" title="Follow the newest line. Unticks when you scroll up, re-ticks at the bottom.">
<input type="checkbox" id="<?= $p ?>-follow" checked> <span>Auto Scroll</span>
</label>
</span>
<span class="vv-ai-head-r">
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
style="display:none">Medium</button>
<!-- Far right, and there whatever the conversation is doing — unlike the size button beside
it, which has nothing to say while collapsed. -->
<button class="vv-ai-btn ghost vv-ai-keysbtn" id="<?= $p ?>-keys-t" type="button"
aria-expanded="true" title="Keyboard shortcuts">Shortcuts</button>
</span>
</div>
<!-- Shortcuts. Open on a browser that has never seen it and remembered from then on, which for