diff --git a/static/js/chat.js b/static/js/chat.js
index 3c8bbe850..cbdd1e9bf 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -24,6 +24,49 @@ import createResearchSynapse from './researchSynapse.js';
import { createStreamRenderer } from './streamingRenderer.js';
import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall';
+/* LIVE_THINKING_THROTTLE_START */
+function _createLiveThinkingThrottle(commit, {
+ delay = 100,
+ schedule = (callback, ms) => setTimeout(callback, ms),
+ cancel = (timer) => clearTimeout(timer),
+} = {}) {
+ let timer = null;
+ let latest = '';
+ let dirty = false;
+
+ const commitLatest = () => {
+ timer = null;
+ if (!dirty) return false;
+ dirty = false;
+ commit(latest);
+ return true;
+ };
+
+ return {
+ update(value) {
+ latest = String(value ?? '');
+ dirty = true;
+ if (timer === null) timer = schedule(commitLatest, delay);
+ },
+ flush() {
+ if (timer !== null) {
+ cancel(timer);
+ timer = null;
+ }
+ return commitLatest();
+ },
+ cancel() {
+ if (timer !== null) cancel(timer);
+ timer = null;
+ dirty = false;
+ },
+ latest() {
+ return latest;
+ },
+ };
+}
+/* LIVE_THINKING_THROTTLE_END */
+
const RESEARCH_TIMEOUT_MS = 360000;
const DEFAULT_TIMEOUT_MS = 120000;
const RESEARCH_SVG = '';
@@ -1370,6 +1413,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let _renderStream = () => {};
let _cancelThinkingTimer = () => {};
let _removeThinkingSpinner = () => {};
+ let _flushLiveThinking = () => '';
+ let _cancelLiveThinkingWork = () => {};
let timeoutId = null;
let responseTimeoutCleared = false;
let clearResponseTimeout = () => {};
@@ -2070,6 +2115,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let _liveThinkTokenCount = 0;
let _liveThinkToggle = null;
let _liveThinkDomId = null;
+ let _liveThinkRenderThrottle = null;
+ let _liveThinkLatestText = '';
+ let _liveThinkTimerId = null;
+ let _liveThinkReducedMotion = false;
function _estimateThinkingTokens(text) {
const clean = (text || '').trim();
@@ -2083,6 +2132,102 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
return time && tokens ? time + ' · ' + tokens : (time || tokens);
}
+ function _extractLiveThinkingText(text, preferComplete = false) {
+ const normalized = markdownModule.normalizeThinkingMarkup(_streamDisplayText(text || ''));
+ if (preferComplete && markdownModule.extractThinkingBlocks) {
+ const extracted = markdownModule.extractThinkingBlocks(normalized);
+ if (extracted?.thinkingBlocks?.length) {
+ return extracted.thinkingBlocks[extracted.thinkingBlocks.length - 1];
+ }
+ }
+ let raw = normalized;
+ const open = /<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/i.exec(raw);
+ if (open) raw = raw.slice(open.index + open[0].length);
+ const closeAt = raw.search(/<\/(?:think(?:ing)?|thought)>/i);
+ if (closeAt >= 0) raw = raw.slice(0, closeAt);
+ return raw
+ .replace(/<\|channel>thought\s*\n?/gi, '')
+ .replace(/<\|channel>response\s*\n?/gi, '')
+ .replace(//gi, '')
+ .replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
+ }
+
+ function _commitLiveThinkingText(text) {
+ _liveThinkLatestText = String(text ?? '');
+ _liveThinkTokenCount = _estimateThinkingTokens(_liveThinkLatestText);
+ const target = _liveThinkInner;
+ if (!target || !target.isConnected) return;
+ const thinkBox = target.closest('.thinking-content');
+ const nearBottom = !thinkBox || thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80;
+ target.style.whiteSpace = 'pre-wrap';
+ target.textContent = _liveThinkLatestText;
+ if (thinkBox && nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight;
+ if (nearBottom) uiModule.scrollHistory();
+ }
+
+ function _ensureLiveThinkingThrottle() {
+ if (!_liveThinkRenderThrottle) {
+ _liveThinkRenderThrottle = _createLiveThinkingThrottle(_commitLiveThinkingText, { delay: 100 });
+ }
+ return _liveThinkRenderThrottle;
+ }
+
+ function _stopLiveThinkTimer() {
+ if (_liveThinkTimerId !== null) clearInterval(_liveThinkTimerId);
+ _liveThinkTimerId = null;
+ }
+
+ function _startLiveThinkTimer() {
+ if (_liveThinkTimerId !== null || !_liveThinkTimerEl) return;
+ _liveThinkReducedMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
+ const cadence = _liveThinkReducedMotion ? 1000 : 250;
+ _liveThinkTimerId = setInterval(() => {
+ if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) {
+ _stopLiveThinkTimer();
+ return;
+ }
+ const elapsed = (Date.now() - thinkingStartTime) / 1000;
+ const seconds = elapsed.toFixed(_liveThinkReducedMotion ? 0 : 1);
+ _liveThinkTimerEl.textContent = _formatThinkStats(seconds, _liveThinkTokenCount);
+ }, cadence);
+ }
+
+ function _queueLiveThinking(text) {
+ _liveThinkLatestText = String(text ?? '');
+ _ensureLiveThinkingThrottle().update(_liveThinkLatestText);
+ _startLiveThinkTimer();
+ }
+
+ _flushLiveThinking = ({ text = null, rich = false } = {}) => {
+ if (text !== null) _queueLiveThinking(text);
+ if (_liveThinkRenderThrottle) _liveThinkRenderThrottle.flush();
+ if (rich && _liveThinkInner && _liveThinkInner.isConnected) {
+ _liveThinkInner.style.whiteSpace = '';
+ _liveThinkInner.innerHTML = markdownModule.mdToHtml(_liveThinkLatestText);
+ }
+ return _liveThinkLatestText;
+ };
+
+ _cancelLiveThinkingWork = () => {
+ if (_liveThinkRenderThrottle) _liveThinkRenderThrottle.cancel();
+ _liveThinkRenderThrottle = null;
+ _stopLiveThinkTimer();
+ };
+
+ function _finalizeLiveThinking(text, rich = true) {
+ const finalText = _flushLiveThinking({ text, rich });
+ _cancelLiveThinkingWork();
+ return finalText;
+ }
+
+ function _closeOpenThinkingMarkup() {
+ if (!_thinkOpen) return;
+ accumulated += '';
+ roundText += '';
+ currentAccumulated = accumulated;
+ _thinkOpen = false;
+ }
+
function _replyAfterClosedThinking(text) {
text = markdownModule.normalizeThinkingMarkup(text || '');
const closeRe = /<\/(?:think(?:ing)?|thought)>|/gi;
@@ -2224,6 +2369,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// On first transition to background, store state in map
if (_isBg && !_backgroundStreams.has(streamSessionId)) {
+ _flushLiveThinking({ rich: false });
+ _cancelLiveThinkingWork();
_backgroundStreams.set(streamSessionId, {
status: 'running',
accumulated: accumulated,
@@ -2240,6 +2387,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (data === '[DONE]') {
_streamSawDone = true;
+ _closeOpenThinkingMarkup();
// Always update background map if entry exists (even if user switched back)
var bgDone = _backgroundStreams.get(streamSessionId);
if (bgDone && !_isBg) {
@@ -2270,7 +2418,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Force-close thinking if still open (model never output boundary)
if (isThinking) {
isThinking = false;
- cancelAnimationFrame(_thinkTimerRAF);
+ _finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true);
var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
if (_elapsedDone) {
accumulated = accumulated.replace(//i, '');
@@ -2486,16 +2634,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
_liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot');
_liveThinkTimerEl = thinkContent.querySelector('.live-think-timer');
_liveThinkToggle = thinkContent.querySelector('.live-think-toggle');
- // Live timer
- var _thinkTimerStart = Date.now();
- var _thinkTimerRAF = 0;
- function _tickThinkTimer() {
- if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return;
- var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1);
- _liveThinkTimerEl.textContent = _formatThinkStats(s, _liveThinkTokenCount);
- _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
- }
- _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
+ _liveThinkLatestText = '';
+ _cancelLiveThinkingWork();
+ _queueLiveThinking(_extractLiveThinkingText(roundText));
// Whirlpool spinner
if (_liveThinkSpinnerSlot) {
var _wp = spinnerModule.createWhirlpool(12);
@@ -2506,34 +2647,13 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
_liveThinkSpinnerSlot.appendChild(_wp.element);
}
} else if (hasUnclosedThink && isThinking) {
- if (_liveThinkInner) {
- // Extract raw thinking text (strip known thinking wrappers and prefixes)
- var thinkText = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText))
- .replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
- .replace(/<\|channel>thought\s*\n?/gi, '')
- .replace(/<\|channel>response\s*\n?/gi, '')
- .replace(//gi, '');
- thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
- _liveThinkTokenCount = _estimateThinkingTokens(thinkText);
- _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
- if (_liveThinkTimerEl) {
- var _elapsedLive = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '';
- _liveThinkTimerEl.textContent = _formatThinkStats(_elapsedLive, _liveThinkTokenCount);
- }
- // Keep thinking box scrolled to bottom, but let user scroll up
- var _followThinking = true;
- var thinkBox = _liveThinkInner.closest('.thinking-content');
- if (thinkBox) {
- var nearBottom = thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80;
- if (nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight;
- _followThinking = nearBottom;
- }
- }
- if (_followThinking) uiModule.scrollHistory();
+ _queueLiveThinking(_extractLiveThinkingText(roundText));
continue;
} else if (!hasUnclosedThink && isThinking) {
isThinking = false;
- var _thinkTextLen = _liveThinkInner ? _liveThinkInner.textContent.trim().length : 0;
+ const _closedThinkText = _extractLiveThinkingText(roundText, true);
+ var _thinkTextLen = _closedThinkText.trim().length;
+ _finalizeLiveThinking(_closedThinkText, _thinkTextLen >= 20);
// If thinking was trivially short (< 20 chars), remove the section entirely
// Models sometimes emit The or similar noise
@@ -2557,7 +2677,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Thinking ended — smooth transition: update header, pause, then collapse
// Stop live timer and spinner
- cancelAnimationFrame(_thinkTimerRAF);
var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
// Embed thinking time in the tag for persistence on reload
if (elapsed) {
@@ -2973,13 +3092,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (holder && json.id) holder.dataset.dbId = json.id;
} else if (json.type === 'tool_start') {
+ _closeOpenThinkingMarkup();
if (_isBg) continue;
_cancelThinkingTimer();
_removeThinkingSpinner();
// Force-close thinking if still open — tools are real content, not thinking
if (isThinking) {
isThinking = false;
- cancelAnimationFrame(_thinkTimerRAF);
+ _finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true);
var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _formatThinkStats(_elapsed2, _liveThinkTokenCount) : '';
@@ -3324,9 +3444,20 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (_pu) _setStoredPlan(_pu);
} else if (json.type === 'agent_step') {
+ _closeOpenThinkingMarkup();
if (_isBg) continue;
_cancelThinkingTimer();
_removeThinkingSpinner();
+ if (isThinking) {
+ isThinking = false;
+ _finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true);
+ var _elapsedStep = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
+ if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
+ if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsedStep ? _formatThinkStats(_elapsedStep, _liveThinkTokenCount) : '';
+ if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
+ } else {
+ _cancelLiveThinkingWork();
+ }
_renderStream();
// Mark thread as connected to bubble below
const _activeThread = document.querySelector('.agent-thread.streaming');
@@ -3739,6 +3870,13 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
} // end if (!_isBgFinal)
} catch (err) {
+ _closeOpenThinkingMarkup();
+ if (isThinking) {
+ isThinking = false;
+ _finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true);
+ } else {
+ _cancelLiveThinkingWork();
+ }
_renderStream();
// Clean up any active spinner (e.g. "Generating response" during tool calls)
if (spinner && spinner.element) spinner.destroy();
@@ -3926,6 +4064,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
}
} finally {
+ _cancelLiveThinkingWork();
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
diff --git a/tests/live_thinking_scheduler.test.mjs b/tests/live_thinking_scheduler.test.mjs
new file mode 100644
index 000000000..77e205c2e
--- /dev/null
+++ b/tests/live_thinking_scheduler.test.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+const source = fs.readFileSync(new URL('../static/js/chat.js', import.meta.url), 'utf8');
+const start = source.indexOf('/* LIVE_THINKING_THROTTLE_START */');
+const end = source.indexOf('/* LIVE_THINKING_THROTTLE_END */');
+assert.ok(start >= 0 && end > start, 'live-thinking throttle markers must exist');
+const helperSource = source.slice(start, end) + '\nglobalThis.createThrottle = _createLiveThinkingThrottle;';
+const sandbox = {};
+vm.runInNewContext(helperSource, sandbox);
+const createThrottle = sandbox.createThrottle;
+
+function fakeTimers() {
+ let nextId = 1;
+ const callbacks = new Map();
+ const delays = [];
+ return {
+ schedule(callback, delay) {
+ const id = nextId++;
+ callbacks.set(id, callback);
+ delays.push(delay);
+ return id;
+ },
+ cancel(id) {
+ callbacks.delete(id);
+ },
+ run(id) {
+ const callback = callbacks.get(id);
+ assert.ok(callback, `missing timer ${id}`);
+ callbacks.delete(id);
+ callback();
+ },
+ pendingIds() {
+ return [...callbacks.keys()];
+ },
+ delays,
+ };
+}
+
+test('coalesces a burst and commits only the latest text after 100 ms', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createThrottle((value) => commits.push(value), timers);
+
+ throttle.update('a');
+ throttle.update('ab');
+ throttle.update('abc');
+
+ assert.deepEqual(commits, []);
+ assert.deepEqual(timers.delays, [100]);
+ const [timer] = timers.pendingIds();
+ timers.run(timer);
+ assert.deepEqual(commits, ['abc']);
+});
+
+test('flush synchronously preserves trailing text and cancels the pending callback', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createThrottle((value) => commits.push(value), timers);
+
+ throttle.update('trailing text');
+ assert.equal(throttle.flush(), true);
+ assert.deepEqual(commits, ['trailing text']);
+ assert.deepEqual(timers.pendingIds(), []);
+ assert.equal(throttle.flush(), false, 'clean flush must not duplicate the commit');
+});
+
+test('cancel discards pending work without a late DOM commit', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createThrottle((value) => commits.push(value), timers);
+
+ throttle.update('stale session text');
+ throttle.cancel();
+ assert.deepEqual(timers.pendingIds(), []);
+ assert.deepEqual(commits, []);
+});
diff --git a/tests/test_live_thinking_scheduler_js.py b/tests/test_live_thinking_scheduler_js.py
new file mode 100644
index 000000000..fd0d56f66
--- /dev/null
+++ b/tests/test_live_thinking_scheduler_js.py
@@ -0,0 +1,68 @@
+"""Regression coverage for bounded live-thinking DOM work in chat.js."""
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+_REPO = Path(__file__).resolve().parent.parent
+_CHAT = _REPO / "static/js/chat.js"
+_HAS_NODE = shutil.which("node") is not None
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_live_thinking_scheduler_behavior():
+ result = subprocess.run(
+ ["node", "--test", "tests/live_thinking_scheduler.test.mjs"],
+ cwd=_REPO,
+ capture_output=True,
+ timeout=30,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise AssertionError(
+ f"node --test failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
+ )
+
+
+def test_live_thinking_hot_path_has_no_full_markdown_reparse_or_raf_timer():
+ source = _CHAT.read_text(encoding="utf-8")
+ hot_start = source.index("} else if (hasUnclosedThink && isThinking) {")
+ hot_end = source.index("} else if (!hasUnclosedThink && isThinking) {", hot_start)
+ hot_path = source[hot_start:hot_end]
+
+ assert "_queueLiveThinking(_extractLiveThinkingText(roundText));" in hot_path
+ assert "mdToHtml" not in hot_path
+ assert "innerHTML" not in hot_path
+ assert "requestAnimationFrame(_tickThinkTimer)" not in source
+ assert "_liveThinkReducedMotion ? 1000 : 250" in source
+ assert "target.style.whiteSpace = 'pre-wrap';" in source
+ assert "_liveThinkInner.style.whiteSpace = '';" in source
+
+
+def test_terminal_paths_close_protocol_markup_flush_and_cancel_pending_work():
+ source = _CHAT.read_text(encoding="utf-8")
+
+ helper_start = source.index("function _closeOpenThinkingMarkup()")
+ helper = source[helper_start:source.index("function _replyAfterClosedThinking", helper_start)]
+ assert "accumulated += '';" in helper
+ assert "roundText += '';" in helper
+ assert "currentAccumulated = accumulated;" in helper
+ assert "_thinkOpen = false;" in helper
+
+ assert source.count("_finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true);") >= 3
+ done_start = source.index("if (data === '[DONE]')")
+ assert "_closeOpenThinkingMarkup();" in source[done_start:done_start + 220]
+ for marker in ("} else if (json.type === 'tool_start') {", "} else if (json.type === 'agent_step') {"):
+ start = source.index(marker)
+ block_head = source[start:start + 220]
+ assert block_head.index("_closeOpenThinkingMarkup();") < block_head.index("if (_isBg) continue;")
+
+ catch_start = source.index(" } catch (err) {")
+ catch_block = source[catch_start:source.index(" } finally {", catch_start)]
+ assert "_closeOpenThinkingMarkup();" in catch_block
+ assert "_finalizeLiveThinking(_extractLiveThinkingText(roundText, true), true);" in catch_block
+ assert catch_block.index("_finalizeLiveThinking") < catch_block.index("_renderStream();")
+ finally_block = source[source.index(" } finally {", catch_start):]
+ assert "_cancelLiveThinkingWork();" in finally_block