diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php
index 84d6251..12fa8ba 100644
--- a/Plugin/unraid/include/ai_chat.php
+++ b/Plugin/unraid/include/ai_chat.php
@@ -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();
+ `
`
+ `starting…`
+ `
`
- + ``
+ // 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.
+ + ``
+ + `
▾`
+ + `reasoning (0 chars)
`
+ + `
`
+ ``);
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 = `Varaverk
`;
if (job.thinking) {
- h += `
`
+ h += `
▸`
+ `reasoning (${job.thinking.length.toLocaleString()} chars)
`
+ `
${esc(job.thinking)}
`;
}
@@ -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);