Skip to content

feat: message-list markdown support #9080

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 8, 2025
Merged
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
162 changes: 162 additions & 0 deletions dev/messages-ai-chat.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Messages AI Chat</title>
<script type="module" src="./common.js"></script>

<style>
html,
body {
height: 100%;
margin: 0;
}

#chat {
display: flex;
flex-direction: column;
height: 100%;
}

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

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

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

/**
* Simulates streaming text from an AI model
* @returns {Object} Subscription object with onNext and onComplete methods
*/
function simulateMessageStream() {
// Sample response to simulate an AI assistant message
const answerMarkdown = `
I can help you with:

1. **Answering questions** – from quick facts to in-depth explanations.
2. **Explaining concepts** – breaking down complex ideas into clear, step-by-step logic.
3. **Brainstorming & creativity** – generating outlines, stories, code snippets, or design ideas.
4. **Guidance & troubleshooting** – walking you through processes or helping debug issues.

---

### How to get the most out of me 🛠️

| Step | What to do | Why it matters |
|------|------------|----------------|
| 1️⃣ | **State your goal clearly.** | A precise prompt yields a precise answer. |
| 2️⃣ | **Add constraints or context.** <br>*(e.g., audience, length, tone)* | Tailors the response to your needs. |
| 3️⃣ | **Ask follow-ups.** | We can iterate until you're satisfied. |

---

#### Example

> **You:** "Explain quantum entanglement in simple terms."

> **Me:**
> *Imagine two coins spun so perfectly in sync that the moment you look at one and see "heads," the other coin—no matter how far away—will instantly show "tails." In quantum physics, particles can become linked in just that way…*

---

Need anything else? Just let me know, and I'll jump right in! ✨`;

let onNextCallback = null;
let onCompleteCallback = null;

// Create subscription interface
const subscription = {
onNext: (callback) => {
onNextCallback = callback;

// Simulate token-by-token streaming with a small delay
const tokenLength = 10;

setTimeout(async () => {
let tokenIndex = 0;
while (tokenIndex < answerMarkdown.length) {
const token = answerMarkdown.substring(tokenIndex, tokenIndex + tokenLength);
tokenIndex += tokenLength;
onNextCallback(token);
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (onCompleteCallback) {
onCompleteCallback();
}
}, 1000);

return subscription;
},
onComplete: (callback) => {
onCompleteCallback = callback;
return subscription;
},
};

return subscription;
}

function createItem(text, assistant = false) {
return {
text,
time: 'Just now',
userName: assistant ? 'Assistant' : 'User',
userColorIndex: assistant ? 2 : 1,
};
}

const list = document.querySelector('vaadin-message-list');
const input = document.querySelector('vaadin-message-input');

// Set initial messages
list.items = [
createItem('Hello! Can you help me with a question?'),
createItem("Of course! I'm here to help. What's your question?", true),
];

// Handle new messages from user
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);

// Simulate AI typing response token by token
simulateMessageStream()
.onNext((token) => {
newAssistantItem.text += token;
// Force UI update by creating a new array
list.items = list.items.includes(newAssistantItem) ? [...list.items] : [...list.items, newAssistantItem];
})
.onComplete(() => {
input.disabled = false;
});
});
</script>
</head>

<body>
<div id="chat">
<vaadin-scroller>
<vaadin-message-list markdown></vaadin-message-list>
</vaadin-scroller>
<vaadin-message-input></vaadin-message-input>
</div>
</body>
</html>
1 change: 1 addition & 0 deletions packages/message-list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@vaadin/a11y-base": "24.8.0-alpha15",
"@vaadin/avatar": "24.8.0-alpha15",
"@vaadin/component-base": "24.8.0-alpha15",
"@vaadin/markdown": "24.8.0-alpha15",
"@vaadin/vaadin-lumo-styles": "24.8.0-alpha15",
"@vaadin/vaadin-material-styles": "24.8.0-alpha15",
"@vaadin/vaadin-themable-mixin": "24.8.0-alpha15",
Expand Down
5 changes: 5 additions & 0 deletions packages/message-list/src/vaadin-message-list-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,9 @@ export declare class MessageListMixinClass {
* ```
*/
items: MessageListItem[] | null | undefined;

/**
* When set to `true`, the message text is parsed as Markdown.
*/
markdown: boolean | undefined;
Copy link
Contributor

@knoobie knoobie May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Instead of markdown boolean - what would you think about a property / enum mode (or format) allowing to switch between mode=text (default), mode=markdown or html or any other thing you guys might come up with?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This option was discussed during API design. We ended up with a markdown flag due to simplicity and because markdown also allows html content.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also discussed later adding some kind of a renderer API which would allow you to decide how an item text gets rendered inside the message. That's not included in the current scope though.

}
20 changes: 19 additions & 1 deletion packages/message-list/src/vaadin-message-list-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Copyright (c) 2021 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import '@vaadin/markdown/src/vaadin-markdown.js';
import { html, render } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { KeyboardDirectionMixin } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js';
Expand Down Expand Up @@ -37,6 +38,16 @@ export const MessageListMixin = (superClass) =>
observer: '_itemsChanged',
sync: true,
},

/**
* When set to `true`, the message text is parsed as Markdown.
* @type {boolean}
*/
markdown: {
type: Boolean,
observer: '__markdownChanged',
reflectToAttribute: true,
},
};
}

Expand Down Expand Up @@ -86,6 +97,11 @@ export const MessageListMixin = (superClass) =>
}
}

/** @private */
__markdownChanged(_markdown) {
this._renderMessages(this.items);
}

/** @private */
_renderMessages(items) {
render(
Expand All @@ -102,7 +118,9 @@ export const MessageListMixin = (superClass) =>
theme="${ifDefined(item.theme)}"
class="${ifDefined(item.className)}"
@focusin="${this._onMessageFocusIn}"
>${item.text}<vaadin-avatar slot="avatar"></vaadin-avatar
>${this.markdown
? html`<vaadin-markdown .content=${item.text}></vaadin-markdown>`
: item.text}<vaadin-avatar slot="avatar"></vaadin-avatar
></vaadin-message>
`,
)}
Expand Down
4 changes: 4 additions & 0 deletions packages/message-list/src/vaadin-message-styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ export const messageStyles = css`
--vaadin-avatar-outline-width: 0;
flex-shrink: 0;
}

::slotted(vaadin-markdown) {
white-space: normal;
}
`;
53 changes: 53 additions & 0 deletions packages/message-list/test/message-list-markdown.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect } from '@vaadin/chai-plugins';
import { fixtureSync, nextUpdate } from '@vaadin/testing-helpers';
import '../src/vaadin-message-list.js';

describe('message-list-markdown', () => {
let messageList;
const messages = [
{
text: 'This is a **bold text** in Markdown',
time: '10:00 AM',
userName: 'Markdown User',
userAbbr: 'MU',
},
];

beforeEach(async () => {
messageList = fixtureSync('<vaadin-message-list markdown></vaadin-message-list>');
messageList.items = messages;
await nextUpdate(messageList);
});

it('should render the message items as markdown', () => {
const strongElement = messageList.querySelector('vaadin-message strong');
expect(strongElement).to.exist;
expect(strongElement.textContent).to.equal('bold text');
});

it('should toggle markdown rendering when property changes', async () => {
// First check with markdown enabled
expect(messageList.querySelector('vaadin-message strong')).to.exist;

// Disable markdown
messageList.markdown = false;
await nextUpdate(messageList);

// Verify markdown is disabled on messages
expect(messageList.querySelector('vaadin-message strong')).to.not.exist;

// Re-enable markdown
messageList.markdown = true;
await nextUpdate(messageList);

// Verify markdown is re-enabled
expect(messageList.querySelector('vaadin-message strong')).to.exist;
});

it('should toggle markdown attribute', async () => {
expect(messageList.hasAttribute('markdown')).to.be.true;
messageList.markdown = false;
await nextUpdate(messageList);
expect(messageList.hasAttribute('markdown')).to.be.false;
});
});
1 change: 1 addition & 0 deletions packages/message-list/test/typings/message-list.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const list = document.createElement('vaadin-message-list');

// Properties
assertType<MessageListItem[] | null | undefined>(list.items);
assertType<boolean | undefined>(list.markdown);

// Item properties
const item: MessageListItem = list.items ? list.items[0] : {};
Expand Down
7 changes: 7 additions & 0 deletions packages/message-list/test/visual/lumo/message-list.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ describe('message-list', () => {
await sendKeys({ press: 'ArrowDown' });
await visualDiff(div, `${dir}-focused`);
});

it('markdown', async () => {
element.items[0].text = 'This is a **bold text** in Markdown';
element.items = [...element.items];
element.markdown = true;
await visualDiff(div, `${dir}-markdown`);
});
});
});
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ describe('message-list', () => {
await sendKeys({ press: 'ArrowDown' });
await visualDiff(div, `${dir}-focused`);
});

it('markdown', async () => {
element.items[0].text = 'This is a **bold text** in Markdown';
element.items = [...element.items];
element.markdown = true;
await visualDiff(div, `${dir}-markdown`);
});
});
});
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/message-list/theme/lumo/vaadin-message-list.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
import '@vaadin/markdown/theme/lumo/vaadin-markdown.js';
import './vaadin-message-list-styles.js';
import '../../src/vaadin-message-list.js';
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
import '@vaadin/markdown/theme/material/vaadin-markdown.js';
import './vaadin-message-list-styles.js';
import '../../src/vaadin-message-list.js';