feat: the wait is a list of steps, ticked by the stream's own events
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:
parent
fa9970458f
commit
d94dc0e357
3 changed files with 125 additions and 5 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue