`
-
+
diff --git a/packages/spright-components/src/chat/conversation/tests/chat-conversation.spec.ts b/packages/spright-components/src/chat/conversation/tests/chat-conversation.spec.ts
index faecca2b51..57ae2e5296 100644
--- a/packages/spright-components/src/chat/conversation/tests/chat-conversation.spec.ts
+++ b/packages/spright-components/src/chat/conversation/tests/chat-conversation.spec.ts
@@ -72,4 +72,226 @@ describe('ChatConversation', () => {
const endSlot: HTMLSlotElement = element.shadowRoot!.querySelector('slot[name="end"]')!;
expect(endSlot.assignedElements().length).toBe(1);
});
+
+ describe('scrolling behavior', () => {
+ interface MockScrollContainer {
+ scrollTop: number;
+ scrollHeight: number;
+ clientHeight: number;
+ style: { paddingBottom: string };
+ getBoundingClientRect?: () => DOMRect;
+ scrollTo?: (options: ScrollToOptions) => void;
+ addEventListener?: (...args: unknown[]) => void;
+ removeEventListener?: (...args: unknown[]) => void;
+ }
+ interface ScrollManagerInternals {
+ container: MockScrollContainer;
+ isUserScrolledUp: boolean;
+ previousScrollTop: number;
+ bottomPaddingPx: number;
+ userMessageScrollTop: number;
+ onScroll: () => void;
+ updatePaddingAndScroll: () => void;
+ getLastOutboundMessage: () => HTMLElement | null;
+ scrollToLastMessageTop: () => void;
+ }
+
+ function getScrollManager(el: ChatConversation): ScrollManagerInternals {
+ return (el as unknown as { scrollManager: ScrollManagerInternals }).scrollManager;
+ }
+
+ function createMockContainer(overrides: Partial = {}): MockScrollContainer {
+ return {
+ scrollTop: 0,
+ scrollHeight: 600,
+ clientHeight: 300,
+ style: { paddingBottom: '' },
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ ...overrides
+ };
+ }
+
+ it('auto-scroll should default to false', async () => {
+ await connect();
+ expect(element.autoScroll).toBeFalse();
+ });
+
+ it('can enable auto-scroll before connect', async () => {
+ element.autoScroll = true;
+
+ await connect();
+
+ const scrollManager = getScrollManager(element);
+ expect(scrollManager).not.toBeNull();
+ });
+
+ describe('with auto-scroll enabled', () => {
+ beforeEach(async () => {
+ await connect();
+ element.autoScroll = true;
+ });
+
+ it('marks user as scrolled up when scrolling up away from bottom', () => {
+ const scrollManager = getScrollManager(element);
+ scrollManager.container = createMockContainer({ scrollTop: 100, scrollHeight: 500, clientHeight: 200 });
+ scrollManager.previousScrollTop = 200;
+
+ scrollManager.onScroll();
+
+ expect(scrollManager.isUserScrolledUp).toBeTrue();
+ });
+
+ it('clears isUserScrolledUp when user scrolls to within 10px of bottom', () => {
+ const scrollManager = getScrollManager(element);
+ scrollManager.container = createMockContainer({ scrollTop: 290, scrollHeight: 500, clientHeight: 200 });
+ scrollManager.isUserScrolledUp = true;
+ scrollManager.previousScrollTop = 100;
+
+ scrollManager.onScroll();
+
+ expect(scrollManager.isUserScrolledUp).toBeFalse();
+ });
+
+ it('auto-scrolls to bottom when content updates and user has not scrolled up', () => {
+ const scrollManager = getScrollManager(element);
+ const container = createMockContainer({ scrollTop: 0, scrollHeight: 600, clientHeight: 300 });
+ scrollManager.container = container;
+ scrollManager.updatePaddingAndScroll();
+
+ expect(container.scrollTop).toBe(600);
+ });
+
+ it('does not auto-scroll when user has scrolled up and content updates', async () => {
+ spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => {
+ cb(0);
+ return 0;
+ });
+
+ const scrollManager = getScrollManager(element);
+ const container = createMockContainer({ scrollTop: 100 });
+ scrollManager.container = container;
+ scrollManager.isUserScrolledUp = true;
+
+ element.appendChild(document.createTextNode('streaming text'));
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(container.scrollTop).toBe(100);
+ });
+
+ it('scrolls so that the outbound message appears at top of viewport when user sends a message', () => {
+ const scrollManager = getScrollManager(element);
+ const container: MockScrollContainer & { scrollTo: (options: ScrollToOptions) => void, getBoundingClientRect: () => DOMRect } = {
+ ...createMockContainer({ scrollTop: 0, scrollHeight: 600, clientHeight: 300 }),
+ getBoundingClientRect: (): DOMRect => ({ top: 50 } as DOMRect),
+ scrollTo(options: ScrollToOptions): void {
+ this.scrollTop = options.top ?? 0;
+ }
+ };
+ scrollManager.container = container;
+ const outboundMsg = document.createElement('div');
+ spyOn(outboundMsg, 'getBoundingClientRect').and.returnValue({ top: 200, height: 50 } as DOMRect);
+ spyOn(scrollManager, 'getLastOutboundMessage').and.returnValue(outboundMsg);
+
+ scrollManager.scrollToLastMessageTop();
+
+ expect(container.scrollTop).toBe(142);
+ });
+
+ it('sets bottom padding to keep outbound message scrollable when content is shorter than viewport', () => {
+ const scrollManager = getScrollManager(element);
+ const container: MockScrollContainer & { scrollTo: (options: ScrollToOptions) => void, getBoundingClientRect: () => DOMRect } = {
+ ...createMockContainer({ scrollTop: 0, scrollHeight: 600, clientHeight: 300 }),
+ getBoundingClientRect: (): DOMRect => ({ top: 50 } as DOMRect),
+ scrollTo(options: ScrollToOptions): void {
+ this.scrollTop = options.top ?? 0;
+ }
+ };
+ scrollManager.container = container;
+ const outboundMsg = document.createElement('div');
+ spyOn(outboundMsg, 'getBoundingClientRect').and.returnValue({ top: 200, height: 50 } as DOMRect);
+ spyOn(scrollManager, 'getLastOutboundMessage').and.returnValue(outboundMsg);
+
+ scrollManager.scrollToLastMessageTop();
+
+ expect(scrollManager.bottomPaddingPx).toBe(242);
+ expect(container.style.paddingBottom).toBe('242px');
+ });
+
+ it('reduces bottom padding as AI response content grows', () => {
+ const scrollManager = getScrollManager(element);
+ const container: MockScrollContainer & { scrollTo: (options: ScrollToOptions) => void } = {
+ ...createMockContainer({ scrollTop: 142, scrollHeight: 842, clientHeight: 300, style: { paddingBottom: '242px' } }),
+ scrollTo(options: ScrollToOptions): void {
+ this.scrollTop = options.top ?? 0;
+ }
+ };
+ scrollManager.container = container;
+ scrollManager.bottomPaddingPx = 242;
+ scrollManager.userMessageScrollTop = 142;
+
+ scrollManager.updatePaddingAndScroll();
+
+ expect(scrollManager.bottomPaddingPx).toBe(0);
+ expect(container.style.paddingBottom).toBe('');
+ });
+
+ it('auto-scrolls to bottom once all padding is removed as AI content fills the viewport', () => {
+ const scrollManager = getScrollManager(element);
+ const container: MockScrollContainer & { scrollTo: (options: ScrollToOptions) => void } = {
+ ...createMockContainer({ scrollTop: 142, scrollHeight: 842, clientHeight: 300, style: { paddingBottom: '242px' } }),
+ scrollTo(options: ScrollToOptions): void {
+ this.scrollTop = options.top ?? 0;
+ }
+ };
+ scrollManager.container = container;
+ scrollManager.bottomPaddingPx = 242;
+ scrollManager.userMessageScrollTop = 142;
+
+ scrollManager.updatePaddingAndScroll();
+
+ expect(container.scrollTop).toBe(container.scrollHeight);
+ });
+
+ it('scrolls so that the last few lines of a tall outbound message appear at top of viewport', () => {
+ const scrollManager = getScrollManager(element);
+ const container: MockScrollContainer & { scrollTo: (options: ScrollToOptions) => void, getBoundingClientRect: () => DOMRect } = {
+ ...createMockContainer({ scrollTop: 0, scrollHeight: 600, clientHeight: 300 }),
+ getBoundingClientRect: (): DOMRect => ({ top: 50 } as DOMRect),
+ scrollTo(options: ScrollToOptions): void {
+ this.scrollTop = options.top ?? 0;
+ }
+ };
+ scrollManager.container = container;
+ const outboundMsg = document.createElement('div');
+ spyOn(outboundMsg, 'getBoundingClientRect').and.returnValue({ top: 200, height: 160 } as DOMRect);
+ spyOn(scrollManager, 'getLastOutboundMessage').and.returnValue(outboundMsg);
+
+ scrollManager.scrollToLastMessageTop();
+
+ expect(container.scrollTop).toBe(250);
+ });
+
+ it('sets bottom padding to clientHeight minus line gap for a tall outbound message', () => {
+ const scrollManager = getScrollManager(element);
+ const container: MockScrollContainer & { scrollTo: (options: ScrollToOptions) => void, getBoundingClientRect: () => DOMRect } = {
+ ...createMockContainer({ scrollTop: 0, scrollHeight: 600, clientHeight: 300 }),
+ getBoundingClientRect: (): DOMRect => ({ top: 50 } as DOMRect),
+ scrollTo(options: ScrollToOptions): void {
+ this.scrollTop = options.top ?? 0;
+ }
+ };
+ scrollManager.container = container;
+ const outboundMsg = document.createElement('div');
+ spyOn(outboundMsg, 'getBoundingClientRect').and.returnValue({ top: 200, height: 160 } as DOMRect);
+ spyOn(scrollManager, 'getLastOutboundMessage').and.returnValue(outboundMsg);
+
+ scrollManager.scrollToLastMessageTop();
+
+ expect(scrollManager.bottomPaddingPx).toBe(240);
+ expect(container.style.paddingBottom).toBe('240px');
+ });
+ });
+ });
});
diff --git a/packages/spright-components/src/chat/specs/README.md b/packages/spright-components/src/chat/specs/README.md
index 02c8e05556..2281b5a649 100644
--- a/packages/spright-components/src/chat/specs/README.md
+++ b/packages/spright-components/src/chat/specs/README.md
@@ -87,7 +87,7 @@ All end text buttons must meet the following criteria
#### Chat conversation
1. Lays out messages vertically based on their order.
-1. Displays a vertical scrollbar if there are more messages than fit in the height allocated to the conversation.
+1. Displays a vertical scrollbar if there are more messages than fit in the height allocated to the conversation. Optionally auto-scrolls when more content is added.
1. Includes a slot to place a toolbar (and its content such as buttons or menu buttons) on top of the conversation.
1. Includes a slot to place content (such as banners) below the toolbar and above the messages.
1. Includes a slot to place an input component below the messages.
@@ -282,6 +282,7 @@ All message types will share the following API:
- `appearance`
- `undefined` (default): provides a vertical gradient background and a 1-pixel border
- `overlay`: hides the background and border of the spright-chat-conversation
+ - `auto-scroll`: Whether to enable [automatic scrolling behaviors](./scrolling-behavior.md)
- _Methods_
- _Events_
- _CSS Classes and CSS Custom Properties that affect the component_
diff --git a/packages/spright-components/src/chat/specs/scrolling-behavior.md b/packages/spright-components/src/chat/specs/scrolling-behavior.md
index 2f94c14c1c..1fba7f689b 100644
--- a/packages/spright-components/src/chat/specs/scrolling-behavior.md
+++ b/packages/spright-components/src/chat/specs/scrolling-behavior.md
@@ -34,7 +34,8 @@ System-initiated auto-scrolling should stop when a manual interruption occurs.
Auto scroll is re-rengaged when a user does any action to return to the bottom of the chat
**Scroll position memory**
-Scroll position should only be preserved for the open chat, we do not need to remember scroll position history when navigating the chat history
+Scroll position should only be preserved for the open chat, we do not need to remember scroll position history when navigating the chat history. Opening an existing chat should scroll to the
+end of the conversation.
## Mouse Interactions
diff --git a/packages/storybook/src/spright/chat/conversation/chat-conversation.stories.ts b/packages/storybook/src/spright/chat/conversation/chat-conversation.stories.ts
index ec113ddd27..28535d017a 100644
--- a/packages/storybook/src/spright/chat/conversation/chat-conversation.stories.ts
+++ b/packages/storybook/src/spright/chat/conversation/chat-conversation.stories.ts
@@ -45,6 +45,7 @@ import { ExampleWelcomeSlotContent } from '../message/types';
interface ChatConversationArgs {
appearance: keyof typeof ChatConversationAppearance;
+ autoScroll: boolean;
content: string;
toolbar: boolean;
start: boolean;
@@ -71,7 +72,7 @@ export const chatConversation: StoryObj = {
max-height: 750px;
}
- <${chatConversationTag} ${ref('conversationRef')} appearance="${x => x.appearance}">
+ <${chatConversationTag} ${ref('conversationRef')} appearance="${x => x.appearance}" ?auto-scroll="${x => x.autoScroll}">
${when(x => x.toolbar, html`
<${toolbarTag} slot='toolbar'>
<${iconMessagesSparkleTag} slot="start">${iconMessagesSparkleTag}>
@@ -155,6 +156,12 @@ export const chatConversation: StoryObj = {
description: 'The appearance of the chat conversation.',
table: { category: apiCategory.attributes }
},
+ autoScroll: {
+ name: 'auto-scroll',
+ description: 'Enables or disables all system-initiated scrolling. User-initiated scrolling is unaffected.',
+ control: { type: 'boolean' },
+ table: { category: apiCategory.attributes }
+ },
content: {
name: 'default',
description:
@@ -186,6 +193,7 @@ export const chatConversation: StoryObj = {
},
args: {
appearance: 'default',
+ autoScroll: true,
input: true,
toolbar: true,
start: true,