From d33d355627a9e1c9c662f0afb9a35e1f4fa8fe73 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 01:55:09 +0200 Subject: [PATCH] feat: a streaming table renders as a table, and the wait says what it is doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against Open WebUI on the same question, sampling every 250ms: it never showed a raw pipe. A real appeared with 2 rows at t+3.25s and grew to 4, then 6, as they arrived. The blank-line rule alone cannot do that, and a table is the case it serves worst: a markdown table contains no blank line, so the whole of it stayed in the tail as plain text until the line *after* it landed, then snapped into place. That is the markdown flash, on exactly the content where it shows most. So a table is now rendered while it is still arriving: once there is a header, the |---| rule and one body row, the tail is rendered as markdown rather than held as text, and every complete row that follows joins it. A half-typed row is left out and appears a frame later, which is what makes the table grow a row at a time. Re-rendered each frame rather than appended, unlike a settled block: the rows arriving next carry no header of their own, so they cannot be parsed as a separate chunk. The tail is small, so the cost is small. The status was the other half of that trace: Open WebUI keeps a skeleton beside the streaming content until the answer is done. Ours removed the status the instant the first token landed — the moment it becomes most useful, because the answer is arriving *and* the assistant is still working, drawing a figure or completing a cut-off reply. It now sits above the partial answer, shimmering, until the final render replaces the bubble. And the wait itself says what is happening. Retrieval finishes before the stream opens — deliberately, so a bad request still returns an error rather than a stream — which makes that first line the only thing a reader has during the slowest part. "Retrieving and synthesizing references" described the software; "Searching the clinical library" describes the work. Mutation-tested: dropping the table rule, rendering the incomplete trailing row, showing a table with no body row, or failing to restore the status each frame all fail a test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- public/css/assistant.css | 14 +++ public/js/clinicalAssistant.js | 87 +++++++++++++++++-- test/assistant-streaming-blocks.test.js | 111 ++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 5 deletions(-) diff --git a/public/css/assistant.css b/public/css/assistant.css index 429cefd2..fc25d0c1 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -182,6 +182,20 @@ dark flash people saw while a table streamed. pre-wrap so the line breaks of a half-finished table are visible rather than collapsed into one run. */ .assistant-streaming-text { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; color:var(--g700); } +/* A table still arriving. Rendered as a real table from its first complete row + — the block the blank-line rule served worst, because a table has no blank + line inside it and so stayed raw until the line after it landed. */ +.assistant-stream-partial { margin:0; } +/* What is still happening, above the answer rather than instead of it. The + shimmer is the same one the thinking bubble uses, so the two read as one + state rather than two. */ +.assistant-stream-status { + margin:0 0 8px; font-size:12px; color:var(--g500); + background:linear-gradient(90deg,var(--g100),var(--g50),var(--g100)); + background-size:220% 100%; animation:assistantShimmer 1.8s ease-in-out infinite; + border-radius:6px; padding:4px 8px; display:inline-block; +} +@media (prefers-reduced-motion: reduce) { .assistant-stream-status { animation:none; } } .assistant-stream-settled:not(:empty) + .assistant-streaming-text:not([hidden]) { margin-top:10px; } .assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; } .assistant-source-modal { position:fixed; inset:0; z-index:10000; background:rgba(15,23,42,.72); display:flex; align-items:center; justify-content:center; padding:24px; } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 39184705..824943f8 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -453,7 +453,11 @@ import { regenerateMode = false; // consumed by this request activeAssistantRequest = request; setBusy(true, 'Looking up sources...'); - var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...'); + // Retrieval finishes before the stream opens — deliberately, so a bad + // request still returns an error rather than a stream — which means this + // line is the only thing the reader has during the slowest part of the + // wait. It should name what is happening, not describe the software. + var loading = appendLoadingMessage('Searching the clinical library', 'Looking for sources that answer this…'); request.loading = loading; request.accept = function() { if (request.accepted || request.cancelled) return; @@ -519,6 +523,7 @@ import { if (request && request.accept) request.accept(); var partial = ''; + var streamStatus = ''; var streamSources = []; var finalData = null; var lastRender = 0; @@ -535,13 +540,20 @@ import { bubble.classList.remove('assistant-thinking'); bubble.assistantSources = streamSources; renderStreamingInto(bubble, partial, streamSources); + // The status used to vanish the instant the first token landed, which is + // the moment it becomes most useful: the answer is arriving *and* the + // assistant is still working — drawing a figure, completing a cut-off + // reply. It now sits above the partial answer until the answer is done. + showStreamStatus(bubble, streamStatus); var wrap = document.getElementById('assistant-messages'); if (wrap) wrap.scrollTop = wrap.scrollHeight; } function handleEvent(type, data) { if (type === 'status') { - updateLoadingMessage(loading, data.message || 'Working...'); + streamStatus = data.message || 'Working...'; + updateLoadingMessage(loading, streamStatus); + if (bubble) showStreamStatus(bubble, streamStatus); return; } if (type === 'sources') { @@ -621,7 +633,7 @@ import { bubble.innerHTML = ''; var state = { settled: document.createElement('div'), - tail: document.createElement('p'), + tail: document.createElement('div'), consumed: 0 }; state.settled.className = 'assistant-stream-settled'; @@ -632,6 +644,26 @@ import { return state; } + /** + * A shimmering line above the partial answer saying what is still happening. + * + * Its own node at the top of the bubble rather than part of the streamed + * content, so it can be replaced on every status without disturbing a word of + * the answer, and removed at the end without leaving a gap. The final render + * rebuilds the bubble, which takes it with it. + */ + function showStreamStatus(bubble, message) { + var strip = bubble.querySelector('.assistant-stream-status'); + if (!message) { if (strip) strip.remove(); return; } + if (!strip) { + strip = document.createElement('div'); + strip.className = 'assistant-stream-status'; + strip.setAttribute('role', 'status'); + bubble.insertBefore(strip, bubble.firstChild); + } + strip.textContent = message; + } + function renderStreamingInto(bubble, text, sources) { if (!text) { bubble.assistantStreamState = null; @@ -651,8 +683,53 @@ import { renderEmbeddedBlocks(state.settled); } var tail = text.slice(state.consumed); - state.tail.textContent = tail; - state.tail.hidden = !tail; + 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); + renderEmbeddedBlocks(state.tail); + state.tail.hidden = false; + } else { + state.tail.className = 'assistant-streaming-text'; + state.tail.textContent = tail; + state.tail.hidden = !tail; + } + } + + var TABLE_ROW = /^\s*\|.*\|\s*$/; + var TABLE_RULE = /^\s*\|[\s:|-]*-{2,}[\s:|-]*\|\s*$/; + + /** + * The part of an unfinished tail that is already a readable table, or ''. + * + * A markdown table is parseable from the moment it has a header row, the + * |---| rule beneath it, and one body row — every complete row after that + * only adds to it. So the answer is the tail up to its last *complete* row: a + * half-typed row is left out and appears a frame later, which is what makes + * the table grow a row at a time instead of arriving all at once. + */ + function partialTableMarkdown(tail) { + var lines = String(tail).split('\n'); + var rule = -1; + for (var i = 1; i < lines.length; i++) { + if (TABLE_RULE.test(lines[i]) && TABLE_ROW.test(lines[i - 1])) { rule = i; break; } + } + if (rule === -1) return ''; // no header and rule yet + var last = -1; + for (var j = rule + 1; j < lines.length; j++) { + if (TABLE_ROW.test(lines[j])) last = j; + } + if (last === -1) return ''; // a rule but no body row: nothing to show yet + return lines.slice(0, last + 1).join('\n'); } /** diff --git a/test/assistant-streaming-blocks.test.js b/test/assistant-streaming-blocks.test.js index 819a94e5..20f69e2b 100644 --- a/test/assistant-streaming-blocks.test.js +++ b/test/assistant-streaming-blocks.test.js @@ -205,3 +205,114 @@ test('the tail is styled as prose, not left to the browser monospace default', ( assert.match(css, /\.assistant-streaming-text \{[^}]*white-space:pre-wrap/); assert.match(css, /\.assistant-streaming-text \{[^}]*color:var\(--g700\)/); }); + +// ---- a table, rendered while it is still arriving --------------------------- +// +// Measured against Open WebUI on the same question, sampling every 250ms: it +// never showed a raw pipe, because it rendered a real
with 2 rows at +// t+3.25s and grew it to 4 then 6 as they arrived. +// +// The blank-line rule alone cannot do that. A markdown table contains no blank +// line, so the whole of it stayed in the tail as plain text until the line +// *after* it landed — the markdown flash, on the content where it shows most. + +const TABLE_HEAD = '| Drug | Dose |\n| --- | --- |'; + +test('a table renders from its first complete row, not when it finishes', (t) => { + const ctx = ui(t); + const bubble = bubbleFor(ctx); + ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |', []); + const table = bubble.querySelector('table'); + assert.ok(table, 'an unfinished table should already be a table'); + assert.equal(bubble.querySelectorAll('tbody tr').length, 1); + assert.doesNotMatch(bubble.textContent, /\|\s*---/, 'no raw pipe syntax on screen'); +}); + +test('rows appear as they arrive', (t) => { + const ctx = ui(t); + const bubble = bubbleFor(ctx); + const rows = ['| Adrenaline | 10 mcg/kg |', '| Amiodarone | 5 mg/kg |', '| Atropine | 20 mcg/kg |']; + const counts = []; + for (let i = 1; i <= rows.length; i++) { + ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD + '\n' + rows.slice(0, i).join('\n'), []); + counts.push(bubble.querySelectorAll('tbody tr').length); + } + assert.deepEqual(counts, [1, 2, 3], 'the table should grow a row at a time'); +}); + +test('a half-typed row waits rather than rendering as half a row', (t) => { + const ctx = ui(t); + const bubble = bubbleFor(ctx); + ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |\n| Amiod', []); + assert.equal(bubble.querySelectorAll('tbody tr').length, 1, 'only the complete row is shown'); + assert.doesNotMatch(bubble.textContent, /Amiod$/); +}); + +test('a header with no body row yet is not yet a table', (t) => { + // Two lines of pipes and nothing under them is not something to show. + const ctx = ui(t); + const bubble = bubbleFor(ctx); + ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD, []); + assert.equal(bubble.querySelector('table'), null); +}); + +test('the finished table settles, and is not left rendered twice', (t) => { + const ctx = ui(t); + const bubble = bubbleFor(ctx); + ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |', []); + ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |\n\nAfter the table.\n\nStill go', []); + assert.equal(bubble.querySelectorAll('table').length, 1, 'one table, not one settled and one in the tail'); + 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. + const ctx = ui(t); + const bubble = bubbleFor(ctx); + ctx.renderStreamingInto(bubble, 'First.\n\nA sentence still bei', []); + assert.equal(bubble.querySelector('table'), null); + assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'A sentence still bei'); +}); + +// ---- what is still happening -------------------------------------------- + +test('the status stays above the answer while it streams', (t) => { + // It used to be removed the moment the first token landed, which is when it + // becomes most useful: the answer is arriving and the assistant is still + // working. + const ctx = ui(t); + const bubble = bubbleFor(ctx); + ctx.showStreamStatus(bubble, 'Generating image…'); + ctx.renderStreamingInto(bubble, 'An answer in progress', []); + ctx.showStreamStatus(bubble, 'Generating image…'); + const strip = bubble.querySelector('.assistant-stream-status'); + assert.ok(strip); + assert.equal(strip.textContent, 'Generating image…'); + assert.equal(bubble.firstChild, strip, 'it sits above the answer, not inside it'); +}); + +test('every render re-asserts the status, so it survives the whole stream', (t) => { + // Calling showStreamStatus in a test proves the function works, not that the + // stream uses it. renderProvisional sets the bubble's contents each frame, so + // it has to put the strip back or the strip only ever lives for one frame. + const src = read('public/js/clinicalAssistant.js'); + const fn = src.slice(src.indexOf('function renderProvisional'), src.indexOf('function handleEvent')); + assert.match(fn, /renderStreamingInto\(bubble, partial, streamSources\);[\s\S]{0,400}showStreamStatus\(bubble, streamStatus\)/, + 'renderProvisional does not restore the status strip after rendering'); + // And a status event updates it immediately rather than waiting for a token. + assert.match(src, /streamStatus = data\.message[\s\S]{0,160}showStreamStatus\(bubble, streamStatus\)/); +}); + +test('an empty status removes the strip rather than leaving a gap', (t) => { + const ctx = ui(t); + const bubble = bubbleFor(ctx); + ctx.showStreamStatus(bubble, 'Working…'); + ctx.showStreamStatus(bubble, ''); + assert.equal(bubble.querySelector('.assistant-stream-status'), null); +}); + +test('the wait names what is happening, not what the software is doing', (t) => { + const ui_ = read('public/js/clinicalAssistant.js'); + assert.match(ui_, /appendLoadingMessage\('Searching the clinical library'/); + assert.doesNotMatch(ui_, /Retrieving and synthesizing references/); +});