From b9d1e618eefd465dd45ea90e573255805b641e28 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:53:06 +0300 Subject: [PATCH 1/3] Add scrolling case for longer user message --- .../models/auto-scroll-manager.ts | 39 ++++++++++++++++--- .../src/chat/specs/scrolling-behavior.md | 2 +- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/spright-components/src/chat/conversation/models/auto-scroll-manager.ts b/packages/spright-components/src/chat/conversation/models/auto-scroll-manager.ts index 78e84bf125..05a9313dfe 100644 --- a/packages/spright-components/src/chat/conversation/models/auto-scroll-manager.ts +++ b/packages/spright-components/src/chat/conversation/models/auto-scroll-manager.ts @@ -13,6 +13,11 @@ import { // Distance from the bottom (px) within which the conversation is considered "at the bottom". const scrollingPixelThreshold = 10; +// Maximum fraction of the viewport height that an anchored message may occupy after the +// post-send scroll. Messages taller than this are scrolled past so only this fraction +// remains visible at the top, leaving room for the AI response. +const anchoredMessageMaxViewportFraction = 0.2; + // Slot name for messages that precede the current turn's anchor message. const historySlotName = 'history'; @@ -84,6 +89,7 @@ export class AutoScrollManager implements Subscriber { this.resizeObserver = undefined; this.setScrollAnchorMessage(undefined); this.clearSlotAssignments(); + this.conversation.anchoredContainer.style.minHeight = ''; this.anchorActive = false; this.previousMessages = []; } @@ -142,7 +148,7 @@ export class AutoScrollManager implements Subscriber { this.pendingAnchorInsert = false; if (anchorInsert) { this.anchorToLastInsertedMessage(); - } else if (this.autoScrollEngaged) { + } else if (this.autoScrollEngaged && this.programmaticScrollTarget === undefined) { this.followContent(); } }); @@ -150,16 +156,28 @@ export class AutoScrollManager implements Subscriber { /** * Pins the most recently inserted anchor message near the top of the - * viewport. + * viewport. Outbound messages taller than anchoredMessageMaxViewportFraction of + * the viewport are scrolled past so only that fraction remains visible */ private anchorToLastInsertedMessage(): void { const message = this.getLastAnchorMessage(); if (message === undefined) { return; } + + const anchored = this.conversation.anchoredContainer; + anchored.style.minHeight = ''; + this.setScrollAnchorMessage(message); this.autoScrollEngaged = true; - this.smoothScrollTo(this.getMaxScrollTop()); + + const target = this.getAnchorScrollTarget(message); + const shortfall = target - this.getMaxScrollTop(); + if (shortfall > 0) { + anchored.style.minHeight = `${anchored.offsetHeight + shortfall}px`; + void this.conversation.messagesContainer.scrollHeight; + } + this.smoothScrollTo(Math.min(target, this.getMaxScrollTop())); } private followContent(): void { @@ -184,8 +202,6 @@ export class AutoScrollManager implements Subscriber { private readonly onScroll = (): void => { const container = this.conversation.messagesContainer; if (this.programmaticScrollTarget !== undefined) { - // The programmatic scroll always targets the bottom, so treat it as - // settled once we reach that target or the bottom itself. const reachedTarget = Math.abs(container.scrollTop - this.programmaticScrollTarget) <= 1; const reachedBottom = this.getDistanceFromBottom() <= scrollingPixelThreshold; if (reachedTarget || reachedBottom) { @@ -206,6 +222,19 @@ export class AutoScrollManager implements Subscriber { return Math.max(0, scrollHeight - clientHeight); } + private getAnchorScrollTarget(message: ChatMessage): number { + const container = this.conversation.messagesContainer; + const containerRect = container.getBoundingClientRect(); + const messageRect = message.getBoundingClientRect(); + const messageTopInContainer = messageRect.top - containerRect.top + container.scrollTop; + const { clientHeight } = container; + const maxMessageCoverage = clientHeight * userMessageMaxViewportFraction; + if (messageRect.height > maxMessageCoverage) { + return messageTopInContainer + messageRect.height - maxMessageCoverage; + } + return messageTopInContainer; + } + private smoothScrollTo(scrollTop: number): void { const container = this.conversation.messagesContainer; if (Math.abs(container.scrollTop - scrollTop) <= 1) { diff --git a/packages/spright-components/src/chat/specs/scrolling-behavior.md b/packages/spright-components/src/chat/specs/scrolling-behavior.md index 1fba7f689b..6613143514 100644 --- a/packages/spright-components/src/chat/specs/scrolling-behavior.md +++ b/packages/spright-components/src/chat/specs/scrolling-behavior.md @@ -23,7 +23,7 @@ A specific type of user scrolling that takes place when the user scrolls while A Scrolling should animate when possible to be smooth rather than instant. **Post send auto-scroll** -Post send auto-scrolling should occur any time the user sends a new message (regardless of where they are in the chat history at the time) +Post send auto-scrolling should occur any time the user sends a new message (regardless of where they are in the chat history at the time). The user message should be scrolled such that it occupies at most 20% of the viewport. **AI response auto-scroll** AI response auto-scroll should occur only if the AI streams responses. Long responses should auto scroll because streaming should be happening at a human readable pace and therefore start removing the old (assumed read) content off screen. If we are not streaming the response and instead returning a block of text, we should not autoscroll because this would take unread content off the screen. From 228e98c57019940f8b381886e9b202e25859d933 Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:53:39 +0300 Subject: [PATCH 2/3] Change files --- ...ht-components-e6f8c74f-96f4-4b4e-a67d-dcad84b3ec6f.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@ni-spright-components-e6f8c74f-96f4-4b4e-a67d-dcad84b3ec6f.json diff --git a/change/@ni-spright-components-e6f8c74f-96f4-4b4e-a67d-dcad84b3ec6f.json b/change/@ni-spright-components-e6f8c74f-96f4-4b4e-a67d-dcad84b3ec6f.json new file mode 100644 index 0000000000..141f98f32a --- /dev/null +++ b/change/@ni-spright-components-e6f8c74f-96f4-4b4e-a67d-dcad84b3ec6f.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add scrolling case for longer user message", + "packageName": "@ni/spright-components", + "email": "163188334+Alexia-Claudia-Micu@users.noreply.github.com", + "dependentChangeType": "patch" +} From bb1fc16e6266691fed9c63b24920d54d493ace6a Mon Sep 17 00:00:00 2001 From: Claudia Micu <163188334+Alexia-Claudia-Micu@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:01:31 +0300 Subject: [PATCH 3/3] Code clean-up --- .../src/chat/conversation/models/auto-scroll-manager.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/spright-components/src/chat/conversation/models/auto-scroll-manager.ts b/packages/spright-components/src/chat/conversation/models/auto-scroll-manager.ts index 05a9313dfe..0efde561b9 100644 --- a/packages/spright-components/src/chat/conversation/models/auto-scroll-manager.ts +++ b/packages/spright-components/src/chat/conversation/models/auto-scroll-manager.ts @@ -202,6 +202,8 @@ export class AutoScrollManager implements Subscriber { private readonly onScroll = (): void => { const container = this.conversation.messagesContainer; if (this.programmaticScrollTarget !== undefined) { + // The programmatic scroll always targets the bottom, so treat it as + // settled once we reach that target or the bottom itself. const reachedTarget = Math.abs(container.scrollTop - this.programmaticScrollTarget) <= 1; const reachedBottom = this.getDistanceFromBottom() <= scrollingPixelThreshold; if (reachedTarget || reachedBottom) { @@ -228,7 +230,7 @@ export class AutoScrollManager implements Subscriber { const messageRect = message.getBoundingClientRect(); const messageTopInContainer = messageRect.top - containerRect.top + container.scrollTop; const { clientHeight } = container; - const maxMessageCoverage = clientHeight * userMessageMaxViewportFraction; + const maxMessageCoverage = clientHeight * anchoredMessageMaxViewportFraction; if (messageRect.height > maxMessageCoverage) { return messageTopInContainer + messageRect.height - maxMessageCoverage; }