diff --git a/public/css/assistant.css b/public/css/assistant.css index fc25d0c1..0c11fcfa 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -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; } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 75aff3df..f9bf299b 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -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 = ''; + 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 = '
' + escapeHtml(title || 'Working') + '
' + - '
' + escapeHtml(detail || 'Preparing response...') + '
'; + '
    '; 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; diff --git a/test/assistant-streaming-blocks.test.js b/test/assistant-streaming-blocks.test.js index 2d51c100..8b017889 100644 --- a/test/assistant-streaming-blocks.test.js +++ b/test/assistant-streaming-blocks.test.js @@ -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/); +});