Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/new-reply-pill.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export interface WidgetLocaleStrings {
suggestionsLabel: string;
unreadCount: string;
sourcesLabel: string;
newReply: string;
disclosure: string;
disclosureLearnMore: string;
}
Expand Down
43 changes: 43 additions & 0 deletions e2e/tests/stream-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>((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);
});
45 changes: 39 additions & 6 deletions packages/widget/src/dom/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -55,6 +56,8 @@ export class Messages {
private streams = new Map<string, Stream>();
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;
Expand All @@ -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-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';
Expand All @@ -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');
Expand All @@ -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;
}

Expand All @@ -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. */
Expand Down Expand Up @@ -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 {
Expand All @@ -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 = `<span class="cc-sr-only">${this.escapeHtml(this.actions?.typingLabel ?? 'Typing...')}</span><div class="cc-typing-dot" aria-hidden="true"></div><div class="cc-typing-dot" aria-hidden="true"></div><div class="cc-typing-dot" aria-hidden="true"></div>`;
this.container.appendChild(this.typingEl);
this.scrollToBottom();
if (follow) this.scrollToBottom();
}

removeTyping(): void {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/dom/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/widget/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface WidgetLocaleStrings {
suggestionsLabel: string;
unreadCount: string;
sourcesLabel: string;
newReply: string;
disclosure: string;
disclosureLearnMore: string;
}
Expand Down Expand Up @@ -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',
};
Expand Down
15 changes: 13 additions & 2 deletions packages/widget/src/styles/widget.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -195,6 +196,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;
Expand Down Expand Up @@ -531,8 +541,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;
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ export class Widget {
regenerateLabel: l.regenerateButton,
typingLabel: l.typingIndicator,
sourcesLabel: l.sourcesLabel,
newReplyLabel: l.newReply,
};
}

Expand Down
119 changes: 119 additions & 0 deletions packages/widget/tests/dom/stream.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => requestAnimationFrame(() => resolve()));
Expand Down Expand Up @@ -150,3 +151,121 @@ 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.classList.contains('cc-suggestion')).toBe(false);
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<typeof getWidgetLocale>);
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 ↓');
});
});
2 changes: 1 addition & 1 deletion packages/widget/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$/,
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/widget/vite.panel.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$/,
},
},
},
Expand Down
Loading
Loading