diff --git a/i18n/en.json b/i18n/en.json index f0fa25c..2297129 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -40,6 +40,10 @@ "Running tool…": "Running tool…", "Still running…": "Still running…", "Response stopped": "Response stopped", + "Cosmo is responding": "Cosmo is responding", + "Response complete": "Response complete", + "Your message": "Your message", + "Cosmo's reply": "Cosmo's reply", "Regenerate response": "Regenerate response", "Regenerate": "Regenerate", "Copy as Markdown": "Copy as Markdown", diff --git a/i18n/es.json b/i18n/es.json index eb21698..99b9ce2 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -40,6 +40,10 @@ "Running tool…": "Ejecutando herramienta…", "Still running…": "Aún ejecutando…", "Response stopped": "Respuesta detenida", + "Cosmo is responding": "Cosmo está respondiendo", + "Response complete": "Respuesta completa", + "Your message": "Tu mensaje", + "Cosmo's reply": "Respuesta de Cosmo", "Regenerate response": "Regenerar respuesta", "Regenerate": "Regenerar", "Copy as Markdown": "Copiar como Markdown", diff --git a/public/app.css b/public/app.css index 9fa75f9..efb79c0 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-path: inset(50%); + white-space: nowrap; + border: 0; +} + /* Messages container (injected by JS when conversation starts) */ .messages { display: flex; @@ -830,17 +843,17 @@ body.is-resizing-sidebar { .message__body li + li { margin-top: var(--UI-Spacing-spacing-xxs); } /* Markdown-specific styles */ -.message__body.markdown h1, -.message__body.markdown h2, -.message__body.markdown h3 { +/* Semantic levels are demoted (+2) under the turn h2; --N is the author's + original markdown depth so visual weight stays the same. */ +.message__body.markdown :is(h1, h2, h3, h4, h5, h6) { margin: var(--UI-Spacing-spacing-ms) 0 var(--UI-Spacing-spacing-xs); font-family: var(--heading-family, inherit); font-weight: 500; margin-top: 0 !important; } -.message__body.markdown h1 { font-size: 1.4em; } -.message__body.markdown h2 { font-size: 1.2em; } -.message__body.markdown h3 { font-size: 1.1em; } +.message__body.markdown .message__heading--1 { font-size: 1.4em; } +.message__body.markdown .message__heading--2 { font-size: 1.2em; } +.message__body.markdown .message__heading--3 { font-size: 1.1em; } /* Inline code */ .message__body.markdown code { diff --git a/public/app.js b/public/app.js index 1991ae1..28acdc1 100644 --- a/public/app.js +++ b/public/app.js @@ -126,6 +126,16 @@ renderer.link = function (token) { return html.replace(/^${body}\n`; +}; + marked.use({ breaks: true, gfm: true, renderer }); /** Strip leading emoji / pictographic decorations models often put before heading text (e.g. colored squares). */ @@ -321,14 +331,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 +374,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 +1027,28 @@ 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, force layout, then set on the next frame so AT reliably notices + // the change — including when the new string matches the previous one. + el.textContent = ''; + void el.offsetWidth; + requestAnimationFrame(() => { + 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 +1060,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 +1074,65 @@ function renderMessages(liveMessages, status) { return -1; })(); + /** @type {{ key: string, structureSig: string, contentSig: string, msg: object, idx: number, isLastAssistant: boolean }[]} */ + 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.role !== 'user' && msg.role !== 'assistant') continue; + + const idx = userAssistantIdx; + const isLastAssistant = i === lastAssistantIdx; + const ctx = { isIdle, isLastAssistant, hidePromptControls: !!chatConfig.hidePromptControls }; + const { structureSig, contentSig } = messageRowSignatures(msg, ctx); + planned.push({ + key: String(i), + structureSig, + contentSig, + msg, + idx, + isLastAssistant, + }); + userAssistantIdx++; + } - 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); - } + for (let i = 0; i < planned.length; i++) { + const { key, structureSig, contentSig, msg, idx, isLastAssistant } = planned[i]; + const current = messagesEl.children[i]; + const ctx = { isIdle, isLastAssistant }; - // 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); - } + // In-place edit UI is DOM-only state; never tear it down on a re-render. + if (current?.querySelector?.('.message__edit-box')) continue; - 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'); - - const filesHtml = fileParts.map((f) => renderFilePart(f)).join(''); - - row.className = 'message message--user'; - row.innerHTML = ` -
- ${filesHtml} - ${text ? `
${text}
` : ''} -
- `; + if (current + && current.dataset.msgKey === key + && current.dataset.structureSig === structureSig + && current.dataset.contentSig === contentSig) { + 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 { + // Same chrome (article/heading/actions); only prose/status changed — + // patch in place so VoiceOver can keep reading during streaming. + if (current + && current.dataset.msgKey === key + && current.dataset.structureSig === structureSig + && msg.role === 'assistant') { + patchAssistantRowContent(current, buildAssistantModel(msg, ctx)); + current.dataset.contentSig = contentSig; continue; } - if (msg.role === 'user' || msg.role === 'assistant') userAssistantIdx++; - messagesEl.appendChild(row); + const next = createMessageRow(msg, idx, ctx); + next.dataset.msgKey = key; + next.dataset.structureSig = structureSig; + next.dataset.contentSig = contentSig; + if (current) current.replaceWith(next); + else messagesEl.appendChild(next); + } + + while (messagesEl.childElementCount > planned.length) { + messagesEl.lastElementChild.remove(); } if (emptyState) { @@ -1218,6 +1151,318 @@ function renderMessages(liveMessages, status) { updateSendBtn(); } +/** + * Split fingerprints so streaming token ticks can patch prose without + * recreating the article/heading chrome (keeps VoiceOver's place). + */ +function messageRowSignatures(msg, { isIdle, isLastAssistant, hidePromptControls }) { + if (msg.role === 'assistant') { + const model = buildAssistantModel(msg, { isIdle, isLastAssistant }); + // Structure = chrome that needs a full rebuild (action buttons/listeners, + // trailing avatar). Prose, thoughts, and status labels are content patches. + const structureSig = JSON.stringify({ + role: 'assistant', + isIdle, + isLastAssistant, + hidePromptControls, + showActions: model.showActions, + }); + const contentSig = JSON.stringify({ + className: model.className, + bodyContent: model.bodyContent, + stopped: model.stopped, + statusLabel: model.statusLabel, + statusIcon: model.statusIcon, + showThoughts: model.showThoughts, + reasoningBlocks: model.reasoningBlocks, + summaryLabel: model.summaryLabel, + streaming: model.streaming, + }); + return { structureSig, contentSig }; + } + + const text = (msg.parts ?? []).filter((p) => p.type === 'text').map((p) => p.text).join(''); + const fileParts = (msg.parts ?? []).filter((p) => p.type === 'file'); + const structureSig = JSON.stringify({ + role: 'user', + isIdle, + hidePromptControls, + showActions: !hidePromptControls && isIdle && !!text, + }); + const contentSig = JSON.stringify({ + text, + files: fileParts.map((f) => ({ url: f.url, filename: f.filename, mediaType: f.mediaType })), + }); + return { structureSig, contentSig }; +} + +function buildAssistantModel(msg, { isIdle, isLastAssistant }) { + 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 isImageGen = isImageGenerationLoading(msg.parts); + + let bodyMode = 'content'; + let bodyContent; + if (streaming && !hasText && fileParts.length === 0 && isImageGen) { + bodyMode = 'image'; + bodyContent = renderImagePlaceholderBody(); + } else if (streaming && !hasText && fileParts.length === 0 && !isImageGen) { + bodyMode = 'empty'; + 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 showActions = !chatConfig.hidePromptControls && isIdle && (hasText || msg.stopped); + + return { + text, + fileParts, + streaming, + stopped: !!msg.stopped, + reasoningBlocks, + showThoughts, + summaryLabel: streaming ? t('Thinking…') : t('Thoughts'), + thoughtsHtml: showThoughts ? renderThoughtsBlock(reasoningBlocks, { streaming }) : '', + bodyMode, + bodyContent, + statusLabel: streamingStatus?.label ?? '', + statusIcon: streamingStatus?.icon ?? null, + statusHtml: streamingStatus + ? `
${streamingStatus.icon ? `${streamingStatus.icon} ` : ''}${streamingStatus.label}
` + : '', + isLastAssistant, + showActions, + className: streaming ? 'message message--ai message--ai--streaming' : 'message message--ai', + }; +} + +/** Update prose/status inside an existing assistant article without replacing it. */ +function patchAssistantRowContent(row, model) { + row.className = model.className; + + const heading = row.querySelector(':scope > h2.visually-hidden'); + let thoughts = row.querySelector(':scope > .message__thoughts'); + if (model.showThoughts) { + const blocksHtml = model.reasoningBlocks + .map((text) => { + const html = escapeHtml(text ?? '').replace(/\n/g, '
'); + return `
${html}
`; + }) + .join(''); + if (thoughts) { + // Preserve the user's open/closed choice across token ticks (A20). + const wasOpen = thoughts.open; + const label = thoughts.querySelector('.message__thoughts-summary-label'); + if (label) label.textContent = model.summaryLabel; + const thoughtsBody = thoughts.querySelector('.message__thoughts-body'); + if (thoughtsBody) thoughtsBody.innerHTML = blocksHtml; + thoughts.open = wasOpen; + } else if (heading) { + heading.insertAdjacentHTML('afterend', model.thoughtsHtml); + } + } else if (thoughts) { + thoughts.remove(); + } + + const body = row.querySelector(':scope > .message__body'); + if (body) { + body.innerHTML = model.bodyContent; + if (model.stopped) { + const stoppedEl = document.createElement('div'); + stoppedEl.className = 'message__stopped body-xsmall'; + stoppedEl.textContent = t('Response stopped'); + body.appendChild(stoppedEl); + } + } + + const statusEl = row.querySelector('.message__ai-status'); + if (model.statusLabel) { + const statusText = `${model.statusIcon ? `${model.statusIcon} ` : ''}${model.statusLabel}`; + if (statusEl) { + statusEl.textContent = statusText; + } else { + const trailing = row.querySelector('.message__ai-trailing'); + if (trailing) { + trailing.insertAdjacentHTML('beforeend', model.statusHtml); + } + } + } else if (statusEl) { + statusEl.remove(); + } + + const avatar = row.querySelector('.message__avatar'); + if (avatar) { + avatar.classList.toggle('message__avatar--thinking', model.streaming); + } +} + +function createMessageRow(msg, userAssistantIdx, { isIdle, isLastAssistant }) { + //
+ visually hidden heading so VoiceOver's Headings / Articles + // rotors can land on each turn. Plain divs left replies as an unlabelled + // paragraph soup inside a scrollable main — reachable in the AX tree, but + // practically undiscoverable via landmark/heading navigation. + const row = document.createElement('article'); + const whoLabel = msg.role === 'assistant' ? t("Cosmo's reply") : t('Your message'); + const labelId = `msg-label-${userAssistantIdx}`; + row.setAttribute('aria-labelledby', labelId); + const headingHtml = `

${escapeHtml(whoLabel)}

`; + + if (msg.role === 'assistant') { + const model = buildAssistantModel(msg, { isIdle, isLastAssistant }); + + const avatarOpen = model.streaming + ? `
` + : '
'; + 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} + ${model.statusHtml} +
+ ` : ''; + + row.className = model.className; + row.innerHTML = ` + ${headingHtml} + ${model.thoughtsHtml} +
+ ${model.bodyContent} +
+ ${trailingHtml} + `; + + if (model.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 (model.showActions) { + 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 (model.text.trim()) { + 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 = model.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 = ` + ${headingHtml} +
+ ${filesHtml} +
+ `; + // User text via textContent — never interpolate into HTML (A21 / XSS). + if (text) { + const bubble = document.createElement('div'); + bubble.className = 'message__bubble body-medium'; + bubble.textContent = text; + row.querySelector('.message__user-content').appendChild(bubble); + } + + 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 +1700,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 +1733,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 @@