Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

## [Unreleased]

### Fixed

- Streamed chat markdown now schedules incremental KaTeX rendering while `streaming-markdown` writes response deltas, so math placeholders are rendered during the live turn instead of waiting until the final `done` event. Refs #2699.

## [v0.51.104] — 2026-05-21 — Release CB (stage-397 — 9-PR batch — i18n zh-CN/zh-TW cron status + geist-contrast skin polish + tablet hardware Enter + stale Codex slash model state + SSE reconnect jitter + cron run inline expansion + inflight send race + new-chat model provider sync + virtualized sidebar scroll-clamp resync + transcript cache invalidation on same-count content)

Expand Down
10 changes: 10 additions & 0 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let _smdParser=null; // current smd parser instance (null until first content)
let _smdWrittenLen=0; // how many chars of displayText have been fed to smd parser
let _smdWrittenText=''; // exact displayText snapshot used for prefix-alignment checks
let _streamingKatexTimer=null; // throttles live KaTeX scans while smd writes deltas
// On reconnect, the assistantBody already has partial smd-rendered content.
// We clear it on first new token and restart the parser from the reconnect point.
let _smdReconnect=reconnecting;
Expand Down Expand Up @@ -940,6 +941,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
// Helper: end the current smd parser (flushes remaining state) and null it out.
function _smdEndParser(){
if(_streamingKatexTimer){clearTimeout(_streamingKatexTimer);_streamingKatexTimer=null;}
if(_smdParser&&window.smd){
try{window.smd.parser_end(_smdParser);}catch(_){}
// parser_end may flush remaining markdown that creates new links/images —
Expand All @@ -950,6 +952,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdWrittenLen=0;
_smdWrittenText='';
}
function _scheduleStreamingKatex(){
if(_streamingKatexTimer) return;
_streamingKatexTimer=setTimeout(()=>{
_streamingKatexTimer=null;
if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody);
},150);
}
// Helper: feed new displayText delta to the smd parser.
// Only feeds chars beyond what has already been written (_smdWrittenLen).
function _smdWrite(displayText, fade=false){
Expand All @@ -974,6 +983,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// streaming-markdown does NOT sanitize URL schemes. The default live path
// scans after writes; fade mode blocks unsafe href/src in its renderer.set_attr.
if(assistantBody&&!fade){_sanitizeSmdLinks(assistantBody);}
_scheduleStreamingKatex();
}
// Allowed URL schemes for anchors and images rendered from agent-streamed markdown.
// Raw file:// anchors are rewritten to /api/media before the user can click them.
Expand Down
8 changes: 6 additions & 2 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -7228,7 +7228,10 @@ let _katexReady=false;

function renderKatexBlocks(container){
const root=container||document;
const blocks=root.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
const blocks=root.querySelectorAll(
'.katex-block:not([data-rendered]),.katex-inline:not([data-rendered]),'+
'equation-block:not([data-rendered]),equation-inline:not([data-rendered])'
);
if(!blocks.length) return;
if(!_katexReady){
if(!_katexLoading){
Expand All @@ -7250,7 +7253,8 @@ function renderKatexBlocks(container){
blocks.forEach(el=>{
el.dataset.rendered='true';
const src=el.textContent||'';
const displayMode=el.dataset.katex==='display';
const tagName=(el.tagName||'').toLowerCase();
const displayMode=el.dataset.katex==='display'||tagName==='equation-block';
try{
katex.render(src,el,{
displayMode,
Expand Down
36 changes: 36 additions & 0 deletions tests/test_streaming_katex_live_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from pathlib import Path


REPO = Path(__file__).resolve().parents[1]
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")


def test_live_smd_writes_schedule_incremental_katex_rendering():
"""Live markdown deltas should render math before the terminal done event."""
assert "let _streamingKatexTimer=null" in MESSAGES_JS
assert "function _scheduleStreamingKatex()" in MESSAGES_JS
assert "setTimeout(()=>{" in MESSAGES_JS
assert "renderKatexBlocks(assistantBody)" in MESSAGES_JS

smd_write_idx = MESSAGES_JS.index("function _smdWrite(displayText, fade=false){")
done_idx = MESSAGES_JS.index("source.addEventListener('done'")
smd_write_block = MESSAGES_JS[smd_write_idx:done_idx]
assert "_scheduleStreamingKatex();" in smd_write_block


def test_streaming_katex_timer_is_cleared_when_smd_parser_ends():
"""The final done path should not leave a stale live KaTeX timer around."""
end_idx = MESSAGES_JS.index("function _smdEndParser(){")
write_idx = MESSAGES_JS.index("function _smdWrite(displayText, fade=false){")
end_block = MESSAGES_JS[end_idx:write_idx]
assert "if(_streamingKatexTimer){clearTimeout(_streamingKatexTimer);_streamingKatexTimer=null;}" in end_block


def test_katex_renderer_scans_live_and_settled_unrendered_nodes_under_container():
assert "function renderKatexBlocks(container){" in UI_JS
assert "const root=container||document;" in UI_JS
assert ".katex-block:not([data-rendered]),.katex-inline:not([data-rendered])," in UI_JS
assert "equation-block:not([data-rendered]),equation-inline:not([data-rendered])" in UI_JS
assert "const tagName=(el.tagName||'').toLowerCase();" in UI_JS
assert "const displayMode=el.dataset.katex==='display'||tagName==='equation-block';" in UI_JS
Loading