feat: the wait is a list of steps, ticked by the stream's own events
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 17s

One line of changing text is now a list the reader can follow: Analyzing
the question → Searching the clinical library → Found 7 sources → Writing
the answer, plus any step the server reports (looking at an image, drawing
one, completing a cut-off reply). Steps are added when the stream says work
began and ticked when it says it ended, so the list is a record of the real
work, not an animation on a timer; nothing delays the answer.

The one hand-off the server cannot signal — retrieval runs before the stream
opens — is done by the stylesheet, not a JS timer, so the autosave debounce
keeps its clock to itself.

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:48:14 +02:00
parent fa9970458f
commit d94dc0e357
3 changed files with 125 additions and 5 deletions

View file

@ -88,6 +88,36 @@
.assistant-thinking-dot { width:7px; height:7px; border-radius:50%; background:var(--blue); display:inline-block; animation:assistantBounce 1.2s infinite ease-in-out; }
.assistant-thinking-dot:nth-child(2) { animation-delay:.15s; }
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; }
/* The steps under the thinking line: a rail, a mark per step, the one in
progress spinning, the finished ones ticked. Added and ticked by the
stream's own events see updateLoadingMessage. */
.assistant-progress { list-style:none; margin:8px 0 0; padding:0 0 0 2px; display:grid; gap:7px; font-size:12.5px; color:var(--g600); }
.assistant-progress-step { display:flex; align-items:flex-start; gap:9px; line-height:1.35; position:relative; }
.assistant-progress-step + .assistant-progress-step::before { content:""; position:absolute; left:6px; top:-8px; width:2px; height:9px; background:var(--g200); }
.assistant-progress-mark { flex:none; width:14px; height:14px; margin-top:1px; border-radius:50%; border:2px solid var(--g300); box-sizing:border-box; background:white; }
.assistant-progress-step.is-active { color:var(--g800); }
.assistant-progress-step.is-active .assistant-progress-mark { border-color:var(--blue-light); border-top-color:var(--blue); animation:spin 0.9s linear infinite; }
.assistant-progress-step.is-done { color:var(--g500); }
.assistant-progress-step.is-done .assistant-progress-mark { border-color:var(--green); background:var(--green); }
.assistant-progress-step.is-done .assistant-progress-mark::after { content:""; display:block; width:3px; height:6px; margin:1px 0 0 3px; border:solid white; border-width:0 2px 2px 0; transform:rotate(45deg); }
/* The hand-off from reading the question to searching, in the stylesheet: the
brief step spins for under a second and settles into a tick; the queued one
waits that long looking pending, then starts spinning. Real events replace
both classes with is-done, which wins. */
.assistant-progress-step.is-brief .assistant-progress-mark { animation:spin 0.9s linear 1, assistantProgressSettle 0.01s linear 0.9s forwards; }
.assistant-progress-step.is-brief .assistant-progress-mark::after { content:""; display:block; width:3px; height:6px; margin:1px 0 0 3px; border:solid white; border-width:0 2px 2px 0; transform:rotate(45deg); opacity:0; animation:assistantProgressShow 0.01s linear 0.9s forwards; }
.assistant-progress-step.is-brief { animation:assistantProgressDim 0.01s linear 0.9s forwards; }
.assistant-progress-step.is-queued .assistant-progress-mark { border-color:var(--g300); animation:assistantProgressStart 0.01s linear 0.9s forwards, spin 0.9s linear 0.9s infinite; }
.assistant-progress-step.is-queued { color:var(--g600); animation:assistantProgressLift 0.01s linear 0.9s forwards; }
.assistant-progress-step.is-done.is-brief .assistant-progress-mark, .assistant-progress-step.is-done.is-queued .assistant-progress-mark { animation:none; }
.assistant-progress-step.is-done.is-brief .assistant-progress-mark::after { opacity:1; animation:none; }
.assistant-progress-step.is-done.is-brief, .assistant-progress-step.is-done.is-queued { animation:none; }
@keyframes assistantProgressSettle { to { border-color:var(--green); background:var(--green); } }
@keyframes assistantProgressShow { to { opacity:1; } }
@keyframes assistantProgressDim { to { color:var(--g500); } }
@keyframes assistantProgressStart { to { border-color:var(--blue-light); border-top-color:var(--blue); } }
@keyframes assistantProgressLift { to { color:var(--g800); } }
@media (prefers-reduced-motion: reduce) { .assistant-progress-step.is-active .assistant-progress-mark { animation:none; border-top-color:var(--blue); } }
@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } }
@keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } }
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 6px; margin:0 1px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:10px; font-weight:800; text-decoration:none; vertical-align:baseline; border:1px solid rgba(124,58,237,.18); text-transform:uppercase; letter-spacing:.03em; }

View file

@ -558,6 +558,7 @@ import {
}
if (type === 'sources') {
streamSources = data.sources || [];
progressSources(loading, streamSources.length);
renderSources(streamSources);
return;
}
@ -822,10 +823,65 @@ import {
return data;
}
function updateLoadingMessage(row, detail) {
if (!row) return;
var el = row.querySelector('.assistant-thinking-detail');
if (el) el.textContent = detail;
// ── What is happening, as steps ────────────────────────────────────
// The wait used to be one line of text that changed. It is now a list the
// reader can follow: each step is added when the server says it has begun
// and ticked when it says it is over, so the list is a record of the real
// work, not an animation on a timer. Nothing here delays the answer: the
// stream is unchanged, this only draws what it already reports.
var PROGRESS_STEPS = {
// The server's status messages, mapped to what they mean for the list.
'Sources checked; preparing answer...': { done: ['analyze', 'search'] },
'Generating answer...': { done: ['analyze', 'search'], active: ['write', 'Writing the answer'] }
};
function progressStep(row, key, label, state) {
var list = row && row.querySelector('.assistant-progress');
if (!list) return null;
var item = list.querySelector('[data-step="' + key + '"]');
if (!item) {
item = document.createElement('li');
item.className = 'assistant-progress-step';
item.dataset.step = key;
item.innerHTML = '<span class="assistant-progress-mark" aria-hidden="true"></span><span class="assistant-progress-label"></span>';
list.appendChild(item);
}
if (label) item.querySelector('.assistant-progress-label').textContent = label;
if (state) {
item.classList.toggle('is-active', state === 'active');
item.classList.toggle('is-done', state === 'done');
}
return item;
}
function finishActiveSteps(row) {
var list = row && row.querySelector('.assistant-progress');
if (!list) return;
list.querySelectorAll('.assistant-progress-step.is-active').forEach(function (item) {
item.classList.remove('is-active');
item.classList.add('is-done');
});
}
// A status from the server. Known ones move the fixed steps; anything else
// ("Looking at your image…", "Generating image…", "Completing answer...")
// becomes a step of its own, and whatever was running is ticked.
function updateLoadingMessage(row, message) {
if (!row || !message) return;
var known = PROGRESS_STEPS[message];
if (known) {
(known.done || []).forEach(function (key) { progressStep(row, key, null, 'done'); });
if (known.active) { finishActiveSteps(row); progressStep(row, known.active[0], known.active[1], 'active'); }
return;
}
finishActiveSteps(row);
progressStep(row, message, message, 'active');
}
// The search is over the moment the sources arrive; the count is the result.
function progressSources(row, count) {
progressStep(row, 'analyze', null, 'done');
progressStep(row, 'search', count > 0 ? 'Found ' + count + ' source' + (count === 1 ? '' : 's') : 'Searched the clinical library', 'done');
}
function appendLoadingMessage(title, detail) {
@ -841,9 +897,17 @@ import {
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble assistant-thinking';
bubble.innerHTML = '<div class="assistant-thinking-line"><span class="assistant-thinking-dot"></span><span class="assistant-thinking-dot"></span><span class="assistant-thinking-dot"></span><strong>' + escapeHtml(title || 'Working') + '</strong></div>' +
'<div class="assistant-thinking-detail">' + escapeHtml(detail || 'Preparing response...') + '</div>';
'<ol class="assistant-progress" aria-live="polite" aria-label="' + escapeAttr(detail || 'Progress') + '"></ol>';
row.appendChild(label);
row.appendChild(bubble);
// Retrieval runs before the stream opens, so the server cannot say when
// reading the question ends and searching begins. Both steps start now;
// the first is marked brief and the second queued, and the stylesheet
// hands over from one to the other — no timer in here, so nothing to
// cancel and nothing for the autosave debounce to share a clock with.
// The real sources event ticks both.
progressStep(row, 'analyze', 'Analyzing the question', 'active').classList.add('is-brief');
progressStep(row, 'search', 'Searching the clinical library', 'active').classList.add('is-queued');
wrap.appendChild(row);
wrap.scrollTop = wrap.scrollHeight;
return row;

View file

@ -333,3 +333,29 @@ test('the wait names what is happening, not what the software is doing', (t) =>
assert.match(ui_, /appendLoadingMessage\('Searching the clinical library'/);
assert.doesNotMatch(ui_, /Retrieving and synthesizing references/);
});
// ---- the steps while the answer is prepared --------------------------------
test('the wait is a list of steps, ticked by what the stream reports', (t) => {
const ctx = ui(t);
const row = ctx.appendLoadingMessage('Searching the clinical library', 'Looking for sources that answer this…');
const steps = () => Array.from(row.querySelectorAll('.assistant-progress-step')).map(li =>
[li.dataset.step, li.textContent.trim(), li.classList.contains('is-active') ? 'active' : li.classList.contains('is-done') ? 'done' : 'pending']);
assert.deepEqual(steps(), [['analyze', 'Analyzing the question', 'active'], ['search', 'Searching the clinical library', 'active']]);
// The hand-off between the two is the stylesheet's, not a timer's.
assert.ok(row.querySelector('[data-step="analyze"]').classList.contains('is-brief'));
assert.ok(row.querySelector('[data-step="search"]').classList.contains('is-queued'));
// The sources event ends the search with its result.
ctx.progressSources(row, 7);
assert.deepEqual(steps().slice(0, 2), [['analyze', 'Analyzing the question', 'done'], ['search', 'Found 7 sources', 'done']]);
// The server's own statuses become steps; the running one is ticked when the next begins.
ctx.updateLoadingMessage(row, 'Generating answer...');
assert.deepEqual(steps()[2], ['write', 'Writing the answer', 'active']);
ctx.updateLoadingMessage(row, 'Generating image…');
assert.deepEqual(steps().slice(2), [['write', 'Writing the answer', 'done'], ['Generating image…', 'Generating image…', 'active']]);
// Nothing in the list is on a JS timer; the stylesheet owns the motion.
assert.match(read('public/css/assistant.css'), /\.assistant-progress-step\.is-active \.assistant-progress-mark \{[^}]*animation:spin/);
});