diff --git a/change/@ni-spright-angular-2188570c-9255-47e7-8f52-a34d062b7b81.json b/change/@ni-spright-angular-2188570c-9255-47e7-8f52-a34d062b7b81.json new file mode 100644 index 0000000000..2d6aff4bc0 --- /dev/null +++ b/change/@ni-spright-angular-2188570c-9255-47e7-8f52-a34d062b7b81.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add auto-scroll attribute to conversation.", + "packageName": "@ni/spright-angular", + "email": "163188334+Alexia-Claudia-Micu@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-spright-blazor-08d16ce6-2034-41f4-9374-2a2fed2ee178.json b/change/@ni-spright-blazor-08d16ce6-2034-41f4-9374-2a2fed2ee178.json new file mode 100644 index 0000000000..c6362a48e4 --- /dev/null +++ b/change/@ni-spright-blazor-08d16ce6-2034-41f4-9374-2a2fed2ee178.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add autoscroll to chat conversation", + "packageName": "@ni/spright-blazor", + "email": "jattasNI@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-spright-components-98a40075-e551-4376-aa9a-f3a3fb1d4c4a.json b/change/@ni-spright-components-98a40075-e551-4376-aa9a-f3a3fb1d4c4a.json new file mode 100644 index 0000000000..d5bb5c0ad7 --- /dev/null +++ b/change/@ni-spright-components-98a40075-e551-4376-aa9a-f3a3fb1d4c4a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add scrolling behavior to chat.", + "packageName": "@ni/spright-components", + "email": "163188334+Alexia-Claudia-Micu@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/angular-workspace/example-client-app/src/app/customapp/chat-conversation-section.component.ts b/packages/angular-workspace/example-client-app/src/app/customapp/chat-conversation-section.component.ts index 0d7022d7d1..871cb817da 100644 --- a/packages/angular-workspace/example-client-app/src/app/customapp/chat-conversation-section.component.ts +++ b/packages/angular-workspace/example-client-app/src/app/customapp/chat-conversation-section.component.ts @@ -1,61 +1,193 @@ -import { Component } from '@angular/core'; +import { Component, type OnDestroy } from '@angular/core'; import type { ChatInputSendEventDetail } from '@ni/spright-angular/chat/input'; +interface ChatEntry { + type: 'user' | 'advisor' | 'system'; + text: string; + streaming: boolean; +} + +const singleResponse = `To configure your Python version, select Adapters from the Configure menu. +Configure the Python adapter. Choose the desired version from the Version dropdown. +You can also specify a Python version for a specific module call in the Advanced Settings of the Python adapter. +Additionally, you can set environment variables in the adapter configuration to control runtime behavior. +This gives you fine-grained control over which interpreter is used per step in your test sequence. +If you have multiple virtual environments, make sure to point the adapter to the correct executable path. +The path must be absolute and should not contain spaces unless properly quoted. +For further reference, consult the NI TestStand help documentation under the Python Adapter section.`; + +const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/); + @Component({ selector: 'example-chat-conversation-section', template: ` - - - - AI Assistant - - Create new chat - - - - - Title of the banner +
+ + + + AI Assistant + + Create new chat + + + + + Title of the banner This is the message text of this banner. It tells you something interesting. - - To start, press any key. - Where is the Any key? - - - - - - - Copy - - -
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
- Order a tab - Check core temperature -
- @for (message of chatUserMessages; track message) { - {{message}} - } - - - AI-generated content may be incorrect. - View Terms and Conditions - -
+ + To start, press any key. + Where is the Any key? + + + + + + + Copy + + +
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+ Order a tab + Check core temperature +
+ @for (message of staticUserMessages; track message) { + {{message}} + } + + + AI-generated content may be incorrect. + View Terms and Conditions + + + +
+ + + + + + AI Assistant (Streaming) + + @for (entry of messages; track entry) { + @if (entry.type === 'user') { + + {{entry.text}} + + } + @if (entry.type === 'system') { + + + + } + @if (entry.type === 'advisor') { + + {{entry.text}} + @if (!entry.streaming) { + + + Copy + + } + + } + } + + AI-generated content may be incorrect. + +
+
`, styles: [` - spright-chat-conversation { max-width: 700px; } - spright-chat-message span { white-space: pre-wrap; } + .conversations { + display: flex; + gap: 16px; + } + spright-chat-conversation { + width: 700px; + height: 650px; + } + .streaming-section { + display: flex; + flex-direction: column; + gap: 8px; + } + .response-label { + font: var(--ni-nimble-body-font); + color: var(--ni-nimble-body-font-color); + } + .response-input { + font: var(--ni-nimble-body-font); + color: var(--ni-nimble-body-font-color); + background: var(--ni-nimble-fill-secondary-color); + border: 1px solid var(--ni-nimble-border-color); + padding: 4px; + width: 700px; + resize: vertical; + } + spright-chat-message-outbound span, + spright-chat-message-inbound span { white-space: pre-wrap; } `], standalone: false }) -export class ChatConversationSectionComponent { - public chatUserMessages: string[] = []; +export class ChatConversationSectionComponent implements OnDestroy { + public staticUserMessages: string[] = []; + public messages: ChatEntry[] = []; + public isStreaming = false; + + private customAdvisorText = ''; + private streamInterval: ReturnType | null = null; + + public onCustomAdvisorTextInput(e: Event): void { + this.customAdvisorText = (e.target as HTMLTextAreaElement).value; + } + + public onStaticChatInputSend(e: Event): void { + this.staticUserMessages.push((e as CustomEvent).detail.text); + } public onChatInputSend(e: Event): void { - const chatInputSendEvent = (e as CustomEvent); - this.chatUserMessages.push(chatInputSendEvent.detail.text); + const text = (e as CustomEvent).detail.text; + this.messages.push({ type: 'user', text, streaming: false }); + this.startStreaming(); + } + + public ngOnDestroy(): void { + if (this.streamInterval !== null) { + clearInterval(this.streamInterval); + } + } + + private startStreaming(): void { + this.isStreaming = true; + const spinnerEntry: ChatEntry = { type: 'system', text: '', streaming: true }; + this.messages.push(spinnerEntry); + + const responseWords = this.customAdvisorText.trim().length > 0 + ? this.customAdvisorText.trim().split(/\s+/) + : cannedResponseWords; + let wordIndex = 0; + setTimeout(() => { + const idx = this.messages.indexOf(spinnerEntry); + if (idx !== -1) { + this.messages.splice(idx, 1); + } + const advisorEntry: ChatEntry = { type: 'advisor', text: '', streaming: true }; + this.messages.push(advisorEntry); + + this.streamInterval = setInterval(() => { + if (wordIndex < responseWords.length) { + advisorEntry.text += (wordIndex === 0 ? '' : ' ') + responseWords[wordIndex]; + wordIndex += 1; + } else { + clearInterval(this.streamInterval!); + this.streamInterval = null; + advisorEntry.streaming = false; + this.isStreaming = false; + } + }, 30); + }, 300); } } diff --git a/packages/angular-workspace/spright-angular/chat/conversation/spright-chat-conversation.directive.ts b/packages/angular-workspace/spright-angular/chat/conversation/spright-chat-conversation.directive.ts index 51e5fc5955..072de0f693 100644 --- a/packages/angular-workspace/spright-angular/chat/conversation/spright-chat-conversation.directive.ts +++ b/packages/angular-workspace/spright-angular/chat/conversation/spright-chat-conversation.directive.ts @@ -1,4 +1,5 @@ -import { Directive } from '@angular/core'; +import { Directive, ElementRef, Input, Renderer2 } from '@angular/core'; +import { toBooleanProperty, type BooleanValueOrAttribute } from '@ni/nimble-angular/internal-utilities'; import { type ChatConversation, chatConversationTag } from '@ni/spright-components/dist/esm/chat/conversation'; export type { ChatConversation }; @@ -11,4 +12,14 @@ export { chatConversationTag }; selector: 'spright-chat-conversation', standalone: false }) -export class SprightChatConversationDirective { } +export class SprightChatConversationDirective { + public get autoScroll(): boolean { + return this.elementRef.nativeElement.autoScroll; + } + + @Input('auto-scroll') public set autoScroll(value: BooleanValueOrAttribute) { + this.renderer.setProperty(this.elementRef.nativeElement, 'autoScroll', toBooleanProperty(value)); + } + + public constructor(private readonly renderer: Renderer2, private readonly elementRef: ElementRef) {} +} diff --git a/packages/angular-workspace/spright-angular/chat/conversation/tests/spright-chat-conversation.directive.spec.ts b/packages/angular-workspace/spright-angular/chat/conversation/tests/spright-chat-conversation.directive.spec.ts index 0bbe863061..42bfc034a0 100644 --- a/packages/angular-workspace/spright-angular/chat/conversation/tests/spright-chat-conversation.directive.spec.ts +++ b/packages/angular-workspace/spright-angular/chat/conversation/tests/spright-chat-conversation.directive.spec.ts @@ -1,5 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SprightChatConversationModule } from '../spright-chat-conversation.module'; +import { SprightChatConversationDirective, type ChatConversation } from '../spright-chat-conversation.directive'; describe('Spright chat conversation', () => { describe('module', () => { @@ -13,4 +15,77 @@ describe('Spright chat conversation', () => { expect(customElements.get('spright-chat-conversation')).not.toBeUndefined(); }); }); + + describe('with no values in template', () => { + @Component({ + template: ` + + `, + standalone: false + }) + class TestHostComponent { + @ViewChild('chatConversation', { read: SprightChatConversationDirective }) public directive: SprightChatConversationDirective; + @ViewChild('chatConversation', { read: ElementRef }) public elementRef: ElementRef; + } + + let fixture: ComponentFixture; + let directive: SprightChatConversationDirective; + let nativeElement: ChatConversation; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestHostComponent], + imports: [SprightChatConversationModule] + }); + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + directive = fixture.componentInstance.directive; + nativeElement = fixture.componentInstance.elementRef.nativeElement; + }); + + it('has expected defaults for autoScroll', () => { + expect(directive.autoScroll).toBeFalse(); + expect(nativeElement.autoScroll).toBeFalse(); + }); + + it('can use the directive to set autoScroll', () => { + directive.autoScroll = true; + expect(nativeElement.autoScroll).toBeTrue(); + }); + }); + + describe('with template string values', () => { + @Component({ + template: ` + + `, + standalone: false + }) + class TestHostComponent { + @ViewChild('chatConversation', { read: SprightChatConversationDirective }) public directive: SprightChatConversationDirective; + @ViewChild('chatConversation', { read: ElementRef }) public elementRef: ElementRef; + } + + let fixture: ComponentFixture; + let directive: SprightChatConversationDirective; + let nativeElement: ChatConversation; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestHostComponent], + imports: [SprightChatConversationModule] + }); + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + directive = fixture.componentInstance.directive; + nativeElement = fixture.componentInstance.elementRef.nativeElement; + }); + + it('will use template string values for autoScroll', () => { + expect(directive.autoScroll).toBeTrue(); + expect(nativeElement.autoScroll).toBeTrue(); + }); + }); }); diff --git a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor index b7d571e1ce..818c9b5331 100644 --- a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor @@ -1,56 +1,242 @@ @namespace Demo.Shared.Pages.Sections +@implements IAsyncDisposable
- - - Title of the banner - This is the message text of this banner. It tells you something interesting. - - - To start, press any key. - - - Where is the Any key? - - - - - - - - Copy - - -
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
- - Order a tab - - - Check core temperature - -
- @foreach(var message in _userMessages) - { +
+ + + + AI Assistant + + Create new chat + + + + + Title of the banner + This is the message text of this banner. It tells you something interesting. + + + To start, press any key. + - @message + Where is the Any key? - } - - - AI-generated content may be incorrect. - View Terms and Conditions - - + + + + + + + Copy + + +
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+ + Order a tab + + + Check core temperature + +
+ @foreach(var message in _staticUserMessages) + { + + @message + + } + + + AI-generated content may be incorrect. + View Terms and Conditions + + + +
+ + + + + + AI Assistant (Streaming) + + @foreach (var entry in _messages) + { + @if (entry.Type == "user") + { + + @entry.Text + + } + @if (entry.Type == "system") + { + + + + } + @if (entry.Type == "advisor") + { + + @entry.Text + @if (!entry.Streaming) + { + + + Copy + + } + + } + } + + AI-generated content may be incorrect. + +
+
+ + @code { - private List _userMessages = new List(); + private record ChatEntry(string Type, string Text, bool Streaming); + + private const string SingleResponse = @"To configure your Python version, select Adapters from the Configure menu. +Configure the Python adapter. Choose the desired version from the Version dropdown. +You can also specify a Python version for a specific module call in the Advanced Settings of the Python adapter. +Additionally, you can set environment variables in the adapter configuration to control runtime behavior. +This gives you fine-grained control over which interpreter is used per step in your test sequence. +If you have multiple virtual environments, make sure to point the adapter to the correct executable path. +The path must be absolute and should not contain spaces unless properly quoted. +For further reference, consult the NI TestStand help documentation under the Python Adapter section."; + + private static readonly string[] CannedResponseWords = + Enumerable.Repeat(SingleResponse, 5) + .Aggregate((a, b) => a + "\n" + b) + .Split(new[] { ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries); + + private List _staticUserMessages = new(); + private List _messages = new(); + private bool _isStreaming = false; + private string? _customAdvisorText; + private PeriodicTimer? _streamTimer; + + public void OnCustomAdvisorTextInput(ChangeEventArgs e) + { + _customAdvisorText = e.Value?.ToString() ?? string.Empty; + } + + public void OnStaticChatInputSend(ChatInputSendEventArgs e) + { + _staticUserMessages.Add(e.Text); + } + + public void OnChatInputSend(ChatInputSendEventArgs e) + { + var text = e.Text; + _messages.Add(new ChatEntry("user", text, false)); + _ = StartStreamingAsync(); + } + + private async Task StartStreamingAsync() + { + _isStreaming = true; + var spinnerEntry = new ChatEntry("system", "", true); + _messages.Add(spinnerEntry); + StateHasChanged(); + + var responseWords = (string.IsNullOrWhiteSpace(_customAdvisorText) + ? CannedResponseWords + : _customAdvisorText.Trim().Split(new[] { ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)) + .ToArray(); - public void UpdateUserMessages(ChatInputSendEventArgs e) + await Task.Delay(300); + + _messages.Remove(spinnerEntry); + var advisorEntry = new ChatEntry("advisor", "", true); + _messages.Add(advisorEntry); + StateHasChanged(); + + var wordIndex = 0; + _streamTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(30)); + + try + { + while (await _streamTimer.WaitForNextTickAsync()) + { + if (wordIndex < responseWords.Length) + { + advisorEntry = new ChatEntry( + "advisor", + advisorEntry.Text + (wordIndex == 0 ? "" : " ") + responseWords[wordIndex], + true + ); + var idx = _messages.FindIndex(m => m.Type == "advisor" && m.Streaming); + if (idx != -1) + { + _messages[idx] = advisorEntry; + } + wordIndex++; + StateHasChanged(); + } + else + { + advisorEntry = new ChatEntry("advisor", advisorEntry.Text, false); + var idx = _messages.FindIndex(m => m.Type == "advisor" && m.Streaming); + if (idx != -1) + { + _messages[idx] = advisorEntry; + } + _isStreaming = false; + _streamTimer?.Dispose(); + StateHasChanged(); + break; + } + } + } + finally + { + _streamTimer?.Dispose(); + _streamTimer = null; + } + } + + async ValueTask IAsyncDisposable.DisposeAsync() { - _userMessages.Add(e.Text); + if (_streamTimer != null) + { + _streamTimer.Dispose(); + } + + await ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/packages/blazor-workspace/SprightBlazor/Source/Chat/Conversation/SprightChatConversation.razor b/packages/blazor-workspace/SprightBlazor/Source/Chat/Conversation/SprightChatConversation.razor index 9e59d0a579..11bc012b9e 100644 --- a/packages/blazor-workspace/SprightBlazor/Source/Chat/Conversation/SprightChatConversation.razor +++ b/packages/blazor-workspace/SprightBlazor/Source/Chat/Conversation/SprightChatConversation.razor @@ -1,5 +1,6 @@ @namespace SprightBlazor @ChildContent diff --git a/packages/blazor-workspace/SprightBlazor/Source/Chat/Conversation/SprightChatConversation.razor.cs b/packages/blazor-workspace/SprightBlazor/Source/Chat/Conversation/SprightChatConversation.razor.cs index ff7d63aaa7..53fb92e27d 100644 --- a/packages/blazor-workspace/SprightBlazor/Source/Chat/Conversation/SprightChatConversation.razor.cs +++ b/packages/blazor-workspace/SprightBlazor/Source/Chat/Conversation/SprightChatConversation.razor.cs @@ -4,6 +4,9 @@ namespace SprightBlazor; public partial class SprightChatConversation : ComponentBase { + [Parameter] + public bool? AutoScroll { get; set; } + /// /// The child content of the element. /// diff --git a/packages/blazor-workspace/Tests/SprightBlazor.Tests/Unit/Components/SprightChatConversationTests.cs b/packages/blazor-workspace/Tests/SprightBlazor.Tests/Unit/Components/SprightChatConversationTests.cs index f8faf480ff..8b69981f69 100644 --- a/packages/blazor-workspace/Tests/SprightBlazor.Tests/Unit/Components/SprightChatConversationTests.cs +++ b/packages/blazor-workspace/Tests/SprightBlazor.Tests/Unit/Components/SprightChatConversationTests.cs @@ -1,4 +1,6 @@ -using Bunit; +using System; +using System.Linq.Expressions; +using Bunit; using Xunit; namespace SprightBlazor.Tests.Unit.Components; @@ -41,4 +43,19 @@ public void SprightChatConversation_WithToolbar_RendersContent() Assert.Contains("slot=\"toolbar\"", component.Markup); Assert.Contains("Toolbar Button", component.Markup); } + + [Fact] + public void SprightChatConversation_AutoScroll_AttributeIsSet() + { + var component = RenderWithPropertySet(x => x.AutoScroll, true); + + Assert.Contains("auto-scroll", component.Markup); + } + + private static IRenderedComponent RenderWithPropertySet(Expression> propertyGetter, TProperty propertyValue) + { + var context = new TestContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + return context.RenderComponent(p => p.Add(propertyGetter, propertyValue)); + } } diff --git a/packages/react-workspace/react-client-app/src/components/ChatConversationSection.tsx b/packages/react-workspace/react-client-app/src/components/ChatConversationSection.tsx index ba38eeeff8..0a28489d95 100644 --- a/packages/react-workspace/react-client-app/src/components/ChatConversationSection.tsx +++ b/packages/react-workspace/react-client-app/src/components/ChatConversationSection.tsx @@ -1,10 +1,13 @@ -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { NimbleAnchor } from '@ni/nimble-react/anchor'; import { NimbleButton } from '@ni/nimble-react/button'; import { NimbleBanner } from '@ni/nimble-react/banner'; import { NimbleSpinner } from '@ni/nimble-react/spinner'; +import { NimbleToolbar } from '@ni/nimble-react/toolbar'; import { NimbleIconCopyText } from '@ni/nimble-react/icons/copy-text'; import { NimbleIconWebviCustom } from '@ni/nimble-react/icons/webvi-custom'; +import { NimbleIconMessagesSparkle } from '@ni/nimble-react/icons/messages-sparkle'; +import { NimbleIconPencilToRectangle } from '@ni/nimble-react/icons/pencil-to-rectangle'; import { SprightChatConversation } from '@ni/spright-react/chat/conversation'; import { SprightChatInput } from '@ni/spright-react/chat/input'; import { SprightChatMessageInbound } from '@ni/spright-react/chat/message/inbound'; @@ -12,52 +15,205 @@ import { SprightChatMessageOutbound } from '@ni/spright-react/chat/message/outbo import { SprightChatMessageSystem } from '@ni/spright-react/chat/message/system'; import { SubContainer } from './SubContainer'; +interface ChatEntry { + type: 'user' | 'advisor' | 'system'; + text: string; + streaming: boolean; +} + +const singleResponse = `To configure your Python version, select Adapters from the Configure menu. +Configure the Python adapter. Choose the desired version from the Version dropdown. +You can also specify a Python version for a specific module call in the Advanced Settings of the Python adapter. +Additionally, you can set environment variables in the adapter configuration to control runtime behavior. +This gives you fine-grained control over which interpreter is used per step in your test sequence. +If you have multiple virtual environments, make sure to point the adapter to the correct executable path. +The path must be absolute and should not contain spaces unless properly quoted. +For further reference, consult the NI TestStand help documentation under the Python Adapter section.`; + +const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/); + export function ChatConversationSection(): React.JSX.Element { - const [chatUserMessages, setChatUserMessages] = useState([]); + const [staticChatUserMessages, setStaticChatUserMessages] = useState([]); + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [customAdvisorText, setCustomAdvisorText] = useState(''); + const streamIntervalRef = useRef | null>(null); + + useEffect((): (() => void) => { + return () => { + if (streamIntervalRef.current !== null) { + clearInterval(streamIntervalRef.current); + } + }; + }, []); + + function onStaticChatInputSend(event: CustomEvent<{ text: string }>): void { + const text = event.detail.text; + setStaticChatUserMessages(prevMessages => [...prevMessages, text]); + } function onChatInputSend(event: CustomEvent<{ text: string }>): void { const text = event.detail.text; - setChatUserMessages(prevMessages => [...prevMessages, text]); + setMessages(prevMessages => [...prevMessages, { type: 'user', text, streaming: false }]); + startStreaming(); + } + + function startStreaming(): void { + setIsStreaming(true); + const spinnerEntry: ChatEntry = { type: 'system', text: '', streaming: true }; + setMessages(prevMessages => [...prevMessages, spinnerEntry]); + + const responseWords = customAdvisorText.trim().length > 0 + ? customAdvisorText.trim().split(/\s+/) + : cannedResponseWords; + let wordIndex = 0; + + setTimeout(() => { + setMessages(prevMessages => { + const newMessages = prevMessages.filter(m => !(m.type === 'system' && m.streaming)); + return [...newMessages, { type: 'advisor', text: '', streaming: true }]; + }); + + streamIntervalRef.current = setInterval(() => { + setMessages(prevMessages => { + const advisorMessageIndex = prevMessages.findIndex(m => m.type === 'advisor' && m.streaming); + if (advisorMessageIndex === -1) { + return prevMessages; + } + + if (wordIndex < responseWords.length) { + const newMessages = [...prevMessages]; + const currentMessage = newMessages[advisorMessageIndex]; + newMessages[advisorMessageIndex] = { + ...currentMessage, + text: currentMessage.text + (wordIndex === 0 ? '' : ' ') + responseWords[wordIndex] + }; + wordIndex += 1; + return newMessages; + } + + clearInterval(streamIntervalRef.current ?? 0); + streamIntervalRef.current = null; + const newMessages = [...prevMessages]; + newMessages[advisorMessageIndex] = { + ...newMessages[advisorMessageIndex], + streaming: false + }; + setIsStreaming(false); + return newMessages; + }); + }, 30); + }, 300); } return ( - - - Title of the banner - This is the message text of this banner. It tells you something interesting. - - To start, press any key. - Where is the Any key? - - - - - - - Copy - - -
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
- Order a tab - Check core temperature -
- {chatUserMessages.map((message, index) => ( - - {message} - - ))} - - - AI-generated content may be incorrect. - View Terms and Conditions - -
+
+ + + + AI Assistant + + Create new chat + + + + + Title of the banner + This is the message text of this banner. It tells you something interesting. + + To start, press any key. + Where is the Any key? + + + + + + + Copy + + +
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+ Order a tab + Check core temperature +
+ {staticChatUserMessages.map((message, index) => ( + + {message} + + ))} + + + AI-generated content may be incorrect. + View Terms and Conditions + +
+ +
+ + + + + + AI Assistant (Streaming) + + {messages.map((entry, index) => { + if (entry.type === 'user') { + return ( + + {entry.text} + + ); + } + if (entry.type === 'system') { + return ( + + + + ); + } + return ( + + {entry.text} + {!entry.streaming && ( + + + Copy + + )} + + ); + })} + + AI-generated content may be incorrect. + +
+
); } diff --git a/packages/spright-components/src/chat/conversation/index.ts b/packages/spright-components/src/chat/conversation/index.ts index a4ffd05296..4a3be0894b 100644 --- a/packages/spright-components/src/chat/conversation/index.ts +++ b/packages/spright-components/src/chat/conversation/index.ts @@ -3,6 +3,7 @@ import { attr, observable } from '@ni/fast-element'; import { styles } from './styles'; import { template } from './template'; import { ChatConversationAppearance } from './types'; +import { ChatConversationScrollManager } from './scroll-manager'; declare global { interface HTMLElementTagNameMap { @@ -17,6 +18,9 @@ export class ChatConversation extends FoundationElement { @attr public appearance = ChatConversationAppearance.default; + @attr({ attribute: 'auto-scroll', mode: 'boolean' }) + public autoScroll = false; + /** @internal */ @observable public inputEmpty = true; @@ -49,6 +53,34 @@ export class ChatConversation extends FoundationElement { @observable public readonly slottedEndElements?: HTMLElement[]; + /** @internal */ + public messagesContainer: HTMLElement | null = null; + + /** @internal */ + public defaultSlot!: HTMLSlotElement; + + private scrollManager: ChatConversationScrollManager | null = null; + + public override connectedCallback(): void { + super.connectedCallback(); + if (this.autoScroll) { + this.connectScrollManager(); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.disconnectScrollManager(); + } + + public autoScrollChanged(_prev: boolean, next: boolean): void { + if (next && this.isConnected) { + this.connectScrollManager(); + } else { + this.disconnectScrollManager(); + } + } + public slottedInputElementsChanged( _prev: HTMLElement[] | undefined, next: HTMLElement[] | undefined @@ -76,6 +108,20 @@ export class ChatConversation extends FoundationElement { ): void { this.endEmpty = next === undefined || next.length === 0; } + + private connectScrollManager(): void { + this.scrollManager = new ChatConversationScrollManager( + this.messagesContainer!, + this, + this.defaultSlot + ); + this.scrollManager.connect(); + } + + private disconnectScrollManager(): void { + this.scrollManager?.disconnect(); + this.scrollManager = null; + } } const sprightChatConversation = ChatConversation.compose({ diff --git a/packages/spright-components/src/chat/conversation/scroll-manager.ts b/packages/spright-components/src/chat/conversation/scroll-manager.ts new file mode 100644 index 0000000000..2c754a4c44 --- /dev/null +++ b/packages/spright-components/src/chat/conversation/scroll-manager.ts @@ -0,0 +1,224 @@ +import { mediumPadding } from '@ni/nimble-components/dist/esm/theme-provider/design-tokens'; +import { chatMessageOutboundTag } from '../message/outbound'; + +/** + * Encapsulates all scroll management logic for the ChatConversation component. + */ +export class ChatConversationScrollManager { + // Distance from the bottom (px) within which the user is considered "at the bottom". + private readonly scrollingPixelThreshold = 10; + // True when the user has manually scrolled up; suppresses auto-scroll until they return to the bottom. + private isUserScrolledUp = false; + // Deduplicates multiple mutation callbacks within the same animation frame. + private scrollPending = false; + // Set when a new outbound message is detected; causes the next rAF to scroll to that message's top. + private scrollToUserMessagePending = false; + // Current bottom padding (px) applied to the scroll container to hold the user message in place. + private bottomPaddingPx = 0; + // The scrollTop at which the last user message sits at the top of the viewport. + private userMessageScrollTop = 0; + // ScrollTop recorded after the last scroll event, used to detect scroll direction. + private previousScrollTop = 0; + // True while a programmatic smooth scroll is in progress; prevents treating it as a user scroll. + private programmaticScrolling = false; + private resizeObserver: ResizeObserver | null = null; + private mutationObserver: MutationObserver | null = null; + private slotChangeHandler: (() => void) | null = null; + + public constructor( + private readonly container: HTMLElement, + private readonly hostElement: HTMLElement, + private readonly defaultSlot: HTMLSlotElement + ) {} + + public connect(): void { + this.previousScrollTop = this.container.scrollTop; + this.container.addEventListener('scroll', this.onScroll, { passive: true }); + this.container.addEventListener('scrollend', this.onScrollEnd, { passive: true }); + this.setupResizeObserver(); + this.setupMutationObserver(); + } + + public disconnect(): void { + this.container.removeEventListener('scroll', this.onScroll); + this.container.removeEventListener('scrollend', this.onScrollEnd); + if (this.slotChangeHandler) { + this.defaultSlot.removeEventListener('slotchange', this.slotChangeHandler); + this.slotChangeHandler = null; + } + this.resizeObserver?.disconnect(); + this.mutationObserver?.disconnect(); + } + + private setupMutationObserver(): void { + this.mutationObserver = new MutationObserver(mutations => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (this.isUserMessage(node as Element)) { + this.scrollToUserMessagePending = true; + } + } + } + if (!this.scrollPending) { + this.scrollPending = true; + requestAnimationFrame(() => { + this.scrollPending = false; + if (this.scrollToUserMessagePending) { + this.scrollToUserMessagePending = false; + this.scrollToLastMessageTop(); + } else if (!this.isUserScrolledUp) { + this.updatePaddingAndScroll(); + } + }); + } + }); + this.mutationObserver.observe(this.hostElement, { childList: true, subtree: true, characterData: true }); + } + + /** + * Observes the scroll container and the last slotted element for size changes. + * Reconnects the observer whenever the slot assignment changes so new messages are tracked. + */ + private setupResizeObserver(): void { + this.resizeObserver = new ResizeObserver(() => { + if (!this.isUserScrolledUp) { + this.updatePaddingAndScroll(); + } + }); + this.resizeObserver.observe(this.container); + this.slotChangeHandler = () => { + this.resizeObserver?.disconnect(); + if (this.container instanceof Element) { + this.resizeObserver?.observe(this.container); + } + const assigned = this.defaultSlot.assignedElements({ flatten: true }); + const lastEl = assigned[assigned.length - 1]; + if (lastEl) { + this.resizeObserver?.observe(lastEl); + } + }; + this.defaultSlot.addEventListener('slotchange', this.slotChangeHandler); + } + + /** + * Shrinks the bottom padding incrementally as content grows to fill the space below + * the pinned user message. Once padding reaches zero, scrolls to the bottom normally. + */ + private updatePaddingAndScroll(): void { + if (this.userMessageScrollTop > 0 && this.bottomPaddingPx > 0) { + const contentHeight = this.container.scrollHeight - this.bottomPaddingPx; + const neededPadding = Math.max(0, this.userMessageScrollTop + this.container.clientHeight - contentHeight); + if (neededPadding < this.bottomPaddingPx) { + this.container.style.paddingBottom = neededPadding > 0 ? `${neededPadding}px` : ''; + this.bottomPaddingPx = neededPadding; + } + } + if (this.bottomPaddingPx === 0) { + this.scrollToBottom(); + } + } + + private scrollToBottom(): void { + this.container.scrollTop = this.container.scrollHeight; + } + + private isUserMessage(element: Element): boolean { + return element.tagName?.toLowerCase() === chatMessageOutboundTag; + } + + private getLastOutboundMessage(): HTMLElement | null { + const assigned = this.defaultSlot.assignedElements({ flatten: true }); + return Array.from(assigned).reverse().find(el => this.isUserMessage(el)) as HTMLElement | null; + } + + /** + * Positions the last outbound message at the top of the visible viewport. + * Uses bottom padding to prevent the content from being pushed up as the + * response grows below. + */ + private scrollToLastMessageTop(): void { + const messageElement = this.getLastOutboundMessage(); + if (!messageElement) { + return; + } + void this.container.scrollHeight; + const containerRect = this.container.getBoundingClientRect(); + const messageRect = messageElement.getBoundingClientRect(); + const messageTopInContent = this.container.scrollTop + (messageRect.top - containerRect.top); + + this.isUserScrolledUp = false; + + if (messageRect.height >= this.container.clientHeight / 2) { + this.scrollToBottomOfTallMessage(messageTopInContent, messageRect.height); + } else if (messageTopInContent <= 0) { + this.resetPaddingForTopAlignedMessage(); + } else { + this.scrollToMessageWithPadding(messageTopInContent, messageRect.height); + } + } + + private scrollToBottomOfTallMessage(messageTopInContent: number, messageHeight: number): void { + const lineGap = Math.round(this.container.clientHeight * 0.2); + const scrollTarget = Math.max(0, messageTopInContent + messageHeight - lineGap); + const padding = this.container.clientHeight - lineGap; + + this.bottomPaddingPx = padding; + this.userMessageScrollTop = scrollTarget; + this.container.style.paddingBottom = padding > 0 ? `${padding}px` : ''; + void this.container.scrollHeight; + + this.previousScrollTop = scrollTarget; + this.programmaticScrolling = true; + this.container.scrollTo({ top: scrollTarget, behavior: 'smooth' }); + } + + private resetPaddingForTopAlignedMessage(): void { + if (this.bottomPaddingPx > 0) { + this.container.style.paddingBottom = ''; + this.bottomPaddingPx = 0; + this.userMessageScrollTop = 0; + } + } + + private scrollToMessageWithPadding(messageTopInContent: number, messageHeight: number): void { + const topMargin = parseFloat(mediumPadding.getValueFor(this.hostElement)); + const scrollTarget = Math.max(0, messageTopInContent - topMargin); + + if (scrollTarget === 0) { + this.resetPaddingForTopAlignedMessage(); + return; + } + + const contentWithoutPadding = messageTopInContent + messageHeight; + const newPadding = Math.max(0, scrollTarget + this.container.clientHeight - contentWithoutPadding); + const preventClampPadding = Math.max(0, this.container.scrollTop + this.container.clientHeight - contentWithoutPadding); + const safePadding = Math.max(newPadding, preventClampPadding); + + this.bottomPaddingPx = safePadding; + this.userMessageScrollTop = scrollTarget; + this.container.style.paddingBottom = safePadding > 0 ? `${safePadding}px` : ''; + void this.container.scrollHeight; + + this.previousScrollTop = scrollTarget; + this.programmaticScrolling = true; + this.container.scrollTo({ top: scrollTarget, behavior: 'smooth' }); + } + + private readonly onScrollEnd = (): void => { + this.programmaticScrolling = false; + }; + + private readonly onScroll = (): void => { + if (this.programmaticScrolling) { + return; + } + const currentScrollTop = this.container.scrollTop; + const distanceFromBottom = this.container.scrollHeight - this.bottomPaddingPx - currentScrollTop - this.container.clientHeight; + if (currentScrollTop < this.previousScrollTop && distanceFromBottom > this.scrollingPixelThreshold) { + this.isUserScrolledUp = true; + } else if (distanceFromBottom <= this.scrollingPixelThreshold) { + this.isUserScrolledUp = false; + } + this.previousScrollTop = currentScrollTop; + }; +} diff --git a/packages/spright-components/src/chat/conversation/template.ts b/packages/spright-components/src/chat/conversation/template.ts index 8f765464fc..2f0446a11a 100644 --- a/packages/spright-components/src/chat/conversation/template.ts +++ b/packages/spright-components/src/chat/conversation/template.ts @@ -1,4 +1,4 @@ -import { html, slotted } from '@ni/fast-element'; +import { html, ref, slotted } from '@ni/fast-element'; import type { ChatConversation } from '.'; export const template = html` @@ -8,7 +8,7 @@ export const template = html`
-
+
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"> @@ -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,