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`
+
+
@@ -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: `
-
-
- 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
+
+
+
+
+
+
@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
+
+
+
+