From 1b1ec81748d54ba4405112f8b45b28bf0f22d4b5 Mon Sep 17 00:00:00 2001 From: Brian Genisio Date: Thu, 6 Aug 2026 16:09:37 -0400 Subject: [PATCH 1/6] fix(a11y): reconcile message rows and add a persistent status region Stop wiping the message list on every render so unchanged rows keep their DOM nodes and keyboard focus. Route stream lifecycle announcements through one role=status element instead of rebuilding live regions with the transcript. Co-authored-by: Cursor --- i18n/en.json | 2 + i18n/es.json | 2 + public/app.css | 13 ++ public/app.js | 455 ++++++++++++++++++++++++--------------- public/index.html | 4 + tests/dom/render.test.js | 49 +++-- 6 files changed, 343 insertions(+), 182 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index f0fa25c..52506bc 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -40,6 +40,8 @@ "Running tool…": "Running tool…", "Still running…": "Still running…", "Response stopped": "Response stopped", + "Cosmo is responding": "Cosmo is responding", + "Response complete": "Response complete", "Regenerate response": "Regenerate response", "Regenerate": "Regenerate", "Copy as Markdown": "Copy as Markdown", diff --git a/i18n/es.json b/i18n/es.json index eb21698..5d3a52b 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -40,6 +40,8 @@ "Running tool…": "Ejecutando herramienta…", "Still running…": "Aún ejecutando…", "Response stopped": "Respuesta detenida", + "Cosmo is responding": "Cosmo está respondiendo", + "Response complete": "Respuesta completa", "Regenerate response": "Regenerar respuesta", "Regenerate": "Regenerar", "Copy as Markdown": "Copiar como Markdown", diff --git a/public/app.css b/public/app.css index 9fa75f9..26022d4 100644 --- a/public/app.css +++ b/public/app.css @@ -620,6 +620,19 @@ body.is-resizing-sidebar { 50% { opacity: 0; } } +/* Visually hidden but available to assistive technology (status live region). */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* Messages container (injected by JS when conversation starts) */ .messages { display: flex; diff --git a/public/app.js b/public/app.js index 1991ae1..105f19a 100644 --- a/public/app.js +++ b/public/app.js @@ -321,14 +321,28 @@ function attachChat(rt) { rt.chat = new OctavusChat({ transport, requestUploadUrls }); rt.unsubscribe = rt.chat.subscribe(() => { const status = rt.chat.status; + const prevStatus = rt.lastStatus; // Track when streaming starts so the elapsed-time indicator is accurate. - if (status === 'streaming' && rt.lastStatus !== 'streaming') { + if (status === 'streaming' && prevStatus !== 'streaming') { rt.streamingStartTime = Date.now(); } else if (status !== 'streaming') { rt.streamingStartTime = null; } + // Short status announcements for AT (A1/A11). Never the transcript itself. + if (rt === active && status !== prevStatus) { + if (status === 'streaming') { + announceChatStatus(t('Cosmo is responding')); + } else if (prevStatus === 'streaming') { + if (rt.stopAnnouncementPending) { + rt.stopAnnouncementPending = false; + } else { + announceChatStatus(t('Response complete')); + } + } + } + // Persistence runs for EVERY runtime, even when it isn't on screen, so a // backgrounded stream keeps saving its tail. Policy: // • Entering streaming → immediate save (durable user message). @@ -350,7 +364,7 @@ function attachChat(rt) { // on each status transition, independent of the save callbacks (which may // be throttled, delayed, or fail). Only on transitions — not every token — // so a full sidebar re-render here stays cheap. - if (status !== rt.lastStatus) renderSidebar(); + if (status !== prevStatus) renderSidebar(); rt.lastStatus = status; }); @@ -1003,9 +1017,25 @@ function getStreamingStatus(parts = []) { return { label: t(label), icon: null, isImage: false }; } +// ── Chat status live region (A1 / A11) ───────────────────────── +// One persistent role="status" outside the message list. Only short +// deliberate strings — never streaming transcript text. +function announceChatStatus(message) { + const el = document.getElementById('chatStatus'); + if (!el) return; + // Clear-then-set so repeated identical strings still announce. + el.textContent = ''; + void el.offsetWidth; + el.textContent = message; +} + // ── Render messages ─────────────────────────────────────────── // `liveMessages` comes from OctavusChat; `restoredMessages` are pre-loaded from disk. // We display restored first, then live so the conversation reads continuously. +// +// Reconciliation (A1/A2): rows are keyed by index and reused when their +// signature is unchanged, so idle re-renders and status ticks do not wipe +// focus or re-announce the whole transcript through the DOM. function renderMessages(liveMessages, status) { const wasPinnedToBottom = isChatNearBottom(); const messages = [...(active?.restoredMessages ?? []).map(storedToDisplayMsg), ...liveMessages]; @@ -1017,10 +1047,13 @@ function renderMessages(liveMessages, status) { chatHistory.appendChild(messagesEl); } - messagesEl.innerHTML = ''; + const sessionId = active?.sessionId ?? ''; + if (messagesEl.dataset.sessionId !== sessionId) { + messagesEl.replaceChildren(); + messagesEl.dataset.sessionId = sessionId; + } const isIdle = status !== 'streaming'; - let userAssistantIdx = 0; const lastAssistantIdx = (() => { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'assistant') return i; @@ -1028,178 +1061,51 @@ function renderMessages(liveMessages, status) { return -1; })(); + /** @type {{ key: string, sig: string, create: () => HTMLElement }[]} */ + const planned = []; + let userAssistantIdx = 0; + for (let i = 0; i < messages.length; i++) { const msg = messages[i]; - const row = document.createElement('div'); - - if (msg.role === 'assistant') { - const rawText = msg.parts.filter((p) => p.type === 'text').map((p) => p.text).join(''); - const fileParts = msg.parts.filter((p) => p.type === 'file'); - const segments = segmentAssistantParts(msg.parts); - const reasoningSegments = segments.filter((seg) => seg.kind === 'reasoning'); - // Join only for resolveAssistantContent's string API; UI keeps one DIV per - // thought so blocks stay delineated (Brian: single section, separate DIVs). - const reasoningFromParts = reasoningSegments.map((seg) => seg.text).join('\n\n'); - const reasoningStreaming = reasoningSegments.some((seg) => seg.streaming); - const resolved = resolveAssistantContent({ - text: rawText, - reasoningFromParts, - reasoningStreaming, - }); - const text = resolved.answer; - const streaming = msg.status === 'streaming'; - // Octavus: one entry per reasoning part. Embedded peel already joins with - // blank lines — split those back into blocks for the same DIV treatment. - const reasoningBlocks = resolved.source === 'octavus' - ? reasoningSegments.map((seg) => seg.text) - : (resolved.reasoning - ? resolved.reasoning.split(/\n\n+/).filter((block) => block.trim().length > 0) - : []); - const hasText = text.trim().length > 0; - const hasReasoning = - reasoningBlocks.some((block) => block.trim().length > 0) - || reasoningStreaming - || resolved.thinkingOpen; - const showThoughts = shouldShowReasoning() && hasReasoning; - const renderedHtml = stripEmojisFromHtml( - stripHeadingLeadDecorationsFromHtml(marked.parse(text)), - ); - const filesHtml = fileParts.map((f) => renderFilePart(f)).join(''); - const thoughtsHtml = showThoughts - ? renderThoughtsBlock(reasoningBlocks, { streaming }) - : ''; - - const isImageGen = isImageGenerationLoading(msg.parts); - - let bodyContent; - if (streaming && !hasText && fileParts.length === 0 && isImageGen) { - bodyContent = renderImagePlaceholderBody(); - } else if (streaming && !hasText && fileParts.length === 0 && !isImageGen) { - bodyContent = ''; - } else if (streaming && fileParts.length === 0) { - bodyContent = `${renderedHtml}`; - } else { - bodyContent = `${renderedHtml}${filesHtml}${streaming ? '' : ''}`; - } - - const streamingStatus = streaming && fileParts.length === 0 ? getStreamingStatus(msg.parts) : null; - const statusHtml = streamingStatus - ? `
${streamingStatus.icon ? `${streamingStatus.icon} ` : ''}${streamingStatus.label}
` - : ''; - - const showThinkingRing = streaming; - const avatarOpen = showThinkingRing - ? `
` - : '
'; - const avatarClose = '
'; - - // Only the last assistant message gets the trailing Cosmo avatar - // (and its thinking animation) — earlier replies don't repeat it. - const trailingHtml = i === lastAssistantIdx ? ` -
- ${avatarOpen} - - ${avatarClose} - ${statusHtml} -
- ` : ''; - - row.className = streaming ? 'message message--ai message--ai--streaming' : 'message message--ai'; - row.innerHTML = ` - ${thoughtsHtml} -
- ${bodyContent} -
- ${trailingHtml} - `; - - if (msg.stopped) { - const stoppedEl = document.createElement('div'); - stoppedEl.className = 'message__stopped body-xsmall'; - stoppedEl.textContent = t('Response stopped'); - row.querySelector('.message__body').appendChild(stoppedEl); - } - - // Hover actions on every assistant message (when idle): regenerate - // and copy-as-markdown. Both use the .button-icon style and reveal on - // hover. On the last message they share the trailing avatar row (12px - // from the avatar); earlier messages get their own row below. - if (!chatConfig.hidePromptControls && isIdle && (hasText || msg.stopped)) { - const actions = document.createElement('div'); - actions.className = 'message__msg-actions'; - - const regenBtn = document.createElement('button'); - regenBtn.type = 'button'; - regenBtn.className = 'button-icon message__hover-btn'; - regenBtn.setAttribute('aria-label', t('Regenerate response')); - regenBtn.title = t('Regenerate'); - regenBtn.innerHTML = REGEN_ICON_SVG; - const capturedIdx = userAssistantIdx; - regenBtn.addEventListener('click', () => regenerateResponse(capturedIdx)); - actions.appendChild(regenBtn); - - if (hasText) { - const copyBtn = document.createElement('button'); - copyBtn.type = 'button'; - copyBtn.className = 'button-icon message__hover-btn'; - copyBtn.setAttribute('aria-label', t('Copy as Markdown')); - copyBtn.title = t('Copy'); - copyBtn.innerHTML = COPY_ICON_SVG; - const markdown = text; - copyBtn.addEventListener('click', () => copyMessageMarkdown(copyBtn, markdown)); - actions.appendChild(copyBtn); - } - - const trailing = row.querySelector('.message__ai-trailing'); - if (trailing) { - trailing.appendChild(actions); - } else { - actions.classList.add('message__msg-actions--standalone'); - row.appendChild(actions); - } - } - } else if (msg.role === 'user') { - const text = msg.parts.filter((p) => p.type === 'text').map((p) => p.text).join(''); - const fileParts = msg.parts.filter((p) => p.type === 'file'); + if (msg.role !== 'user' && msg.role !== 'assistant') continue; + + const idx = userAssistantIdx; + const isLastAssistant = i === lastAssistantIdx; + const sig = messageRowSignature(msg, { isIdle, isLastAssistant, hidePromptControls: !!chatConfig.hidePromptControls }); + const key = String(i); + planned.push({ + key, + sig, + create: () => { + const row = createMessageRow(msg, idx, { isIdle, isLastAssistant }); + row.dataset.msgKey = key; + row.dataset.msgSig = sig; + return row; + }, + }); + userAssistantIdx++; + } - const filesHtml = fileParts.map((f) => renderFilePart(f)).join(''); + for (let i = 0; i < planned.length; i++) { + const { key, sig, create } = planned[i]; + const current = messagesEl.children[i]; - row.className = 'message message--user'; - row.innerHTML = ` -
- ${filesHtml} - ${text ? `
${text}
` : ''} -
- `; + // In-place edit UI is DOM-only state; never tear it down on a re-render. + if (current?.querySelector?.('.message__edit-box')) continue; - if (!chatConfig.hidePromptControls && isIdle && text) { - const actionsEl = document.createElement('div'); - actionsEl.className = 'message__actions message__actions--user'; - - const editBtn = document.createElement('button'); - editBtn.type = 'button'; - editBtn.className = 'button-icon'; - editBtn.setAttribute('aria-label', t('Edit message')); - editBtn.title = t('Edit'); - // Edit icon (local inline SVG) — inherits currentColor. A matching - // stroke fattens the otherwise-thin fill paths. - editBtn.innerHTML = ``; - const capturedIdx = userAssistantIdx; - const capturedText = text; - editBtn.addEventListener('click', () => startEditingMessage(row, capturedIdx, capturedText)); - - actionsEl.appendChild(editBtn); - row.querySelector('.message__user-content').appendChild(actionsEl); - } - } else { + if (current + && current.dataset.msgKey === key + && current.dataset.msgSig === sig) { continue; } - if (msg.role === 'user' || msg.role === 'assistant') userAssistantIdx++; - messagesEl.appendChild(row); + const next = create(); + if (current) current.replaceWith(next); + else messagesEl.appendChild(next); + } + + while (messagesEl.childElementCount > planned.length) { + messagesEl.lastElementChild.remove(); } if (emptyState) { @@ -1218,6 +1124,213 @@ function renderMessages(liveMessages, status) { updateSendBtn(); } +/** Stable fingerprint of everything that affects a row's rendered output. */ +function messageRowSignature(msg, { isIdle, isLastAssistant, hidePromptControls }) { + const parts = (msg.parts ?? []).map((p) => { + if (p.type === 'text' || p.type === 'reasoning') { + return { type: p.type, text: p.text ?? '', status: p.status ?? '' }; + } + if (p.type === 'file') { + return { type: 'file', url: p.url ?? '', filename: p.filename ?? '', mediaType: p.mediaType ?? '' }; + } + if (p.type === 'tool-call') { + return { type: 'tool-call', name: p.name ?? p.toolName ?? '', status: p.status ?? '' }; + } + return { type: p.type }; + }); + + let statusLabel = ''; + if (msg.role === 'assistant' && msg.status === 'streaming') { + const fileParts = (msg.parts ?? []).filter((p) => p.type === 'file'); + if (fileParts.length === 0) { + statusLabel = getStreamingStatus(msg.parts).label; + } + } + + return JSON.stringify({ + role: msg.role, + status: msg.status ?? '', + stopped: !!msg.stopped, + parts, + isIdle, + isLastAssistant, + hidePromptControls, + showReasoning: shouldShowReasoning(), + statusLabel, + }); +} + +function createMessageRow(msg, userAssistantIdx, { isIdle, isLastAssistant }) { + const row = document.createElement('div'); + + if (msg.role === 'assistant') { + const rawText = msg.parts.filter((p) => p.type === 'text').map((p) => p.text).join(''); + const fileParts = msg.parts.filter((p) => p.type === 'file'); + const segments = segmentAssistantParts(msg.parts); + const reasoningSegments = segments.filter((seg) => seg.kind === 'reasoning'); + // Join only for resolveAssistantContent's string API; UI keeps one DIV per + // thought so blocks stay delineated (Brian: single section, separate DIVs). + const reasoningFromParts = reasoningSegments.map((seg) => seg.text).join('\n\n'); + const reasoningStreaming = reasoningSegments.some((seg) => seg.streaming); + const resolved = resolveAssistantContent({ + text: rawText, + reasoningFromParts, + reasoningStreaming, + }); + const text = resolved.answer; + const streaming = msg.status === 'streaming'; + // Octavus: one entry per reasoning part. Embedded peel already joins with + // blank lines — split those back into blocks for the same DIV treatment. + const reasoningBlocks = resolved.source === 'octavus' + ? reasoningSegments.map((seg) => seg.text) + : (resolved.reasoning + ? resolved.reasoning.split(/\n\n+/).filter((block) => block.trim().length > 0) + : []); + const hasText = text.trim().length > 0; + const hasReasoning = + reasoningBlocks.some((block) => block.trim().length > 0) + || reasoningStreaming + || resolved.thinkingOpen; + const showThoughts = shouldShowReasoning() && hasReasoning; + const renderedHtml = stripEmojisFromHtml( + stripHeadingLeadDecorationsFromHtml(marked.parse(text)), + ); + const filesHtml = fileParts.map((f) => renderFilePart(f)).join(''); + const thoughtsHtml = showThoughts + ? renderThoughtsBlock(reasoningBlocks, { streaming }) + : ''; + + const isImageGen = isImageGenerationLoading(msg.parts); + + let bodyContent; + if (streaming && !hasText && fileParts.length === 0 && isImageGen) { + bodyContent = renderImagePlaceholderBody(); + } else if (streaming && !hasText && fileParts.length === 0 && !isImageGen) { + bodyContent = ''; + } else if (streaming && fileParts.length === 0) { + bodyContent = `${renderedHtml}`; + } else { + bodyContent = `${renderedHtml}${filesHtml}${streaming ? '' : ''}`; + } + + // Visual-only stage label. Announcements go through #chatStatus (A11). + const streamingStatus = streaming && fileParts.length === 0 ? getStreamingStatus(msg.parts) : null; + const statusHtml = streamingStatus + ? `
${streamingStatus.icon ? `${streamingStatus.icon} ` : ''}${streamingStatus.label}
` + : ''; + + const showThinkingRing = streaming; + const avatarOpen = showThinkingRing + ? `
` + : '
'; + const avatarClose = '
'; + + // Only the last assistant message gets the trailing Cosmo avatar + // (and its thinking animation) — earlier replies don't repeat it. + const trailingHtml = isLastAssistant ? ` +
+ ${avatarOpen} + + ${avatarClose} + ${statusHtml} +
+ ` : ''; + + row.className = streaming ? 'message message--ai message--ai--streaming' : 'message message--ai'; + row.innerHTML = ` + ${thoughtsHtml} +
+ ${bodyContent} +
+ ${trailingHtml} + `; + + if (msg.stopped) { + const stoppedEl = document.createElement('div'); + stoppedEl.className = 'message__stopped body-xsmall'; + stoppedEl.textContent = t('Response stopped'); + row.querySelector('.message__body').appendChild(stoppedEl); + } + + // Hover actions on every assistant message (when idle): regenerate + // and copy-as-markdown. Both use the .button-icon style and reveal on + // hover. On the last message they share the trailing avatar row (12px + // from the avatar); earlier messages get their own row below. + if (!chatConfig.hidePromptControls && isIdle && (hasText || msg.stopped)) { + const actions = document.createElement('div'); + actions.className = 'message__msg-actions'; + + const regenBtn = document.createElement('button'); + regenBtn.type = 'button'; + regenBtn.className = 'button-icon message__hover-btn'; + regenBtn.setAttribute('aria-label', t('Regenerate response')); + regenBtn.title = t('Regenerate'); + regenBtn.innerHTML = REGEN_ICON_SVG; + const capturedIdx = userAssistantIdx; + regenBtn.addEventListener('click', () => regenerateResponse(capturedIdx)); + actions.appendChild(regenBtn); + + if (hasText) { + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'button-icon message__hover-btn'; + copyBtn.setAttribute('aria-label', t('Copy as Markdown')); + copyBtn.title = t('Copy'); + copyBtn.innerHTML = COPY_ICON_SVG; + const markdown = text; + copyBtn.addEventListener('click', () => copyMessageMarkdown(copyBtn, markdown)); + actions.appendChild(copyBtn); + } + + const trailing = row.querySelector('.message__ai-trailing'); + if (trailing) { + trailing.appendChild(actions); + } else { + actions.classList.add('message__msg-actions--standalone'); + row.appendChild(actions); + } + } + } else { + const text = msg.parts.filter((p) => p.type === 'text').map((p) => p.text).join(''); + const fileParts = msg.parts.filter((p) => p.type === 'file'); + + const filesHtml = fileParts.map((f) => renderFilePart(f)).join(''); + + row.className = 'message message--user'; + row.innerHTML = ` +
+ ${filesHtml} + ${text ? `
${text}
` : ''} +
+ `; + + if (!chatConfig.hidePromptControls && isIdle && text) { + const actionsEl = document.createElement('div'); + actionsEl.className = 'message__actions message__actions--user'; + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'button-icon'; + editBtn.setAttribute('aria-label', t('Edit message')); + editBtn.title = t('Edit'); + // Edit icon (local inline SVG) — inherits currentColor. A matching + // stroke fattens the otherwise-thin fill paths. + editBtn.innerHTML = ``; + const capturedIdx = userAssistantIdx; + const capturedText = text; + editBtn.addEventListener('click', () => startEditingMessage(row, capturedIdx, capturedText)); + + actionsEl.appendChild(editBtn); + row.querySelector('.message__user-content').appendChild(actionsEl); + } + } + + return row; +} + function renderFilePart(part) { if (part.mediaType?.startsWith('image/')) { return `${part.filename || 'image'}`; @@ -1455,6 +1568,11 @@ function stopGeneration() { const rt = active; if (!rt) return; + // Announce before abort so the subscribe handler can suppress the generic + // "Response complete" that would otherwise fire on the streaming→idle tick. + rt.stopAnnouncementPending = true; + announceChatStatus(t('Response stopped')); + clearSaveTimer(rt); if (rt.abortController) { rt.abortController.abort(); @@ -1483,6 +1601,7 @@ function stopGeneration() { // Rebuild the chat so the live message set is cleared before we persist; // saveSession() then writes restoredMessages (the full convo) only. rt.lastStatus = null; + rt.stopAnnouncementPending = false; attachChat(rt); renderActive(); syncStreamingLoop(); diff --git a/public/index.html b/public/index.html index a491452..c22de6b 100644 --- a/public/index.html +++ b/public/index.html @@ -79,6 +79,10 @@