From cb5bce13e3f908ad9e32e2d093e1a30a8c106604 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 05:10:12 +0200 Subject: [PATCH] fix: the unfinished block streams as markdown, and a deployment's JS is never an hour stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- public/js/clinicalAssistant.js | 31 ++++++++++++++---------- server.js | 14 ++++++----- test/assistant-streaming-blocks.test.js | 32 ++++++++++++++++++------- 3 files changed, 50 insertions(+), 27 deletions(-) diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 27c9e050..75aff3df 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -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; } diff --git a/server.js b/server.js index eef8c57a..7b17087c 100644 --- a/server.js +++ b/server.js @@ -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'); } } })); diff --git a/test/assistant-streaming-blocks.test.js b/test/assistant-streaming-blocks.test.js index 9c0c4b07..2d51c100 100644 --- a/test/assistant-streaming-blocks.test.js +++ b/test/assistant-streaming-blocks.test.js @@ -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
 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 
 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 --------------------------------------------