From b9e1d0838962e37170d58fd200a3bd7788095d66 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:53:09 +0300 Subject: [PATCH 01/23] Change files --- ...ht-components-98a40075-e551-4376-aa9a-f3a3fb1d4c4a.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@ni-spright-components-98a40075-e551-4376-aa9a-f3a3fb1d4c4a.json 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" +} From 9348c28db7ae6bb2b3f6f95708a6a9cdb154ff29 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:56:04 +0300 Subject: [PATCH 02/23] Add scrolling behavior to chat. --- .../chat-conversation-section.component.ts | 182 +++++++++++---- .../src/chat/conversation/index.ts | 24 ++ .../src/chat/conversation/scroll-manager.ts | 217 ++++++++++++++++++ .../src/chat/conversation/template.ts | 4 +- .../tests/chat-conversation.spec.ts | 171 ++++++++++++++ 5 files changed, 552 insertions(+), 46 deletions(-) create mode 100644 packages/spright-components/src/chat/conversation/scroll-manager.ts 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..2a29e8abe1 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,155 @@ -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) + + + + {{entry.text}} + + + + + + {{entry.text}} + + + 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; + } + 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 streamInterval: ReturnType | null = null; + + 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); + + 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 < cannedResponseWords.length) { + advisorEntry.text += (wordIndex === 0 ? '' : ' ') + cannedResponseWords[wordIndex]; + wordIndex += 1; + } else { + clearInterval(this.streamInterval!); + this.streamInterval = null; + advisorEntry.streaming = false; + this.isStreaming = false; + } + }, 30); + }, 300); } } diff --git a/packages/spright-components/src/chat/conversation/index.ts b/packages/spright-components/src/chat/conversation/index.ts index a4ffd05296..4c215d60ca 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 { @@ -49,6 +50,29 @@ export class ChatConversation extends FoundationElement { @observable public readonly slottedEndElements?: HTMLElement[]; + /** @internal */ + public messagesContainer: HTMLElement | null = null; + private scrollManager: ChatConversationScrollManager | null = null; + + public override connectedCallback(): void { + super.connectedCallback(); + const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])') as HTMLSlotElement | null; + if (this.messagesContainer && defaultSlot) { + this.scrollManager = new ChatConversationScrollManager( + this.messagesContainer, + this, + defaultSlot + ); + this.scrollManager.connect(); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.scrollManager?.disconnect(); + this.scrollManager = null; + } + public slottedInputElementsChanged( _prev: HTMLElement[] | undefined, next: HTMLElement[] | undefined 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..4cc7832240 --- /dev/null +++ b/packages/spright-components/src/chat/conversation/scroll-manager.ts @@ -0,0 +1,217 @@ +import { mediumPadding } from '@ni/nimble-components/dist/esm/theme-provider/design-tokens'; + +/** + * 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 ((node as Element).tagName?.toLowerCase() === 'spright-chat-message-outbound') { + 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(); + } + }); + } + }); + // childList catches new messages; subtree + characterData catches streaming text updates + 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] as Element | undefined; + 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 getLastOutboundMessage(): HTMLElement | null { + const assigned = this.defaultSlot.assignedElements({ flatten: true }); + for (let i = assigned.length - 1; i >= 0; i--) { + if (assigned[i]?.tagName.toLowerCase() === 'spright-chat-message-outbound') { + return assigned[i] as HTMLElement; + } + } + return 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) { + 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 targetScrollTop = messageTopInContent + messageHeight - this.container.clientHeight; + this.previousScrollTop = targetScrollTop; + this.programmaticScrolling = true; + this.container.scrollTo({ top: targetScrollTop, 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..1221874170 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..7af18b2d1d 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,175 @@ 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: '' }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + addEventListener: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + removeEventListener: () => {}, + ...overrides + }; + } + + it('marks user as scrolled up when scrolling up away from bottom', async () => { + await connect(); + 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', async () => { + await connect(); + 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', async () => { + await connect(); + 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; + }); + await connect(); + 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', async () => { + await connect(); + 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', async () => { + await connect(); + 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', async () => { + await connect(); + 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', async () => { + await connect(); + 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); + }); + }); }); From 60671fc7b1826f97ef9059326bb39750eac29af8 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:22:37 +0300 Subject: [PATCH 03/23] Code clean-up --- .../spright-components/src/chat/conversation/scroll-manager.ts | 2 +- .../src/chat/conversation/tests/chat-conversation.spec.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/spright-components/src/chat/conversation/scroll-manager.ts b/packages/spright-components/src/chat/conversation/scroll-manager.ts index 4cc7832240..9b48d22aaf 100644 --- a/packages/spright-components/src/chat/conversation/scroll-manager.ts +++ b/packages/spright-components/src/chat/conversation/scroll-manager.ts @@ -92,7 +92,7 @@ export class ChatConversationScrollManager { this.resizeObserver?.observe(this.container); } const assigned = this.defaultSlot.assignedElements({ flatten: true }); - const lastEl = assigned[assigned.length - 1] as Element | undefined; + const lastEl = assigned[assigned.length - 1]; if (lastEl) { this.resizeObserver?.observe(lastEl); } 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 7af18b2d1d..77f2bd47a9 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 @@ -106,9 +106,7 @@ describe('ChatConversation', () => { scrollHeight: 600, clientHeight: 300, style: { paddingBottom: '' }, - // eslint-disable-next-line @typescript-eslint/no-empty-function addEventListener: () => {}, - // eslint-disable-next-line @typescript-eslint/no-empty-function removeEventListener: () => {}, ...overrides }; From 71e56e885f819a809aaa472adb1f3e368ba63d90 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:40:05 +0300 Subject: [PATCH 04/23] Lint fix --- .../chat-conversation-section.component.ts | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) 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 2a29e8abe1..4d559bb795 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 @@ -67,21 +67,29 @@ const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/ AI Assistant (Streaming) - - - {{entry.text}} - - - - - - {{entry.text}} - - - Copy - - - + @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. From 934955b71d7fbeaf964048da69a39a1455749316 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:58:13 +0300 Subject: [PATCH 05/23] Some behaviour changes --- .../spright-chat-conversation.directive.ts | 14 ++++++- .../src/chat/conversation/index.ts | 6 ++- .../src/chat/conversation/scroll-manager.ts | 25 +++++++---- .../tests/chat-conversation.spec.ts | 41 +++++++++++++++++++ .../conversation/chat-conversation.stories.ts | 10 ++++- 5 files changed, 85 insertions(+), 11 deletions(-) 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..ef467f0d29 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,4 @@ -import { Directive } from '@angular/core'; +import { Directive, ElementRef, Input, Renderer2 } from '@angular/core'; import { type ChatConversation, chatConversationTag } from '@ni/spright-components/dist/esm/chat/conversation'; export type { ChatConversation }; @@ -11,4 +11,14 @@ export { chatConversationTag }; selector: 'spright-chat-conversation', standalone: false }) -export class SprightChatConversationDirective { } +export class SprightChatConversationDirective { + public get autoScroll(): boolean | undefined { + return this.elementRef.nativeElement.autoScroll; + } + + @Input('auto-scroll') public set autoScroll(value: boolean | undefined) { + this.renderer.setProperty(this.elementRef.nativeElement, 'autoScroll', value); + } + + public constructor(private readonly renderer: Renderer2, private readonly elementRef: ElementRef) {} +} diff --git a/packages/spright-components/src/chat/conversation/index.ts b/packages/spright-components/src/chat/conversation/index.ts index 4c215d60ca..9691e8509a 100644 --- a/packages/spright-components/src/chat/conversation/index.ts +++ b/packages/spright-components/src/chat/conversation/index.ts @@ -18,6 +18,9 @@ export class ChatConversation extends FoundationElement { @attr public appearance = ChatConversationAppearance.default; + @attr({ attribute: 'auto-scroll', mode: 'boolean' }) + public autoScroll = true; + /** @internal */ @observable public inputEmpty = true; @@ -61,7 +64,8 @@ export class ChatConversation extends FoundationElement { this.scrollManager = new ChatConversationScrollManager( this.messagesContainer, this, - defaultSlot + defaultSlot, + () => this.autoScroll ); this.scrollManager.connect(); } diff --git a/packages/spright-components/src/chat/conversation/scroll-manager.ts b/packages/spright-components/src/chat/conversation/scroll-manager.ts index 9b48d22aaf..be9858dc76 100644 --- a/packages/spright-components/src/chat/conversation/scroll-manager.ts +++ b/packages/spright-components/src/chat/conversation/scroll-manager.ts @@ -27,7 +27,8 @@ export class ChatConversationScrollManager { public constructor( private readonly container: HTMLElement, private readonly hostElement: HTMLElement, - private readonly defaultSlot: HTMLSlotElement + private readonly defaultSlot: HTMLSlotElement, + private readonly getAutoScroll: () => boolean ) {} public connect(): void { @@ -62,6 +63,9 @@ export class ChatConversationScrollManager { this.scrollPending = true; requestAnimationFrame(() => { this.scrollPending = false; + if (!this.getAutoScroll()) { + return; + } if (this.scrollToUserMessagePending) { this.scrollToUserMessagePending = false; this.scrollToLastMessageTop(); @@ -71,7 +75,6 @@ export class ChatConversationScrollManager { }); } }); - // childList catches new messages; subtree + characterData catches streaming text updates this.mutationObserver.observe(this.hostElement, { childList: true, subtree: true, characterData: true }); } @@ -81,7 +84,7 @@ export class ChatConversationScrollManager { */ private setupResizeObserver(): void { this.resizeObserver = new ResizeObserver(() => { - if (!this.isUserScrolledUp) { + if (!this.isUserScrolledUp && this.getAutoScroll()) { this.updatePaddingAndScroll(); } }); @@ -149,7 +152,7 @@ export class ChatConversationScrollManager { this.isUserScrolledUp = false; - if (messageRect.height >= this.container.clientHeight) { + if (messageRect.height >= this.container.clientHeight / 2) { this.scrollToBottomOfTallMessage(messageTopInContent, messageRect.height); } else if (messageTopInContent <= 0) { this.resetPaddingForTopAlignedMessage(); @@ -159,10 +162,18 @@ export class ChatConversationScrollManager { } private scrollToBottomOfTallMessage(messageTopInContent: number, messageHeight: number): void { - const targetScrollTop = messageTopInContent + messageHeight - this.container.clientHeight; - this.previousScrollTop = targetScrollTop; + 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: targetScrollTop, behavior: 'smooth' }); + this.container.scrollTo({ top: scrollTarget, behavior: 'smooth' }); } private resetPaddingForTopAlignedMessage(): void { 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 77f2bd47a9..0df5637641 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 @@ -240,5 +240,46 @@ describe('ChatConversation', () => { expect(container.scrollTop).toBe(container.scrollHeight); }); + + it('scrolls so that the last few lines of a tall outbound message appear at top of viewport', async () => { + await connect(); + 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', async () => { + await connect(); + 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/storybook/src/spright/chat/conversation/chat-conversation.stories.ts b/packages/storybook/src/spright/chat/conversation/chat-conversation.stories.ts index ec113ddd27..54d8dcecdc 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/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, From 99ea9e753055c7cdcbe570116e69da4533708ec3 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:05:58 +0300 Subject: [PATCH 06/23] Change files --- ...right-angular-2188570c-9255-47e7-8f52-a34d062b7b81.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@ni-spright-angular-2188570c-9255-47e7-8f52-a34d062b7b81.json 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" +} From b1868c8638e5ebf596a9b08fd48b2c924c956133 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:07:00 +0300 Subject: [PATCH 07/23] Update example app --- .../chat-conversation-section.component.ts | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) 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 4d559bb795..d5a9d30e1f 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 @@ -23,7 +23,7 @@ const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/ template: `
- + AI Assistant @@ -62,6 +62,9 @@ const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/ +
+ + @@ -93,6 +96,7 @@ const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/ AI-generated content may be incorrect. +
`, @@ -105,6 +109,24 @@ const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/ 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; } `], @@ -115,8 +137,13 @@ export class ChatConversationSectionComponent implements OnDestroy { 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); } @@ -138,6 +165,9 @@ export class ChatConversationSectionComponent implements OnDestroy { 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); @@ -148,8 +178,8 @@ export class ChatConversationSectionComponent implements OnDestroy { this.messages.push(advisorEntry); this.streamInterval = setInterval(() => { - if (wordIndex < cannedResponseWords.length) { - advisorEntry.text += (wordIndex === 0 ? '' : ' ') + cannedResponseWords[wordIndex]; + if (wordIndex < responseWords.length) { + advisorEntry.text += (wordIndex === 0 ? '' : ' ') + responseWords[wordIndex]; wordIndex += 1; } else { clearInterval(this.streamInterval!); From c48348b309ac2e8a52d78df6a1ffd4a486dbf563 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 13:27:14 -0500 Subject: [PATCH 08/23] Storybook minor text edits --- .../src/spright/chat/conversation/chat-conversation.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 54d8dcecdc..28535d017a 100644 --- a/packages/storybook/src/spright/chat/conversation/chat-conversation.stories.ts +++ b/packages/storybook/src/spright/chat/conversation/chat-conversation.stories.ts @@ -158,7 +158,7 @@ export const chatConversation: StoryObj = { }, autoScroll: { name: 'auto-scroll', - description: 'Enables/Disables all system-initiated scrolling. User-initiated scrolling is unaffected.', + description: 'Enables or disables all system-initiated scrolling. User-initiated scrolling is unaffected.', control: { type: 'boolean' }, table: { category: apiCategory.attributes } }, From b7259ade25937fd2263c6b5a75addf8f6f78e09c Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 13:29:57 -0500 Subject: [PATCH 09/23] Document auto-scroll in spec --- packages/spright-components/src/chat/specs/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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_ From 00032b2345a686510d6706aa0888234e1c75f9a1 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 13:32:11 -0500 Subject: [PATCH 10/23] Document reload scroll behavior --- .../spright-components/src/chat/specs/scrolling-behavior.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 2ca3e4538e2ef3fc9154967926fcea89e362f183 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 14:08:05 -0500 Subject: [PATCH 11/23] Change auto-scroll default value --- .../chat-conversation-section.component.ts | 4 +- .../src/chat/conversation/index.ts | 2 +- .../tests/chat-conversation.spec.ts | 263 +++++++++--------- 3 files changed, 137 insertions(+), 132 deletions(-) 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 d5a9d30e1f..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 @@ -23,7 +23,7 @@ const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/ template: `
- + AI Assistant @@ -65,7 +65,7 @@ const cannedResponseWords = Array(5).fill(singleResponse).join('\n').split(/\s+/
- + AI Assistant (Streaming) diff --git a/packages/spright-components/src/chat/conversation/index.ts b/packages/spright-components/src/chat/conversation/index.ts index 9691e8509a..fd998495fd 100644 --- a/packages/spright-components/src/chat/conversation/index.ts +++ b/packages/spright-components/src/chat/conversation/index.ts @@ -19,7 +19,7 @@ export class ChatConversation extends FoundationElement { public appearance = ChatConversationAppearance.default; @attr({ attribute: 'auto-scroll', mode: 'boolean' }) - public autoScroll = true; + public autoScroll = false; /** @internal */ @observable 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 0df5637641..e85d3f152e 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 @@ -135,151 +135,156 @@ describe('ChatConversation', () => { expect(scrollManager.isUserScrolledUp).toBeFalse(); }); - it('auto-scrolls to bottom when content updates and user has not scrolled up', async () => { + it('auto-scroll should default to false', async () => { await connect(); - const scrollManager = getScrollManager(element); - const container = createMockContainer({ scrollTop: 0, scrollHeight: 600, clientHeight: 300 }); - scrollManager.container = container; - scrollManager.updatePaddingAndScroll(); - - expect(container.scrollTop).toBe(600); + expect(element.autoScroll).toBeFalse(); }); - 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; + describe('with auto-scroll enabled', () => { + beforeEach(async () => { + await connect(); + element.autoScroll = true; }); - await connect(); - 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', async () => { - await connect(); - 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(); + 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(142); - }); - - it('sets bottom padding to keep outbound message scrollable when content is shorter than viewport', async () => { - await connect(); - 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', async () => { - await connect(); - 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; + expect(container.scrollTop).toBe(600); + }); - scrollManager.updatePaddingAndScroll(); + 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; + }); - expect(scrollManager.bottomPaddingPx).toBe(0); - expect(container.style.paddingBottom).toBe(''); - }); + const scrollManager = getScrollManager(element); + const container = createMockContainer({ scrollTop: 100 }); + scrollManager.container = container; + scrollManager.isUserScrolledUp = true; - it('auto-scrolls to bottom once all padding is removed as AI content fills the viewport', async () => { - await connect(); - 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; + element.appendChild(document.createTextNode('streaming text')); + await Promise.resolve(); + await Promise.resolve(); - scrollManager.updatePaddingAndScroll(); - - expect(container.scrollTop).toBe(container.scrollHeight); - }); + expect(container.scrollTop).toBe(100); + }); - it('scrolls so that the last few lines of a tall outbound message appear at top of viewport', async () => { - await connect(); - 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); + 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); + }); - scrollManager.scrollToLastMessageTop(); + 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'); + }); - expect(container.scrollTop).toBe(250); - }); + 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('sets bottom padding to clientHeight minus line gap for a tall outbound message', async () => { - await connect(); - 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); + 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); + }); - scrollManager.scrollToLastMessageTop(); + 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); + }); - expect(scrollManager.bottomPaddingPx).toBe(240); - expect(container.style.paddingBottom).toBe('240px'); + 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'); + }); }); }); }); From bd7d7e3916e7020b0d92477661fe688d09c1a94f Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 14:08:20 -0500 Subject: [PATCH 12/23] Directive boolean attribute and tests --- .../spright-chat-conversation.directive.ts | 7 +- ...pright-chat-conversation.directive.spec.ts | 77 ++++++++++++++++++- 2 files changed, 80 insertions(+), 4 deletions(-) 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 ef467f0d29..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, 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 }; @@ -12,12 +13,12 @@ export { chatConversationTag }; standalone: false }) export class SprightChatConversationDirective { - public get autoScroll(): boolean | undefined { + public get autoScroll(): boolean { return this.elementRef.nativeElement.autoScroll; } - @Input('auto-scroll') public set autoScroll(value: boolean | undefined) { - this.renderer.setProperty(this.elementRef.nativeElement, 'autoScroll', value); + @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(); + }); + }); }); From b78bf4ff3766055185359134a7d446698d5b3070 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 14:14:10 -0500 Subject: [PATCH 13/23] Vibe code Blazor app --- .../Sections/ChatConversationSection.razor | 269 +++++++++++++++--- 1 file changed, 227 insertions(+), 42 deletions(-) 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..b9f4eb0242 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,241 @@ @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(); + + 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; + await _streamTimer.StopAsync(); + StateHasChanged(); + break; + } + } + } + finally + { + _streamTimer?.Dispose(); + _streamTimer = null; + } + } - public void UpdateUserMessages(ChatInputSendEventArgs e) + async ValueTask IAsyncDisposable.DisposeAsync() { - _userMessages.Add(e.Text); + if (_streamTimer != null) + { + await _streamTimer.StopAsync(); + _streamTimer.Dispose(); + } } } \ No newline at end of file From a591a7b4fddae113acd809615b26ffe205fc49c3 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 14:14:16 -0500 Subject: [PATCH 14/23] Vibe code React app --- .../components/ChatConversationSection.tsx | 234 +++++++++++++++--- 1 file changed, 195 insertions(+), 39 deletions(-) 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..88ae7f90ad 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. + +
+
); } From f5d31a9e62cd36d8fe52bf903cbcda51798272ac Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 14:23:46 -0500 Subject: [PATCH 15/23] ref for messages slot instead of query --- .../spright-components/src/chat/conversation/index.ts | 9 ++++++--- .../spright-components/src/chat/conversation/template.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/spright-components/src/chat/conversation/index.ts b/packages/spright-components/src/chat/conversation/index.ts index fd998495fd..c28c70cdbd 100644 --- a/packages/spright-components/src/chat/conversation/index.ts +++ b/packages/spright-components/src/chat/conversation/index.ts @@ -55,16 +55,19 @@ export class ChatConversation extends FoundationElement { /** @internal */ public messagesContainer: HTMLElement | null = null; + + /** @internal */ + public defaultSlot!: HTMLSlotElement; + private scrollManager: ChatConversationScrollManager | null = null; public override connectedCallback(): void { super.connectedCallback(); - const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])') as HTMLSlotElement | null; - if (this.messagesContainer && defaultSlot) { + if (this.messagesContainer) { this.scrollManager = new ChatConversationScrollManager( this.messagesContainer, this, - defaultSlot, + this.defaultSlot, () => this.autoScroll ); this.scrollManager.connect(); diff --git a/packages/spright-components/src/chat/conversation/template.ts b/packages/spright-components/src/chat/conversation/template.ts index 1221874170..2f0446a11a 100644 --- a/packages/spright-components/src/chat/conversation/template.ts +++ b/packages/spright-components/src/chat/conversation/template.ts @@ -8,7 +8,7 @@ export const template = html`
-
+
From 28be7dd030421e77bd970bbd65319d2eeaeaaaff Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 14:39:42 -0500 Subject: [PATCH 16/23] Change scroll manager lifecycle --- .../src/chat/conversation/index.ts | 35 +++++++++---- .../src/chat/conversation/scroll-manager.ts | 8 +-- .../tests/chat-conversation.spec.ts | 49 +++++++++++-------- 3 files changed, 55 insertions(+), 37 deletions(-) diff --git a/packages/spright-components/src/chat/conversation/index.ts b/packages/spright-components/src/chat/conversation/index.ts index c28c70cdbd..4a3be0894b 100644 --- a/packages/spright-components/src/chat/conversation/index.ts +++ b/packages/spright-components/src/chat/conversation/index.ts @@ -63,21 +63,22 @@ export class ChatConversation extends FoundationElement { public override connectedCallback(): void { super.connectedCallback(); - if (this.messagesContainer) { - this.scrollManager = new ChatConversationScrollManager( - this.messagesContainer, - this, - this.defaultSlot, - () => this.autoScroll - ); - this.scrollManager.connect(); + if (this.autoScroll) { + this.connectScrollManager(); } } public override disconnectedCallback(): void { super.disconnectedCallback(); - this.scrollManager?.disconnect(); - this.scrollManager = null; + this.disconnectScrollManager(); + } + + public autoScrollChanged(_prev: boolean, next: boolean): void { + if (next && this.isConnected) { + this.connectScrollManager(); + } else { + this.disconnectScrollManager(); + } } public slottedInputElementsChanged( @@ -107,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 index be9858dc76..3e3d115502 100644 --- a/packages/spright-components/src/chat/conversation/scroll-manager.ts +++ b/packages/spright-components/src/chat/conversation/scroll-manager.ts @@ -27,8 +27,7 @@ export class ChatConversationScrollManager { public constructor( private readonly container: HTMLElement, private readonly hostElement: HTMLElement, - private readonly defaultSlot: HTMLSlotElement, - private readonly getAutoScroll: () => boolean + private readonly defaultSlot: HTMLSlotElement ) {} public connect(): void { @@ -63,9 +62,6 @@ export class ChatConversationScrollManager { this.scrollPending = true; requestAnimationFrame(() => { this.scrollPending = false; - if (!this.getAutoScroll()) { - return; - } if (this.scrollToUserMessagePending) { this.scrollToUserMessagePending = false; this.scrollToLastMessageTop(); @@ -84,7 +80,7 @@ export class ChatConversationScrollManager { */ private setupResizeObserver(): void { this.resizeObserver = new ResizeObserver(() => { - if (!this.isUserScrolledUp && this.getAutoScroll()) { + if (!this.isUserScrolledUp) { this.updatePaddingAndScroll(); } }); 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 e85d3f152e..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 @@ -112,32 +112,18 @@ describe('ChatConversation', () => { }; } - it('marks user as scrolled up when scrolling up away from bottom', async () => { + it('auto-scroll should default to false', async () => { await connect(); - const scrollManager = getScrollManager(element); - scrollManager.container = createMockContainer({ scrollTop: 100, scrollHeight: 500, clientHeight: 200 }); - scrollManager.previousScrollTop = 200; - - scrollManager.onScroll(); - - expect(scrollManager.isUserScrolledUp).toBeTrue(); + expect(element.autoScroll).toBeFalse(); }); - it('clears isUserScrolledUp when user scrolls to within 10px of bottom', async () => { - await connect(); - const scrollManager = getScrollManager(element); - scrollManager.container = createMockContainer({ scrollTop: 290, scrollHeight: 500, clientHeight: 200 }); - scrollManager.isUserScrolledUp = true; - scrollManager.previousScrollTop = 100; + it('can enable auto-scroll before connect', async () => { + element.autoScroll = true; - scrollManager.onScroll(); - - expect(scrollManager.isUserScrolledUp).toBeFalse(); - }); - - it('auto-scroll should default to false', async () => { await connect(); - expect(element.autoScroll).toBeFalse(); + + const scrollManager = getScrollManager(element); + expect(scrollManager).not.toBeNull(); }); describe('with auto-scroll enabled', () => { @@ -146,6 +132,27 @@ describe('ChatConversation', () => { 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 }); From dd3990a8884067a979ffa7278bbc656b61995beb Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 14:56:01 -0500 Subject: [PATCH 17/23] Blazor build --- .../Demo.Shared/Pages/Sections/ChatConversationSection.razor | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 b9f4eb0242..376d4069fa 100644 --- a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor @@ -217,7 +217,7 @@ For further reference, consult the NI TestStand help documentation under the Pyt _messages[idx] = advisorEntry; } _isStreaming = false; - await _streamTimer.StopAsync(); + _streamTimer?.Dispose(); StateHasChanged(); break; } @@ -234,8 +234,9 @@ For further reference, consult the NI TestStand help documentation under the Pyt { if (_streamTimer != null) { - await _streamTimer.StopAsync(); _streamTimer.Dispose(); } + + await ValueTask.CompletedTask; } } \ No newline at end of file From 53b88c794fa86bb14b5eec5c72ff7d87df909376 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 15:20:23 -0500 Subject: [PATCH 18/23] Use tag name --- .../src/chat/conversation/scroll-manager.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/spright-components/src/chat/conversation/scroll-manager.ts b/packages/spright-components/src/chat/conversation/scroll-manager.ts index 3e3d115502..2c754a4c44 100644 --- a/packages/spright-components/src/chat/conversation/scroll-manager.ts +++ b/packages/spright-components/src/chat/conversation/scroll-manager.ts @@ -1,4 +1,5 @@ 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. @@ -53,7 +54,7 @@ export class ChatConversationScrollManager { this.mutationObserver = new MutationObserver(mutations => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { - if ((node as Element).tagName?.toLowerCase() === 'spright-chat-message-outbound') { + if (this.isUserMessage(node as Element)) { this.scrollToUserMessagePending = true; } } @@ -121,14 +122,13 @@ export class ChatConversationScrollManager { 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 }); - for (let i = assigned.length - 1; i >= 0; i--) { - if (assigned[i]?.tagName.toLowerCase() === 'spright-chat-message-outbound') { - return assigned[i] as HTMLElement; - } - } - return null; + return Array.from(assigned).reverse().find(el => this.isUserMessage(el)) as HTMLElement | null; } /** From c23922bf5b0e09769bfc1d8b5590f4d5457bfed9 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 15:41:41 -0500 Subject: [PATCH 19/23] React autoscroll --- .../react-client-app/src/components/ChatConversationSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 88ae7f90ad..0a28489d95 100644 --- a/packages/react-workspace/react-client-app/src/components/ChatConversationSection.tsx +++ b/packages/react-workspace/react-client-app/src/components/ChatConversationSection.tsx @@ -172,7 +172,7 @@ export function ChatConversationSection(): React.JSX.Element { resize: 'vertical' }} > - + AI Assistant (Streaming) From 38851f7083d3238b1efc864f936bb4b64506ba3b Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 15:42:10 -0500 Subject: [PATCH 20/23] Blazor styling --- .../Demo.Shared/Pages/Sections/ChatConversationSection.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 376d4069fa..dd105e7768 100644 --- a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor @@ -103,7 +103,7 @@ display: flex; gap: 16px; } - :deep(spright-chat-conversation) { + spright-chat-conversation { width: 700px; height: 650px; } From 2ffa392c396bb005b700b66962759e372575a7c4 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 15:46:05 -0500 Subject: [PATCH 21/23] Blazor wrapper and test and example app --- .../Sections/ChatConversationSection.razor | 2 +- .../SprightChatConversation.razor | 1 + .../SprightChatConversation.razor.cs | 3 +++ .../SprightChatConversationTests.cs | 19 ++++++++++++++++++- 4 files changed, 23 insertions(+), 2 deletions(-) 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 dd105e7768..77b140e115 100644 --- a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor @@ -57,7 +57,7 @@
- + AI Assistant (Streaming) 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)); + } } From 12c0e271c8be0d38df91c7ebbbfaf54110f9d4e1 Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 15:49:00 -0500 Subject: [PATCH 22/23] Fix Blazor example --- .../Demo.Shared/Pages/Sections/ChatConversationSection.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 77b140e115..818c9b5331 100644 --- a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/ChatConversationSection.razor @@ -57,7 +57,7 @@
- + AI Assistant (Streaming) From d1d2a5034769f2cd4f1432d270de6380b00c41ae Mon Sep 17 00:00:00 2001 From: Jesse Attas Date: Thu, 11 Jun 2026 16:22:48 -0500 Subject: [PATCH 23/23] Change files --- ...pright-blazor-08d16ce6-2034-41f4-9374-2a2fed2ee178.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@ni-spright-blazor-08d16ce6-2034-41f4-9374-2a2fed2ee178.json 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" +}