fix: the unfinished block streams as markdown, and a deployment's JS is never an hour stale
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 16s

The tail — the block still arriving — was shown as raw text until its blank
line came, which is the flash of asterisks, pipes and brackets the reader
saw on every paragraph. It is now parsed each frame like the finished part
(Open WebUI never shows the flash because it re-renders the whole message
per token; ours re-renders one block). Two exceptions stay text: a block
inside an unclosed code fence, so a half-written diagram is not handed to
its renderer every frame, and a table header with no body row yet.

JS, CSS and component HTML were cached for an hour, and the assistant's ES
module imports carry no version query, so every open browser kept the old
citation renderer for an hour after the deploy that replaced it. They now
revalidate on every load (no-cache with the ETag), which is a 304.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 05:10:12 +02:00
parent 07161c6fa8
commit cb5bce13e3
3 changed files with 50 additions and 27 deletions

View file

@ -691,23 +691,28 @@ import {
renderEmbeddedBlocks(state.settled);
}
var tail = text.slice(state.consumed);
// The unfinished block is rendered as markdown too, re-parsed each frame.
// It used to be shown as raw text until its blank line arrived, which is
// the flash of asterisks and pipes the reader sees on every paragraph —
// Open WebUI never shows it because it re-renders the whole message on
// every token. The tail is one block, so re-parsing it costs nothing.
//
// A table gets its rows completed first (partialTableMarkdown): rows that
// arrive next have no header of their own and cannot parse alone. A block
// inside an unclosed code fence stays text: a half-written mermaid or
// chart would be handed to its renderer on every frame and fail there.
var growing = partialTableMarkdown(tail);
if (growing) {
// A table is the one block worth rendering before it is finished, and
// the one the blank-line rule serves worst: a table contains no blank
// lines, so the whole of it stays raw until the line *after* it arrives,
// and then snaps into place. That is the markdown flash, on exactly the
// content where it is most obvious.
//
// Re-rendered each frame rather than appended: the rows that arrive next
// have no header of their own, so they cannot be parsed as a separate
// chunk. The tail is small, so re-parsing it is cheap.
state.tail.className = 'assistant-stream-partial';
state.tail.innerHTML = renderAssistantBubbleHtml(growing, sources, false);
var openFence = (tail.match(/```/g) || []).length % 2 === 1;
// A header row with nothing under it yet is a table markdown-it would
// happily draw — two lines of pipes and an empty body. Not yet.
var headerOnly = !growing && TABLE_ROW.test(tail.trim().split('\n')[0] || '');
if (tail && !openFence && !headerOnly) {
state.tail.className = 'assistant-stream-tail assistant-stream-partial';
state.tail.innerHTML = renderAssistantBubbleHtml(growing || tail, sources, false);
renderEmbeddedBlocks(state.tail);
state.tail.hidden = false;
} else {
state.tail.className = 'assistant-streaming-text';
state.tail.className = 'assistant-stream-tail assistant-streaming-text';
state.tail.textContent = tail;
state.tail.hidden = !tail;
}

View file

@ -253,12 +253,14 @@ app.use(express.static(path.join(__dirname, 'public'), {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
} else if (filePath.match(/\.(js|css)$/)) {
// Short cache for JS/CSS — 1 hour
res.setHeader('Cache-Control', 'public, max-age=3600');
} else if (filePath.indexOf('/components/') !== -1) {
// Component HTML — 1 hour cache
res.setHeader('Cache-Control', 'public, max-age=3600');
} else if (filePath.match(/\.(js|css)$/) || filePath.indexOf('/components/') !== -1) {
// Revalidate every time, never serve stale. ES module imports
// (clinicalAssistant.js → ./assistant/citations.js) carry no version
// query, so the hour of max-age this used to set meant a deployment's
// renderer sat behind the old one in every open browser for an hour —
// "not fixed" from a reader who had just watched it deploy. no-cache
// keeps the ETag round-trip (a 304, a few bytes) and drops the wait.
res.setHeader('Cache-Control', 'no-cache');
}
}
}));

View file

@ -116,14 +116,14 @@ test('the search resumes from what was already consumed', (t) => {
// ---- what lands in the bubble ----------------------------------------------
test('the settled part is real markdown while the tail is still plain text', (t) => {
test('the settled part is real markdown and the tail is markdown too, short of a table row', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE + '\n\n| Half | tab', []);
const settled = bubble.querySelector('.assistant-stream-settled');
assert.ok(settled.querySelector('table'), 'the finished table should be a table');
assert.equal(settled.querySelectorAll('table tbody tr').length, 2);
const tail = bubble.querySelector('.assistant-streaming-text');
const tail = bubble.querySelector('.assistant-stream-tail');
assert.equal(tail.textContent.trim(), '| Half | tab');
assert.equal(tail.querySelector('table'), null, 'the unfinished row must not be parsed');
});
@ -138,7 +138,7 @@ test('no raw-text <pre> dump, however long or table-heavy the answer', (t) => {
ctx.renderStreamingInto(bubble, long, []);
assert.ok(bubble.querySelectorAll('table').length >= 20);
assert.equal(bubble.querySelector('pre'), null, 'nothing should fall back to a <pre> dump');
assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'Trailing sen');
assert.equal(bubble.querySelector('.assistant-stream-tail').textContent.trim(), 'Trailing sen');
});
test('settled content is appended, never re-rendered', (t) => {
@ -150,7 +150,7 @@ test('settled content is appended, never re-rendered', (t) => {
ctx.renderStreamingInto(bubble, 'First.\n\nSecond.\n\nThird stil', []);
assert.equal(settled.firstChild, firstNode, 'the first block should be the same node, untouched');
assert.equal(settled.children.length, 2);
assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'Third stil');
assert.equal(bubble.querySelector('.assistant-stream-tail').textContent.trim(), 'Third stil');
});
test('a diagram that has drawn is not torn down by the next block', async (t) => {
@ -266,13 +266,29 @@ test('the finished table settles, and is not left rendered twice', (t) => {
assert.equal(bubble.querySelector('.assistant-stream-settled').querySelectorAll('table').length, 1);
});
test('ordinary text still streams as text', (t) => {
// The table rule must not swallow the plain case.
test('the unfinished paragraph is rendered, not shown as raw markdown', (t) => {
// The flash the reader used to see: "**Severe:** seizures [8]" as literal
// asterisks and brackets until the paragraph's blank line arrived.
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'First.\n\nA sentence still bei', []);
ctx.renderStreamingInto(bubble, 'First.\n\n**Severe:** a sentence [1] still bei', [{ number: 1, title: 'S' }]);
assert.equal(bubble.querySelector('table'), null);
assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'A sentence still bei');
const tail = bubble.querySelector('.assistant-stream-tail');
assert.ok(tail.querySelector('strong'), 'bold is bold while the paragraph is still arriving');
assert.ok(tail.querySelector('a.assistant-cite'), 'a citation is a chip while the paragraph is still arriving');
assert.equal(tail.textContent.trim(), 'Severe: a sentence 1 still bei');
});
test('a block inside an unclosed code fence stays text until the fence closes', (t) => {
// A half-written mermaid diagram handed to its renderer every frame would
// fail every frame; monospaced text is what the reader expects there anyway.
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'First.\n\n```mermaid\ngraph TD\n A --> B', []);
const tail = bubble.querySelector('.assistant-stream-tail');
assert.ok(tail.classList.contains('assistant-streaming-text'));
assert.equal(tail.textContent, '```mermaid\ngraph TD\n A --> B');
assert.equal(tail.querySelector('pre, .mermaid'), null);
});
// ---- what is still happening --------------------------------------------