Repository: jupyterlab/jupyter-chat (language: python)

Issue:
Stack messages in the UI
### Problem

When a user send several messages in a row, the username is repeated for each message.

### Proposed Solution

The messages could be stacked to avoid repeated useless information.

### Additional context

It could be `true` by default but configurable using the config of the model.



Discussion / hints:

Fix patch:
diff --git a/packages/jupyter-chat/src/components/chat-messages.tsx b/packages/jupyter-chat/src/components/chat-messages.tsx
index 0dad67d..74c6585 100644
--- a/packages/jupyter-chat/src/components/chat-messages.tsx
+++ b/packages/jupyter-chat/src/components/chat-messages.tsx
@@ -16,6 +16,7 @@ import { IChatMessage } from '../types';
 
 const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
 const MESSAGE_CLASS = 'jp-chat-message';
+const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
 const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
 const MESSAGE_TIME_CLASS = 'jp-chat-message-time';
 
@@ -39,23 +40,18 @@ type ChatMessagesProps = BaseMessageProps & {
  */
 export function ChatMessages(props: ChatMessagesProps): JSX.Element {
   return (
-    <Box
-      sx={{
-        '& > :not(:last-child)': {
-          borderBottom: '1px solid var(--jp-border-color2)'
-        }
-      }}
-      className={clsx(MESSAGES_BOX_CLASS)}
-    >
+    <Box className={clsx(MESSAGES_BOX_CLASS)}>
       {props.messages.map((message, i) => {
         return (
           // extra div needed to ensure each bubble is on a new line
           <Box
             key={i}
-            sx={{ padding: '1em 1em 0 1em' }}
-            className={clsx(MESSAGE_CLASS)}
+            className={clsx(
+              MESSAGE_CLASS,
+              message.stacked ? MESSAGE_STACKED_CLASS : ''
+            )}
           >
-            <ChatMessageHeader message={message} sx={{ marginBottom: 3 }} />
+            <ChatMessageHeader message={message} />
             <ChatMessage {...props} message={message} />
           </Box>
         );
@@ -121,7 +117,7 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
   });
 
   const bgcolor = sender.color;
-  const avatar = sender.avatar_url ? (
+  const avatar = message.stacked ? null : sender.avatar_url ? (
     <Avatar
       sx={{
         ...sharedStyles,
@@ -159,6 +155,7 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
         '& > :not(:last-child)': {
           marginRight: 3
         },
+        marginBottom: message.stacked ? '0px' : '12px',
         ...props.sx
       }}
     >
@@ -173,17 +170,22 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
         }}
       >
         <Box sx={{ display: 'flex', alignItems: 'center' }}>
-          <Typography
-            sx={{ fontWeight: 700, color: 'var(--jp-ui-font-color1)' }}
-          >
-            {name}
-          </Typography>
+          {!message.stacked && (
+            <Typography
+              sx={{
+                fontWeight: 700,
+                color: 'var(--jp-ui-font-color1)',
+                paddingRight: '0.5em'
+              }}
+            >
+              {name}
+            </Typography>
+          )}
           {(message.deleted || message.edited) && (
             <Typography
               sx={{
                 fontStyle: 'italic',
-                fontSize: 'var(--jp-content-font-size0)',
-                paddingLeft: '0.5em'
+                fontSize: 'var(--jp-content-font-size0)'
               }}
             >
               {message.deleted ? '(message deleted)' : '(edited)'}
diff --git a/packages/jupyter-chat/src/model.ts b/packages/jupyter-chat/src/model.ts
index 540e4b8..0d0c459 100644
--- a/packages/jupyter-chat/src/model.ts
+++ b/packages/jupyter-chat/src/model.ts
@@ -119,7 +119,10 @@ export class ChatModel implements IChatModel {
    * Create a new chat model.
    */
   constructor(options: ChatModel.IOptions = {}) {
-    this._config = options.config ?? {};
+    const config = options.config ?? {};
+
+    // Stack consecutive messages from the same user by default.
+    this._config = { stackMessages: true, ...config };
   }
 
   /**
@@ -146,7 +149,25 @@ export class ChatModel implements IChatModel {
     return this._config;
   }
   set config(value: Partial<IConfig>) {
+    const stackMessagesChanged =
+      'stackMessages' in value &&
+      this._config.stackMessages !== value?.stackMessages;
+
     this._config = { ...this._config, ...value };
+
+    if (stackMessagesChanged) {
+      if (this._config.stackMessages) {
+        this._messages.slice(1).forEach((message, idx) => {
+          const previousUser = this._messages[idx].sender.username;
+          message.stacked = previousUser === message.sender.username;
+        });
+      } else {
+        this._messages.forEach(message => {
+          delete message.stacked;
+        });
+      }
+      this._messagesUpdated.emit();
+    }
   }
 
   /**
@@ -220,10 +241,28 @@ export class ChatModel implements IChatModel {
    */
   messagesInserted(index: number, messages: IChatMessage[]): void {
     const formattedMessages: IChatMessage[] = [];
-    messages.forEach(message => {
+
+    // Format the messages.
+    messages.forEach((message, idx) => {
       formattedMessages.push(this.formatChatMessage(message));
     });
+
+    // Insert the messages.
     this._messages.splice(index, 0, ...formattedMessages);
+
+    if (this._config.stackMessages) {
+      // Check if some messages should be stacked by comparing each message' sender
+      // with the previous one.
+      const lastIdx = index + formattedMessages.length - 1;
+      const start = index === 0 ? 1 : index;
+      const end = this._messages.length > lastIdx + 1 ? lastIdx + 1 : lastIdx;
+      for (let idx = start; idx <= end; idx++) {
+        const message = this._messages[idx];
+        const previousUser = this._messages[idx - 1].sender.username;
+        message.stacked = previousUser === message.sender.username;
+      }
+    }
+
     this._messagesUpdated.emit();
   }
 
diff --git a/packages/jupyter-chat/src/types.ts b/packages/jupyter-chat/src/types.ts
index e0c82ed..0a94583 100644
--- a/packages/jupyter-chat/src/types.ts
+++ b/packages/jupyter-chat/src/types.ts
@@ -21,6 +21,7 @@ export interface IUser {
 export interface IConfig {
   sendWithShiftEnter?: boolean;
   lastRead?: number;
+  stackMessages?: boolean;
 }
 
 /**
@@ -54,6 +55,6 @@ export interface INewMessage {
 }
 
 /**
- * An empty interface to describe optional settings taht could be fetched from server.
+ * An empty interface to describe optional settings that could be fetched from server.
  */
 export interface ISettings {}
diff --git a/packages/jupyter-chat/style/chat.css b/packages/jupyter-chat/style/chat.css
index f6b0f95..b3c7dff 100644
--- a/packages/jupyter-chat/style/chat.css
+++ b/packages/jupyter-chat/style/chat.css
@@ -2,6 +2,17 @@
  * Copyright (c) Jupyter Development Team.
  * Distributed under the terms of the Modified BSD License.
  */
+.jp-chat-message:not(:first-child):not(.jp-chat-message-stacked) {
+  border-top: 1px solid var(--jp-border-color2);
+}
+
+.jp-chat-message:not(.jp-chat-message-stacked) {
+  padding: 1em 1em 0 1em;
+}
+
+.jp-chat-message.jp-chat-message-stacked {
+  padding: 0 1em;
+}
 
 .jp-chat-rendermime-markdown {
   position: relative;
diff --git a/packages/jupyterlab-collaborative-chat/schema/factories.json b/packages/jupyterlab-collaborative-chat/schema/factories.json
index 8fc2695..1f00f83 100644
--- a/packages/jupyterlab-collaborative-chat/schema/factories.json
+++ b/packages/jupyterlab-collaborative-chat/schema/factories.json
@@ -21,6 +21,12 @@
       "type": "boolean",
       "default": false,
       "readOnly": false
+    },
+    "stackMessages": {
+      "description": "Whether to stack consecutive messages from same user.",
+      "type": "boolean",
+      "default": true,
+      "readOnly": false
     }
   },
   "additionalProperties": false,
diff --git a/packages/jupyterlab-collaborative-chat/src/index.ts b/packages/jupyterlab-collaborative-chat/src/index.ts
index c34ebba..fa58c88 100644
--- a/packages/jupyterlab-collaborative-chat/src/index.ts
+++ b/packages/jupyterlab-collaborative-chat/src/index.ts
@@ -89,12 +89,13 @@ export const docFactories: JupyterFrontEndPlugin<IWidgetConfig> = {
      * Load the settings for the chat widgets.
      */
     let sendWithShiftEnter = false;
-
+    let stackMessages = true;
     function loadSetting(setting: ISettingRegistry.ISettings): void {
       // Read the settings and convert to the correct type
       sendWithShiftEnter = setting.get('sendWithShiftEnter')
         .composite as boolean;
-      widgetConfig.configChanged.emit({ sendWithShiftEnter });
+      stackMessages = setting.get('stackMessages').composite as boolean;
+      widgetConfig.configChanged.emit({ sendWithShiftEnter, stackMessages });
     }
 
     if (settingRegistry) {
diff --git a/packages/jupyterlab-ws-chat/schema/chat.json b/packages/jupyterlab-ws-chat/schema/chat.json
index 125cfef..7a29acb 100644
--- a/packages/jupyterlab-ws-chat/schema/chat.json
+++ b/packages/jupyterlab-ws-chat/schema/chat.json
@@ -1,5 +1,5 @@
 {
-  "title": "Jupyter chat configuration",
+  "title": "Chat configuration",
   "description": "Configuration for the chat panel",
   "type": "object",
   "properties": {
@@ -8,6 +8,12 @@
       "type": "boolean",
       "default": false,
       "readOnly": false
+    },
+    "stackMessages": {
+      "description": "Whether to stack consecutive messages from same user.",
+      "type": "boolean",
+      "default": true,
+      "readOnly": false
     }
   },
   "additionalProperties": false
diff --git a/packages/jupyterlab-ws-chat/src/index.ts b/packages/jupyterlab-ws-chat/src/index.ts
index 95609df..52d8ccc 100644
--- a/packages/jupyterlab-ws-chat/src/index.ts
+++ b/packages/jupyterlab-ws-chat/src/index.ts
@@ -41,12 +41,14 @@ const chat: JupyterFrontEndPlugin<void> = {
      * Load the settings.
      */
     let sendWithShiftEnter = false;
+    let stackMessages = true;
 
     function loadSetting(setting: ISettingRegistry.ISettings): void {
       // Read the settings and convert to the correct type
       sendWithShiftEnter = setting.get('sendWithShiftEnter')
         .composite as boolean;
-      chatHandler.config = { sendWithShiftEnter };
+      stackMessages = setting.get('stackMessages').composite as boolean;
+      chatHandler.config = { sendWithShiftEnter, stackMessages };
     }
 
     // Wait for the application to be restored and


Test patch:
diff --git a/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts
index e915fbb..ce692e6 100644
--- a/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts
+++ b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts
@@ -3,7 +3,6 @@
  * Distributed under the terms of the Modified BSD License.
  */
 
-import { IChatMessage } from '@jupyter/chat';
 import {
   IJupyterLabPageFixture,
   expect,
@@ -212,9 +211,7 @@ test.describe('#launcher', () => {
     ).toHaveCount(1);
 
     // Chat tile
-    const tile = page
-      .locator('.jp-LauncherCard[data-category="Chat"]')
-      .getByTitle('Create a chat');
+    const tile = page.locator('.jp-LauncherCard[data-category="Chat"]');
     expect(await tile.screenshot()).toMatchSnapshot('launcher-tile.png');
   });
 
@@ -226,7 +223,42 @@ test.describe('#launcher', () => {
   });
 });
 
-test.describe('#messages', () => {
+test.describe('#settings', () => {
+  test.beforeEach(async ({ page }) => {
+    // Create a chat file
+    await page.filebrowser.contents.uploadContent('{}', 'text', FILENAME);
+  });
+
+  test.afterEach(async ({ page }) => {
+    if (await page.filebrowser.contents.fileExists(FILENAME)) {
+      await page.filebrowser.contents.deleteFile(FILENAME);
+    }
+  });
+
+  test('should have chat settings', async ({ page }) => {
+    const settings = await openSettings(page, true);
+    await expect(
+      settings.locator(
+        '.jp-PluginList-entry[data-id="jupyterlab-collaborative-chat:factories"]'
+      )
+    ).toBeVisible();
+  });
+
+  test('should have default settings values', async ({ page }) => {
+    const settings = await openSettings(page);
+    const sendWithShiftEnter = settings?.getByRole('checkbox', {
+      name: 'sendWithShiftEnter'
+    });
+    expect(sendWithShiftEnter!).not.toBeChecked();
+
+    const stackMessages = settings?.getByRole('checkbox', {
+      name: 'stackMessages'
+    });
+    expect(stackMessages!).toBeChecked();
+  });
+});
+
+test.describe('#sendMessages', () => {
   test.beforeEach(async ({ page }) => {
     // Create a chat file
     await page.filebrowser.contents.uploadContent('{}', 'text', FILENAME);
@@ -272,10 +304,96 @@ test.describe('#messages', () => {
       messages.locator('.jp-chat-message .jp-chat-rendermime-markdown')
     ).toHaveText(MSG_CONTENT + '\n');
   });
+
+  test('should use settings value sendWithShiftEnter', async ({ page }) => {
+    // Modify the settings
+    const settings = await openSettings(page);
+    const sendWithShiftEnter = settings?.getByRole('checkbox', {
+      name: 'sendWithShiftEnter'
+    });
+    await sendWithShiftEnter?.check();
+
+    // wait for the settings to be saved
+    await expect(page.activity.getTabLocator('Settings')).toHaveAttribute(
+      'class',
+      /jp-mod-dirty/
+    );
+    await expect(page.activity.getTabLocator('Settings')).not.toHaveAttribute(
+      'class',
+      /jp-mod-dirty/
+    );
+
+    // Should not send message with Enter
+    const chatPanel = await openChat(page, FILENAME);
+    const messages = chatPanel.locator('.jp-chat-messages-container');
+    const input = chatPanel
+      .locator('.jp-chat-input-container')
+      .getByRole('textbox');
+    await input!.pressSequentially(MSG_CONTENT);
+    await input!.press('Enter');
+
+    await expect(messages!.locator('.jp-chat-message')).toHaveCount(0);
+
+    // Should not send message with Shift+Enter
+    await input!.press('Shift+Enter');
+    await expect(messages!.locator('.jp-chat-message')).toHaveCount(1);
+
+    // It seems that the markdown renderer adds a new line, but the '\n' inserter when
+    // pressing Enter above is trimmed.
+    await expect(
+      messages.locator('.jp-chat-message .jp-chat-rendermime-markdown')
+    ).toHaveText(MSG_CONTENT + '\n');
+  });
+
+  test('should update settings value sendWithShiftEnter on existing chat', async ({
+    page
+  }) => {
+    const chatPanel = await openChat(page, FILENAME);
+
+    // Modify the settings
+    const settings = await openSettings(page);
+    const sendWithShiftEnter = settings?.getByRole('checkbox', {
+      name: 'sendWithShiftEnter'
+    });
+    await sendWithShiftEnter?.check();
+
+    // wait for the settings to be saved
+    await expect(page.activity.getTabLocator('Settings')).toHaveAttribute(
+      'class',
+      /jp-mod-dirty/
+    );
+    await expect(page.activity.getTabLocator('Settings')).not.toHaveAttribute(
+      'class',
+      /jp-mod-dirty/
+    );
+
+    // Activate the chat panel
+    await page.activity.activateTab(FILENAME);
+
+    // Should not send message with Enter
+    const messages = chatPanel.locator('.jp-chat-messages-container');
+    const input = chatPanel
+      .locator('.jp-chat-input-container')
+      .getByRole('textbox');
+    await input!.pressSequentially(MSG_CONTENT);
+    await input!.press('Enter');
+
+    await expect(messages!.locator('.jp-chat-message')).toHaveCount(0);
+
+    // Should not send message with Shift+Enter
+    await input!.press('Shift+Enter');
+    await expect(messages!.locator('.jp-chat-message')).toHaveCount(1);
+
+    // It seems that the markdown renderer adds a new line, but the '\n' inserted when
+    // pressing Enter above is trimmed.
+    await expect(
+      messages.locator('.jp-chat-message .jp-chat-rendermime-markdown')
+    ).toHaveText(MSG_CONTENT + '\n');
+  });
 });
 
 test.describe('#raw_time', () => {
-  const msg_raw_time: IChatMessage = {
+  const msg_raw_time = {
     type: 'msg',
     id: UUID.uuid4(),
     sender: USERNAME,
@@ -283,7 +401,7 @@ test.describe('#raw_time', () => {
     time: 1714116341,
     raw_time: true
   };
-  const msg_verif: IChatMessage = {
+  const msg_verif = {
     type: 'msg',
     id: UUID.uuid4(),
     sender: USERNAME,
@@ -292,12 +410,10 @@ test.describe('#raw_time', () => {
     raw_time: false
   };
   const chatContent = {
-    messages: <IChatMessage[]>[],
+    messages: [msg_raw_time, msg_verif],
     users: {}
   };
   chatContent.users[USERNAME] = USER.identity;
-  chatContent.messages.push(msg_raw_time);
-  chatContent.messages.push(msg_verif);
 
   test.beforeEach(async ({ page }) => {
     // Create a chat file with content
@@ -357,7 +473,7 @@ test.describe('#raw_time', () => {
 test.describe('#messageToolbar', () => {
   const additionnalContent = ' Messages can be edited';
 
-  const msg: IChatMessage = {
+  const msg = {
     type: 'msg',
     id: UUID.uuid4(),
     sender: USERNAME,
@@ -365,11 +481,10 @@ test.describe('#messageToolbar', () => {
     time: 1714116341
   };
   const chatContent = {
-    messages: <IChatMessage[]>[],
+    messages: [msg],
     users: {}
   };
   chatContent.users[USERNAME] = USER.identity;
-  chatContent.messages.push(msg);
 
   test.beforeEach(async ({ page }) => {
     // Create a chat file with content
@@ -486,7 +601,7 @@ test.describe('#messageToolbar', () => {
 });
 
 test.describe('#outofband', () => {
-  const msg: IChatMessage = {
+  const msg = {
     type: 'msg',
     id: UUID.uuid4(),
     sender: USERNAME,
@@ -494,11 +609,10 @@ test.describe('#outofband', () => {
     time: 1714116341
   };
   const chatContent = {
-    messages: <IChatMessage[]>[],
+    messages: [msg],
     users: {}
   };
   chatContent.users[USERNAME] = USER.identity;
-  chatContent.messages.push(msg);
 
   test.beforeEach(async ({ page }) => {
     // Create a chat file with content
@@ -523,11 +637,10 @@ test.describe('#outofband', () => {
       .first();
     const newMsg = { ...msg, body: updatedContent };
     const newContent = {
-      messages: <IChatMessage[]>[],
+      messages: [newMsg],
       users: {}
     };
     newContent.users[USERNAME] = USER.identity;
-    newContent.messages.push(newMsg);
 
     await page.filebrowser.contents.uploadContent(
       JSON.stringify(newContent),
@@ -544,7 +657,7 @@ test.describe('#outofband', () => {
     const messages = chatPanel.locator(
       '.jp-chat-messages-container .jp-chat-message'
     );
-    const newMsg: IChatMessage = {
+    const newMsg = {
       type: 'msg',
       id: UUID.uuid4(),
       sender: USERNAME,
@@ -552,12 +665,10 @@ test.describe('#outofband', () => {
       time: msg.time + 5
     };
     const newContent = {
-      messages: <IChatMessage[]>[],
+      messages: [msg, newMsg],
       users: {}
     };
     newContent.users[USERNAME] = USER.identity;
-    newContent.messages.push(msg);
-    newContent.messages.push(newMsg);
 
     await page.filebrowser.contents.uploadContent(
       JSON.stringify(newContent),
@@ -577,7 +688,7 @@ test.describe('#outofband', () => {
       .locator('.jp-chat-messages-container .jp-chat-rendermime-markdown')
       .first();
     const newContent = {
-      messages: <IChatMessage[]>[],
+      messages: [],
       users: {}
     };
     newContent.users[USERNAME] = USER.identity;
@@ -592,122 +703,6 @@ test.describe('#outofband', () => {
   });
 });
 
-test.describe('#settings', () => {
-  test.beforeEach(async ({ page }) => {
-    // Create a chat file
-    await page.filebrowser.contents.uploadContent('{}', 'text', FILENAME);
-  });
-
-  test.afterEach(async ({ page }) => {
-    if (await page.filebrowser.contents.fileExists(FILENAME)) {
-      await page.filebrowser.contents.deleteFile(FILENAME);
-    }
-  });
-
-  test('should have chat settings', async ({ page }) => {
-    const settings = await openSettings(page, true);
-    await expect(
-      settings.locator(
-        '.jp-PluginList-entry[data-id="jupyterlab-collaborative-chat:factories"]'
-      )
-    ).toBeVisible();
-  });
-
-  test('should have default settings values', async ({ page }) => {
-    const settings = await openSettings(page);
-    const sendWithShiftEnter = settings?.getByRole('checkbox', {
-      name: 'sendWithShiftEnter'
-    });
-    expect(sendWithShiftEnter!).not.toBeChecked();
-  });
-
-  test('should use settings value sendWithShiftEnter', async ({ page }) => {
-    // Modify the settings
-    const settings = await openSettings(page);
-    const sendWithShiftEnter = settings?.getByRole('checkbox', {
-      name: 'sendWithShiftEnter'
-    });
-    await sendWithShiftEnter?.check();
-
-    // wait for the settings to be saved
-    await expect(page.activity.getTabLocator('Settings')).toHaveAttribute(
-      'class',
-      /jp-mod-dirty/
-    );
-    await expect(page.activity.getTabLocator('Settings')).not.toHaveAttribute(
-      'class',
-      /jp-mod-dirty/
-    );
-
-    // Should not send message with Enter
-    const chatPanel = await openChat(page, FILENAME);
-    const messages = chatPanel.locator('.jp-chat-messages-container');
-    const input = chatPanel
-      .locator('.jp-chat-input-container')
-      .getByRole('textbox');
-    await input!.pressSequentially(MSG_CONTENT);
-    await input!.press('Enter');
-
-    await expect(messages!.locator('.jp-chat-message')).toHaveCount(0);
-
-    // Should not send message with Shift+Enter
-    await input!.press('Shift+Enter');
-    await expect(messages!.locator('.jp-chat-message')).toHaveCount(1);
-
-    // It seems that the markdown renderer adds a new line, but the '\n' inserter when
-    // pressing Enter above is trimmed.
-    await expect(
-      messages.locator('.jp-chat-message .jp-chat-rendermime-markdown')
-    ).toHaveText(MSG_CONTENT + '\n');
-  });
-
-  test('should update settings value sendWithShiftEnter on existing chat', async ({
-    page
-  }) => {
-    const chatPanel = await openChat(page, FILENAME);
-
-    // Modify the settings
-    const settings = await openSettings(page);
-    const sendWithShiftEnter = settings?.getByRole('checkbox', {
-      name: 'sendWithShiftEnter'
-    });
-    await sendWithShiftEnter?.check();
-
-    // wait for the settings to be saved
-    await expect(page.activity.getTabLocator('Settings')).toHaveAttribute(
-      'class',
-      /jp-mod-dirty/
-    );
-    await expect(page.activity.getTabLocator('Settings')).not.toHaveAttribute(
-      'class',
-      /jp-mod-dirty/
-    );
-
-    // Activate the chat panel
-    await page.activity.activateTab(FILENAME);
-
-    // Should not send message with Enter
-    const messages = chatPanel.locator('.jp-chat-messages-container');
-    const input = chatPanel
-      .locator('.jp-chat-input-container')
-      .getByRole('textbox');
-    await input!.pressSequentially(MSG_CONTENT);
-    await input!.press('Enter');
-
-    await expect(messages!.locator('.jp-chat-message')).toHaveCount(0);
-
-    // Should not send message with Shift+Enter
-    await input!.press('Shift+Enter');
-    await expect(messages!.locator('.jp-chat-message')).toHaveCount(1);
-
-    // It seems that the markdown renderer adds a new line, but the '\n' inserted when
-    // pressing Enter above is trimmed.
-    await expect(
-      messages.locator('.jp-chat-message .jp-chat-rendermime-markdown')
-    ).toHaveText(MSG_CONTENT + '\n');
-  });
-});
-
 test.describe('#chatPanel', () => {
   test.describe('#initiailization', () => {
     test('should contain the chat panel icon', async ({ page }) => {
@@ -791,9 +786,7 @@ test.describe('#chatPanel', () => {
       ).toHaveText('untitled');
     });
 
-    test('should not create a chat if dialog is cancelled', async ({
-      page
-    }) => {
+    test('should not create a chat if dialog is cancelled', async () => {
       await dialog.getByRole('button').getByText('Cancel').click();
 
       const content = panel.locator('.jp-SidePanel-content');
@@ -923,3 +916,100 @@ test.describe('#chatPanel', () => {
     });
   });
 });
+
+test.describe('#stackedMessages', () => {
+  const msg1 = {
+    type: 'msg',
+    id: UUID.uuid4(),
+    sender: USERNAME,
+    body: MSG_CONTENT,
+    time: 1714116341
+  };
+  const msg2 = {
+    type: 'msg',
+    id: UUID.uuid4(),
+    sender: USERNAME,
+    body: 'Me again',
+    time: 1714116341
+  };
+  const chatContent = {
+    messages: [msg1, msg2],
+    users: {}
+  };
+  chatContent.users[USERNAME] = USER.identity;
+
+  test.beforeEach(async ({ page }) => {
+    // Create a chat file with content
+    await page.filebrowser.contents.uploadContent(
+      JSON.stringify(chatContent),
+      'text',
+      FILENAME
+    );
+  });
+
+  test.afterEach(async ({ page }) => {
+    if (await page.filebrowser.contents.fileExists(FILENAME)) {
+      await page.filebrowser.contents.deleteFile(FILENAME);
+    }
+  });
+
+  test('messages should be stacked by default', async ({ page }) => {
+    const chatPanel = await openChat(page, FILENAME);
+    const messagesContainer = chatPanel.locator('.jp-chat-messages-container');
+    const messages = messagesContainer.locator('.jp-chat-message');
+    await expect(messages).toHaveCount(2);
+
+    // Hide the time to avoid time zone diff
+    await messages
+      .locator('.jp-chat-message-time')
+      .evaluateAll(elements =>
+        elements.map(element => (element.style.display = 'none'))
+      );
+
+    expect(await messagesContainer.screenshot()).toMatchSnapshot(
+      'stacked-messages.png'
+    );
+  });
+
+  test('should update settings value stackMessages on existing chat', async ({
+    page
+  }) => {
+    const chatPanel = await openChat(page, FILENAME);
+
+    // Modify the settings
+    const settings = await openSettings(page);
+    const stackMessages = settings?.getByRole('checkbox', {
+      name: 'stackMessages'
+    });
+    await stackMessages?.uncheck();
+
+    // wait for the settings to be saved
+    await expect(page.activity.getTabLocator('Settings')).toHaveAttribute(
+      'class',
+      /jp-mod-dirty/
+    );
+    await expect(page.activity.getTabLocator('Settings')).not.toHaveAttribute(
+      'class',
+      /jp-mod-dirty/
+    );
+
+    // Activate the chat panel
+    await page.activity.activateTab(FILENAME);
+
+    // Should not send message with Enter
+    const messagesContainer = chatPanel.locator('.jp-chat-messages-container');
+    const messages = messagesContainer.locator('.jp-chat-message');
+    await expect(messages).toHaveCount(2);
+
+    // Hide the time to avoid time zone diff
+    await messages
+      .locator('.jp-chat-message-time')
+      .evaluateAll(elements =>
+        elements.map(element => (element.style.display = 'none'))
+      );
+
+    expect(await messagesContainer.screenshot()).toMatchSnapshot(
+      'not-stacked-messages.png'
+    );
+  });
+});
diff --git a/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/launcher-tile-linux.png b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/launcher-tile-linux.png
index a7a2fd9..2d99dff 100644
Binary files a/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/launcher-tile-linux.png and b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/launcher-tile-linux.png differ
diff --git a/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/not-stacked-messages-linux.png b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/not-stacked-messages-linux.png
new file mode 100644
index 0000000..2b58289
Binary files /dev/null and b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/not-stacked-messages-linux.png differ
diff --git a/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/stacked-messages-linux.png b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/stacked-messages-linux.png
new file mode 100644
index 0000000..f3f1186
Binary files /dev/null and b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/stacked-messages-linux.png differ