Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 4 additions & 18 deletions dev/messages-ai-chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,14 @@
height: 100%;
}

vaadin-scroller {
flex: 1;
scroll-snap-type: y proximity;
}

vaadin-scroller::after {
display: block;
content: '';
scroll-snap-align: end;
min-height: 1px;
vaadin-message-list {
flex: 1 1 auto;
}
</style>

<script type="module">
import '@vaadin/message-input';
import '@vaadin/message-list';
import '@vaadin/scroller';

/**
* Simulates streaming text from an AI model
Expand Down Expand Up @@ -132,7 +123,6 @@
input.addEventListener('submit', async (e) => {
// Add user message to the list
list.items = [...list.items, createItem(e.detail.value)];
input.disabled = true;

// Create empty assistant message that will be populated gradually
const newAssistantItem = createItem('', true);
Expand All @@ -144,18 +134,14 @@
// Force UI update by creating a new array
list.items = list.items.includes(newAssistantItem) ? [...list.items] : [...list.items, newAssistantItem];
})
.onComplete(() => {
input.disabled = false;
});
.onComplete(() => {});
});
</script>
</head>

<body>
<div id="chat">
<vaadin-scroller>
<vaadin-message-list markdown></vaadin-message-list>
</vaadin-scroller>
<vaadin-message-list markdown></vaadin-message-list>
<vaadin-message-input></vaadin-message-input>
</div>
</body>
Expand Down
13 changes: 13 additions & 0 deletions packages/message-list/src/vaadin-message-list-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import { html, render } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { KeyboardDirectionMixin } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js';
import { timeOut } from '@vaadin/component-base/src/async.js';
import { Debouncer } from '@vaadin/component-base/src/debounce.js';

/**
* @polymerMixin
Expand Down Expand Up @@ -99,6 +101,9 @@ export const MessageListMixin = (superClass) =>

this._renderMessages(items);
this._setTabIndexesByIndex(focusedIndex);
if (oldItems.length) {
this.__debounceAppending();
}

requestAnimationFrame(() => {
if (items.length > oldItems.length && closeToBottom) {
Expand Down Expand Up @@ -160,6 +165,14 @@ export const MessageListMixin = (superClass) =>
}
}

/** @private */
__debounceAppending() {
this.$.list.style.setProperty('--_vaadin-message-list-scroll-snap-align', 'end');
this.__debounceAnimation = Debouncer.debounce(this.__debounceAnimation, timeOut.after(500), () => {
this.$.list.style.removeProperty('--_vaadin-message-list-scroll-snap-align');
});
}

/** @private */
_onMessageFocusIn(e) {
const target = e.composedPath().find((node) => node instanceof customElements.get('vaadin-message'));
Expand Down
10 changes: 9 additions & 1 deletion packages/message-list/src/vaadin-message-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,26 @@ class MessageList extends SlotStylesMixin(MessageListMixin(ElementMixin(Themable
display: block;
overflow: auto;
padding: var(--vaadin-message-list-padding, var(--vaadin-padding-xs) 0);
scroll-padding: var(--vaadin-message-list-padding, var(--vaadin-padding-xs) 0);
scroll-snap-type: y proximity;
}

:host([hidden]) {
display: none !important;
}

[part='list']::after {
content: '';
display: block;
scroll-snap-align: var(--_vaadin-message-list-scroll-snap-align, none);
}
`;
}

/** @protected */
render() {
return html`
<div part="list" role="list">
<div part="list" role="list" id="list">
<slot></slot>
</div>
`;
Expand Down
49 changes: 49 additions & 0 deletions packages/message-list/test/message-list.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
arrowDown,
arrowRight,
arrowUp,
aTimeout,
end,
fixtureSync,
focusin,
Expand Down Expand Up @@ -205,6 +206,54 @@ describe('message-list', () => {
await nextRender();
expect(messageList.scrollTop).to.be.equal(0);
});

it('should scroll to bottom on appending message text', async () => {
// Scroll to end
messageList.scrollBy(0, 1000);
await nextRender();

// Append text to last message
messageList.items.at(-1).text += '\nfoo';
messageList.items = [...messageList.items];
await nextRender();
await nextRender();

// Verify scrolled to bottom
expect(messageList.scrollTop).to.be.closeTo(messageList.scrollHeight - messageList.clientHeight, 1);
});

it('should not scroll if not at the bottom on appending message text', async () => {
const scrollTopBeforeAppend = messageList.scrollTop;

// Append text to last message while still at top
messageList.items.at(-1).text += '\nfoo';
messageList.items = [...messageList.items];
await nextRender();
await nextRender();

// Verify scroll position unchanged
expect(messageList.scrollTop).to.be.equal(scrollTopBeforeAppend);
});

it('should not snap to bottom after snap duration expires', async () => {
// Scroll to end
messageList.scrollBy(0, 1000);
await nextRender();

// Append text to last message
messageList.items.at(-1).text += '\nfoo';
messageList.items = [...messageList.items];

// Wait for snap behavior to expire (500ms + buffer)
await aTimeout(600);

// Now scroll up manually
const scrollTopBefore = messageList.scrollTop;
messageList.scrollTop -= 20;

// Should be able to scroll up freely
expect(messageList.scrollTop).to.be.equal(scrollTopBefore - 20);
});
});

describe('tabindex', () => {
Expand Down