feat: a streaming table renders as a table, and the wait says what it is doing

Measured against Open WebUI on the same question, sampling every 250ms:
it never showed a raw pipe. A real <table> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 01:55:09 +02:00
parent c14fa25c3f
commit d33d355627
3 changed files with 207 additions and 5 deletions

View file

@ -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; }

View file

@ -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');
}
/**

View file

@ -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 <table> 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/);
});