From acd6e02a0492635513bcef049df8853589367a74 Mon Sep 17 00:00:00 2001 From: Srujan Kaleru Date: Thu, 29 Oct 2026 08:44:25 +0530 Subject: [PATCH 1/2] feat(widget): show new reply pill while scrolled up Add an accessible, localized jump-to-latest button with reduced-motion support and preserve the existing stream follow threshold. Include regression coverage, API snapshot, documentation, and a minor changeset. --- .changeset/new-reply-pill.md | 5 + api/widget.api.md | 1 + e2e/tests/stream-render.spec.ts | 43 +++++++++ packages/widget/src/dom/messages.ts | 45 +++++++-- packages/widget/src/dom/panel.ts | 1 + packages/widget/src/i18n.ts | 2 + packages/widget/src/styles/widget.css | 12 ++- packages/widget/src/widget.ts | 1 + packages/widget/tests/dom/stream.test.ts | 118 +++++++++++++++++++++++ packages/widget/vite.config.ts | 2 +- packages/widget/vite.panel.config.ts | 2 +- website/src/content/docs/widget/i18n.mdx | 3 + 12 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 .changeset/new-reply-pill.md diff --git a/.changeset/new-reply-pill.md b/.changeset/new-reply-pill.md new file mode 100644 index 00000000..5f3edc6b --- /dev/null +++ b/.changeset/new-reply-pill.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": minor +--- + +Show a localized New reply button when messages arrive while the visitor reads earlier messages. The button scrolls to the latest reply, respects reduced motion, and disappears when the visitor returns to the bottom. diff --git a/api/widget.api.md b/api/widget.api.md index 97e050d5..7bc34ff9 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -207,6 +207,7 @@ export interface WidgetLocaleStrings { suggestionsLabel: string; unreadCount: string; sourcesLabel: string; + newReply: string; disclosure: string; disclosureLearnMore: string; } diff --git a/e2e/tests/stream-render.spec.ts b/e2e/tests/stream-render.spec.ts index a3eac6cc..0f683638 100644 --- a/e2e/tests/stream-render.spec.ts +++ b/e2e/tests/stream-render.spec.ts @@ -84,7 +84,50 @@ test("new text doesn't pull the log down while the visitor reads above it", asyn el.scrollTop = 0; }); await page.waitForTimeout(500); + await expect(page.getByRole('button', { name: 'New reply ↓' })).toBeVisible(); expect(await log.evaluate((el) => el.scrollTop)).toBe(0); await expect(page.locator('.cc-message-assistant[data-status="complete"]')).toHaveCount(1, { timeout: 45_000 }); expect(await log.evaluate((el) => el.scrollTop)).toBe(0); }); + +test("new reply button scrolls to the latest reply with the keyboard", async ({ page }) => { + let finish: (body: string) => void = () => {}; + const response = new Promise((resolve) => { finish = resolve; }); + await page.route('**/api/chat', async (route) => { + await route.fulfill({ contentType: 'text/event-stream', body: await response }); + }); + await page.goto('/'); + await page.locator('.cc-fab').click(); + const log = page.locator('.cc-messages'); + // Seed enough history to exercise real browser scrolling and overlay placement. + await log.evaluate((el) => { + for (let i = 0; i < 20; i++) { + const message = document.createElement('div'); + message.className = 'cc-message cc-message-assistant'; + message.textContent = `Earlier reply ${i}: ${'Some history. '.repeat(10)}`; + el.appendChild(message); + } + el.scrollTop = el.scrollHeight; + }); + await page.locator('.cc-input').fill('Hello'); + await page.locator('.cc-send-btn').click(); + await expect(page.locator('.cc-typing')).toBeVisible(); + await log.evaluate((el) => { el.scrollTop = 100; }); + finish('data: {"content":"A new reply below the history."}\n\ndata: {"done":true}\n\ndata: [DONE]\n\n'); + await expect(page.locator('.cc-message-assistant').last()).toContainText('A new reply below'); + expect(await log.evaluate((el) => el.scrollTop)).toBe(100); + const pill = page.getByRole('button', { name: 'New reply ↓' }); + await expect(pill).toBeVisible(); + const logBox = await log.boundingBox(); + const pillBox = await pill.boundingBox(); + expect(pillBox!.y + pillBox!.height).toBeLessThanOrEqual(logBox!.y + logBox!.height); + await pill.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(pill).toBeFocused(); + await expect(pill).not.toHaveCSS('box-shadow', 'none'); + await page.screenshot({ path: test.info().outputPath('new-reply.png') }); + await pill.press('Enter'); + await expect(pill).toBeHidden(); + await expect.poll(() => log.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThanOrEqual(1); +}); diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index 20efd9b4..19817e45 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -24,6 +24,7 @@ export interface MessageActions { regenerateLabel?: string; typingLabel?: string; sourcesLabel?: string; + newReplyLabel?: string; } /** How close to the bottom (px) the log has to be for new text to scroll it. */ @@ -55,6 +56,8 @@ export class Messages { private streams = new Map(); private streamIntervalMs: number; private announcement: HTMLDivElement; + private logEl: HTMLDivElement; + private newReplyEl: HTMLButtonElement; private typingEl: HTMLDivElement | null = null; private suggestionsEl: HTMLDivElement | null = null; private disclosureEl: HTMLElement | null = null; @@ -67,7 +70,26 @@ export class Messages { this.container.className = 'cc-messages'; this.container.setAttribute('role', 'log'); this.container.setAttribute('aria-live', 'polite'); - parent.appendChild(this.container); + this.logEl = document.createElement('div'); + this.logEl.className = 'cc-messages-wrapper'; + this.logEl.appendChild(this.container); + this.newReplyEl = document.createElement('button'); + this.newReplyEl.type = 'button'; + this.newReplyEl.className = 'cc-suggestion cc-new-reply'; + this.refreshReplyLabel(); + this.newReplyEl.hidden = true; + this.newReplyEl.addEventListener('click', () => { + this.container.scrollTo({ + top: this.container.scrollHeight, + behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', + }); + this.newReplyEl.hidden = true; + }); + this.container.addEventListener('scroll', () => { + if (!this.newReplyEl.hidden && this.nearBottom()) this.newReplyEl.hidden = true; + }); + this.logEl.appendChild(this.newReplyEl); + parent.appendChild(this.logEl); this.announcement = document.createElement('div'); this.announcement.className = 'cc-reply-announcement cc-sr-only'; @@ -76,6 +98,10 @@ export class Messages { parent.appendChild(this.announcement); } + refreshReplyLabel(): void { + this.newReplyEl.textContent = `${this.actions?.newReplyLabel ?? 'New reply'} ↓`; + } + beginReply(): void { this.announcement.textContent = ''; this.container.setAttribute('aria-live', 'off'); @@ -99,7 +125,7 @@ export class Messages { if (msg.role === 'user') this.removeSuggestions(); const el = this.createMessageElement(msg); this.container.appendChild(el); - if (follow) this.scrollToBottom(); + this.followReply(follow); return el; } @@ -113,7 +139,7 @@ export class Messages { this.endStream(msg.id); const follow = this.nearBottom(); this.renderMessageElement(el, msg); - if (follow) this.scrollToBottom(); + this.followReply(follow); } /** Takes the reply's text so far, and renders what changed on the next frame. */ @@ -158,7 +184,7 @@ export class Messages { stream.committed = boundary; } stream.tail.innerHTML = renderMarkdown(stream.text.slice(stream.committed)); - if (follow) this.scrollToBottom(); + this.followReply(follow); } private endStream(id: string): void { @@ -174,11 +200,12 @@ export class Messages { showTyping(): void { if (this.typingEl) return; + const follow = this.nearBottom(); this.typingEl = document.createElement('div'); this.typingEl.className = 'cc-typing'; this.typingEl.innerHTML = `${this.escapeHtml(this.actions?.typingLabel ?? 'Typing...')}`; this.container.appendChild(this.typingEl); - this.scrollToBottom(); + if (follow) this.scrollToBottom(); } removeTyping(): void { @@ -217,6 +244,7 @@ export class Messages { for (const id of [...this.streams.keys()]) this.endStream(id); this.container.innerHTML = ''; this.typingEl = null; + this.newReplyEl.hidden = true; // A new conversation starts with the disclosure too. if (this.disclosureEl) this.container.prepend(this.disclosureEl); } @@ -276,7 +304,12 @@ export class Messages { } setVisible(visible: boolean): void { - this.container.classList.toggle('cc-hidden', !visible); + this.logEl.classList.toggle('cc-hidden', !visible); + } + + private followReply(follow: boolean): void { + this.newReplyEl.toggleAttribute('hidden', follow); + if (follow) this.scrollToBottom(); } private findMessage(id: string): HTMLDivElement | null { diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index ae236a5c..56fbe987 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -120,6 +120,7 @@ export class Panel { // Messages reads its labels from this same object as it renders. if (this.options.messageActions) Object.assign(this.options.messageActions, actionLabels); this.renderHeader(); + this.messages.refreshReplyLabel(); this.input.setLabels(labels.placeholder, labels.sendLabel); this.footer.innerHTML = labels.footerText; } diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index 8c25b740..280b75d2 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -32,6 +32,7 @@ export interface WidgetLocaleStrings { suggestionsLabel: string; unreadCount: string; sourcesLabel: string; + newReply: string; disclosure: string; disclosureLearnMore: string; } @@ -69,6 +70,7 @@ const en: WidgetLocaleStrings = { suggestionsLabel: 'Suggested questions', unreadCount: '{count} unread', sourcesLabel: 'Sources', + newReply: 'New reply', disclosure: "You're chatting with an AI assistant. It can make mistakes.", disclosureLearnMore: 'Learn more', }; diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index 3ac14532..446fa98a 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -195,6 +195,15 @@ border-color: var(--cc-accent); } +.cc-new-reply { + position: absolute; + inset-block-end: 12px; + inset-inline-end: 16px; + border-radius: 999px; + box-shadow: var(--cc-shadow); + font-size: 12px; +} + .cc-message a { color: var(--cc-accent); text-decoration: underline; @@ -531,8 +540,9 @@ white-space: nowrap; } -/* Messages wrapper (only present when pre-chat form is enabled) */ +/* Messages wrapper and overlay anchor */ .cc-messages-wrapper { + position: relative; flex: 1; display: flex; flex-direction: column; diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index f84fcd60..9c63f164 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -498,6 +498,7 @@ export class Widget { regenerateLabel: l.regenerateButton, typingLabel: l.typingIndicator, sourcesLabel: l.sourcesLabel, + newReplyLabel: l.newReply, }; } diff --git a/packages/widget/tests/dom/stream.test.ts b/packages/widget/tests/dom/stream.test.ts index bc6ea86a..a5c5272a 100644 --- a/packages/widget/tests/dom/stream.test.ts +++ b/packages/widget/tests/dom/stream.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Messages, lastBlockBoundary } from '../../src/dom/messages.js'; +import { getWidgetLocale, registerWidgetLocale } from '../../src/i18n.js'; import { renderMarkdown } from '../../src/markdown.js'; const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); @@ -150,3 +151,120 @@ describe('streaming replies', () => { expect(text()).toBe('One two'); }); }); + +describe('reply scrolling', () => { + let parent: HTMLDivElement; + let messages: Messages; + let log: HTMLDivElement; + let pill: HTMLButtonElement; + let height: number; + + beforeEach(() => { + document.body.innerHTML = ''; + parent = document.createElement('div'); + document.body.appendChild(parent); + messages = new Messages(parent); + log = parent.querySelector('.cc-messages') as HTMLDivElement; + pill = parent.querySelector('.cc-new-reply') as HTMLButtonElement; + height = 1000; + Object.defineProperties(log, { + scrollHeight: { get: () => height }, + clientHeight: { value: 300 }, + }); + }); + + it('shows a button outside the live log for a new message while scrolled up', () => { + log.scrollTop = 100; + messages.addMessage({ id: '1', role: 'assistant', content: 'Hello' }); + expect(pill.hidden).toBe(false); + expect(pill.type).toBe('button'); + expect(pill.textContent).toBe('New reply ↓'); + expect(pill.closest('[aria-live]')).toBeNull(); + expect(log.scrollTop).toBe(100); + }); + + it('shows for streamed text without pulling the log down', () => { + messages.addMessage({ id: '1', role: 'assistant', content: 'Hel', status: 'streaming' }); + log.scrollTop = 100; + messages.updateMessage({ id: '1', role: 'assistant', content: 'Hello', status: 'streaming' }); + expect(log.scrollTop).toBe(100); + expect(pill).not.toBeNull(); + expect(pill.hidden).toBe(false); + }); + + it.each([0, 48])('follows new content within %i pixels of the bottom', (distance) => { + log.scrollTop = 700 - distance; + // The decision must use the old height, even if the new message is tall. + const append = log.appendChild.bind(log); + vi.spyOn(log, 'appendChild').mockImplementation((node) => { + height = 1400; + return append(node); + }); + messages.addMessage({ id: '1', role: 'assistant', content: 'Hello' }); + expect(pill.hidden).toBe(true); + expect(log.scrollTop).toBe(1400); + messages.updateMessage({ id: '1', role: 'assistant', content: 'Hello again' }); + expect(pill.hidden).toBe(true); + }); + + it('hides when the visitor scrolls back within 48px of the bottom', () => { + messages.addMessage({ id: '1', role: 'assistant', content: 'Hello' }); + log.scrollTop = 651; + log.dispatchEvent(new Event('scroll')); + expect(pill.hidden).toBe(false); + log.scrollTop = 652; + log.dispatchEvent(new Event('scroll')); + expect(pill.hidden).toBe(true); + }); + + it.each([false, true])('scrolls and hides on click (reduced motion: %s)', (reduced) => { + messages.addMessage({ id: '1', role: 'assistant', content: 'Hello' }); + const scroll = vi.spyOn(log, 'scrollTo').mockImplementation(() => {}); + const media = vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: reduced } as MediaQueryList); + pill.click(); + expect(media).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)'); + expect(scroll).toHaveBeenCalledWith({ top: 1000, behavior: reduced ? 'instant' : 'smooth' }); + expect(pill.hidden).toBe(true); + media.mockRestore(); + }); + + it('does not show or scroll for typing alone while reading earlier messages', () => { + log.scrollTop = 100; + messages.showTyping(); + expect(pill.hidden).toBe(true); + expect(log.scrollTop).toBe(100); + messages.addMessage({ id: '1', role: 'assistant', content: 'Hello' }); + expect(pill.hidden).toBe(false); + messages.clear(); + expect(pill.hidden).toBe(true); + }); + + it('hides the pill with the log for the pre-chat form', () => { + messages.addMessage({ id: '1', role: 'assistant', content: 'Hello' }); + messages.setVisible(false); + expect(pill.closest('.cc-hidden')).toBeTruthy(); + messages.setVisible(true); + expect(pill.closest('.cc-hidden')).toBeNull(); + }); + + it('refreshes the button when the panel updates its locale labels', () => { + const actions = { newReplyLabel: 'New reply' }; + const localized = document.createElement('div'); + const log = new Messages(localized, actions); + actions.newReplyLabel = 'Nouvelle réponse'; + log.refreshReplyLabel(); + expect(localized.querySelector('.cc-new-reply')?.textContent).toBe('Nouvelle réponse ↓'); + }); + + it('uses locale overrides and falls back to English for older registered locales', () => { + const { newReply: _newReply, ...olderStrings } = getWidgetLocale('en'); + registerWidgetLocale('old', olderStrings as ReturnType); + expect(getWidgetLocale('old').newReply).toBe('New reply'); + // Existing locale files are partial and are merged with English by the widget. + const locale = getWidgetLocale('fr', { retryButton: 'Réessayer' }); + expect(locale.newReply).toBe('New reply'); + const localized = document.createElement('div'); + new Messages(localized, { newReplyLabel: 'Nouvelle réponse' }); + expect(localized.querySelector('.cc-new-reply')?.textContent).toBe('Nouvelle réponse ↓'); + }); +}); diff --git a/packages/widget/vite.config.ts b/packages/widget/vite.config.ts index 7ad9d65d..25b67ba0 100644 --- a/packages/widget/vite.config.ts +++ b/packages/widget/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ compress: { passes: 2 }, mangle: { properties: { - regex: /^(?:mounted|fab|panel|bubble|preChatFormEl|namespace|hydrated|isStreaming|destroyed|abortController|retryRequests|eventHandlers|containerEl|autoOpenTimer|unreadReplies|savedOverflow|shadow|unreadLabel|openLabel|closeLabel|badge|el|inline|messagesContainer|isVisible|typingEl|suggestionsEl|area|textarea|sendBtn|onSend|timerId)$/, + regex: /^(?:createActionButton|createActionsContainer|ensureActionsContainer|cleanupEmptyActionContainers|actionLabels|panelLabels|renderDisclosure|findLatestRegeneratableAssistant|findUserMessageForAssistant|showLatestRegenerateButton|resolveErrorType|markFailed|getErrorMessage|refreshReplyLabel|saveHistory|createRequest|showChatView|newReplyEl|logEl|followReply|streams|streamIntervalMs|renderStream|endStream|nearBottom|scrollToBottom|renderMessageElement|createMessageElement|findMessage|escapeHtml|announcement|mounted|fab|panel|bubble|preChatFormEl|namespace|hydrated|isStreaming|destroyed|abortController|retryRequests|eventHandlers|containerEl|autoOpenTimer|unreadReplies|savedOverflow|shadow|unreadLabel|openLabel|closeLabel|badge|el|inline|messagesContainer|isVisible|typingEl|suggestionsEl|area|textarea|sendBtn|onSend|timerId)$/, }, }, }, diff --git a/packages/widget/vite.panel.config.ts b/packages/widget/vite.panel.config.ts index 741bd95e..0c5c2bcc 100644 --- a/packages/widget/vite.panel.config.ts +++ b/packages/widget/vite.panel.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ compress: { passes: 2 }, mangle: { properties: { - regex: /^(?:mounted|fab|panel|bubble|preChatFormEl|namespace|hydrated|isStreaming|destroyed|abortController|retryRequests|eventHandlers|containerEl|autoOpenTimer|unreadReplies|savedOverflow|shadow|unreadLabel|openLabel|closeLabel|badge|el|inline|messagesContainer|isVisible|typingEl|suggestionsEl|area|textarea|sendBtn|onSend|timerId)$/, + regex: /^(?:createActionButton|createActionsContainer|ensureActionsContainer|cleanupEmptyActionContainers|actionLabels|panelLabels|renderDisclosure|findLatestRegeneratableAssistant|findUserMessageForAssistant|showLatestRegenerateButton|resolveErrorType|markFailed|getErrorMessage|refreshReplyLabel|saveHistory|createRequest|showChatView|newReplyEl|logEl|followReply|streams|streamIntervalMs|renderStream|endStream|nearBottom|scrollToBottom|renderMessageElement|createMessageElement|findMessage|escapeHtml|announcement|mounted|fab|panel|bubble|preChatFormEl|namespace|hydrated|isStreaming|destroyed|abortController|retryRequests|eventHandlers|containerEl|autoOpenTimer|unreadReplies|savedOverflow|shadow|unreadLabel|openLabel|closeLabel|badge|el|inline|messagesContainer|isVisible|typingEl|suggestionsEl|area|textarea|sendBtn|onSend|timerId)$/, }, }, }, diff --git a/website/src/content/docs/widget/i18n.mdx b/website/src/content/docs/widget/i18n.mdx index faa3d0f3..42a16d96 100644 --- a/website/src/content/docs/widget/i18n.mdx +++ b/website/src/content/docs/widget/i18n.mdx @@ -102,12 +102,15 @@ interface LocaleStrings { retryButton: string; regenerateButton: string; suggestionsLabel: string; + newReply: string; unreadCount: string; disclosure: string; // "You're chatting with an AI assistant. It can make mistakes." disclosureLearnMore: string; // "Learn more" } ``` +The `newReply` string defaults to `New reply`. The widget adds a downward arrow to the button shown when new text arrives while the visitor is reading earlier messages. Clicking it returns to the latest reply. Existing translations use English for this string until they add it. + ## Server-Side i18n The server doesn't translate anything itself. When a request has a `locale`, it adds this line to the system prompt: From 65ad213968e1d87473a00e44b90d315f7fdf68a0 Mon Sep 17 00:00:00 2001 From: Srujan Kaleru Date: Thu, 29 Oct 2026 10:56:39 +0530 Subject: [PATCH 2/2] fix(widget): keep new reply pill out of suggestions --- packages/widget/src/dom/messages.ts | 2 +- packages/widget/src/styles/widget.css | 3 ++- packages/widget/tests/dom/stream.test.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index 19817e45..3cf4419b 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -75,7 +75,7 @@ export class Messages { this.logEl.appendChild(this.container); this.newReplyEl = document.createElement('button'); this.newReplyEl.type = 'button'; - this.newReplyEl.className = 'cc-suggestion cc-new-reply'; + this.newReplyEl.className = 'cc-new-reply'; this.refreshReplyLabel(); this.newReplyEl.hidden = true; this.newReplyEl.addEventListener('click', () => { diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index 446fa98a..5f1afbfa 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -181,7 +181,8 @@ padding: 0 16px 12px; } -.cc-suggestion { +.cc-suggestion, +.cc-new-reply { border: 1px solid var(--cc-border); border-radius: var(--cc-radius); padding: 8px 12px; diff --git a/packages/widget/tests/dom/stream.test.ts b/packages/widget/tests/dom/stream.test.ts index a5c5272a..e911a4ce 100644 --- a/packages/widget/tests/dom/stream.test.ts +++ b/packages/widget/tests/dom/stream.test.ts @@ -179,6 +179,7 @@ describe('reply scrolling', () => { expect(pill.hidden).toBe(false); expect(pill.type).toBe('button'); expect(pill.textContent).toBe('New reply ↓'); + expect(pill.classList.contains('cc-suggestion')).toBe(false); expect(pill.closest('[aria-live]')).toBeNull(); expect(log.scrollTop).toBe(100); });