Repository: jupyterlab/jupyter-chat
Pull request #190: Mention users in messages (using @)
Author: login: brichet
Labels: name: enhancement
description: New feature or request
Created: 2025-03-11T17:41:29Z | Merged: 2025-04-10T20:04:14Z

This PR allows to mention users in messages.
Fixes #184 

[record-2025-03-20_10.55.07.webm](https://github.com/user-attachments/assets/df8da77d-bedf-4326-b5cc-aa21bf11480a)

### General workflow

- The autocomplete contains all users who have already written in the chat and all users who are currently connected to it. Each chat command displays the user avatar.
- Selecting a user from the chat commands updates a list of mentions in the input model, and adds `@username` in the input.
- When the message is sent, the mentioned users are attached to the message object.
- When the message is displayed (markdown renderer), the mentioned user string (`@username`) is replaced by a span to add style on it.

### Some details on the changes

- The `IChatCommandProvider` methods receive also the `ChatModel` in arguments. It's required to find the users of the chat.
- The `IUser` interface has now an optional attribute `mention_name` (string), used to find the mention in the message and to replace it with the span on the fly.
- When editing a message, the mention span is replaced back to the `mention_name`, for a better user experience.
- It seems difficult to catch when a mention is removed from the message, we should listen for changes in message and looks for `user_mention` in real time. Therefore, when the message is sent (or updated), the mention list of the input is checked to make sure that the message still contains the `mention_name`.


Linked issue #184: Subissue: Allow users to be mentioned
### Problem

It must be possible to `@`-mention other users in the chat. Typing `@` should open the completions menu by default and show a list of users and their avatars.

### Proposed Solution

1. Use the chat commands framework to implement this capability.
2. Show user avatars in the chat commands menu.
3. (optional) Explore ways to show an inline toast for a mentioned user, e.g. `@Nicolas` should be highlighted, clickable, and in blue.
4. Add the user to a list of mentions.

### Additional context

Does there exist a method in the frontend to retrieve the current list of users from `IChatModel`? If not, that also needs to be implemented.


Diff:
diff --git a/docs/jupyter-chat-example/src/index.ts b/docs/jupyter-chat-example/src/index.ts
index a4685b51..c25ffe68 100644
--- a/docs/jupyter-chat-example/src/index.ts
+++ b/docs/jupyter-chat-example/src/index.ts
@@ -11,7 +11,9 @@ import {
   IAttachment,
   IChatMessage,
   INewMessage,
-  SelectionWatcher
+  SelectionWatcher,
+  IChatContext,
+  AbstractChatContext
 } from '@jupyter/chat';
 import {
   JupyterFrontEnd,
@@ -24,6 +26,12 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
 import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import { UUID } from '@lumino/coreutils';
 
+class ChatContext extends AbstractChatContext {
+  get users() {
+    return [];
+  }
+}
+
 class MyChatModel extends AbstractChatModel {
   sendMessage(
     newMessage: INewMessage
@@ -39,6 +47,10 @@ class MyChatModel extends AbstractChatModel {
     this.messageAdded(message);
     this.input.clearAttachments();
   }
+
+  createChatContext(): IChatContext {
+    return new ChatContext({ model: this });
+  }
 }
 
 /*
diff --git a/packages/jupyter-chat/src/__tests__/mocks.ts b/packages/jupyter-chat/src/__tests__/mocks.ts
new file mode 100644
index 00000000..d4d83003
--- /dev/null
+++ b/packages/jupyter-chat/src/__tests__/mocks.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import {
+  AbstractChatContext,
+  AbstractChatModel,
+  IChatModel,
+  IChatContext
+} from '../model';
+import { INewMessage } from '../types';
+
+export class MockChatContext
+  extends AbstractChatContext
+  implements IChatContext
+{
+  get users() {
+    return [];
+  }
+}
+
+export class MockChatModel extends AbstractChatModel implements IChatModel {
+  sendMessage(message: INewMessage): Promise<boolean | void> | boolean | void {
+    // No-op
+  }
+
+  createChatContext(): IChatContext {
+    return new MockChatContext({ model: this });
+  }
+}
diff --git a/packages/jupyter-chat/src/__tests__/model.spec.ts b/packages/jupyter-chat/src/__tests__/model.spec.ts
index fb7bbdbf..60695bb5 100644
--- a/packages/jupyter-chat/src/__tests__/model.spec.ts
+++ b/packages/jupyter-chat/src/__tests__/model.spec.ts
@@ -7,31 +7,26 @@
  * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests
  */
 
-import { AbstractChatModel, IChatModel } from '../model';
+import { AbstractChatModel, IChatContext, IChatModel } from '../model';
 import { IChatMessage, INewMessage } from '../types';
-
-class MyChatModel extends AbstractChatModel {
-  sendMessage(message: INewMessage): Promise<boolean | void> | boolean | void {
-    // No-op
-  }
-}
+import { MockChatModel, MockChatContext } from './mocks';
 
 describe('test chat model', () => {
   describe('model instantiation', () => {
     it('should create an AbstractChatModel', () => {
-      const model = new MyChatModel();
+      const model = new MockChatModel();
       expect(model).toBeInstanceOf(AbstractChatModel);
     });
 
     it('should dispose an AbstractChatModel', () => {
-      const model = new MyChatModel();
+      const model = new MockChatModel();
       model.dispose();
       expect(model.isDisposed).toBeTruthy();
     });
   });
 
   describe('incoming message', () => {
-    class TestChat extends AbstractChatModel {
+    class TestChat extends AbstractChatModel implements IChatModel {
       protected formatChatMessage(message: IChatMessage): IChatMessage {
         message.body = 'formatted msg';
         return message;
@@ -41,6 +36,10 @@ describe('test chat model', () => {
       ): Promise<boolean | void> | boolean | void {
         // No-op
       }
+
+      createChatContext(): IChatContext {
+        return new MockChatContext({ model: this });
+      }
     }
 
     let model: IChatModel;
@@ -58,7 +57,7 @@ describe('test chat model', () => {
     });
 
     it('should signal incoming message', () => {
-      model = new MyChatModel();
+      model = new MockChatModel();
       model.messagesUpdated.connect((sender: IChatModel) => {
         expect(sender).toBe(model);
         messages = model.messages;
@@ -83,12 +82,12 @@ describe('test chat model', () => {
 
   describe('model config', () => {
     it('should have empty config', () => {
-      const model = new MyChatModel();
+      const model = new MockChatModel();
       expect(model.config.sendWithShiftEnter).toBeUndefined();
     });
 
     it('should allow config', () => {
-      const model = new MyChatModel({ config: { sendWithShiftEnter: true } });
+      const model = new MockChatModel({ config: { sendWithShiftEnter: true } });
       expect(model.config.sendWithShiftEnter).toBeTruthy();
     });
   });
diff --git a/packages/jupyter-chat/src/__tests__/widgets.spec.ts b/packages/jupyter-chat/src/__tests__/widgets.spec.ts
index bd30fac5..0fdbd424 100644
--- a/packages/jupyter-chat/src/__tests__/widgets.spec.ts
+++ b/packages/jupyter-chat/src/__tests__/widgets.spec.ts
@@ -11,22 +11,16 @@ import {
   IRenderMimeRegistry,
   RenderMimeRegistry
 } from '@jupyterlab/rendermime';
-import { AbstractChatModel, IChatModel } from '../model';
-import { INewMessage } from '../types';
+import { IChatModel } from '../model';
 import { ChatWidget } from '../widgets/chat-widget';
-
-class MyChatModel extends AbstractChatModel {
-  sendMessage(message: INewMessage): Promise<boolean | void> | boolean | void {
-    // No-op
-  }
-}
+import { MockChatModel } from './mocks';
 
 describe('test chat widget', () => {
   let model: IChatModel;
   let rmRegistry: IRenderMimeRegistry;
 
   beforeEach(() => {
-    model = new MyChatModel();
+    model = new MockChatModel();
     rmRegistry = new RenderMimeRegistry();
   });
 
diff --git a/packages/jupyter-chat/src/chat-commands/types.ts b/packages/jupyter-chat/src/chat-commands/types.ts
index 6f230a30..ad14de08 100644
--- a/packages/jupyter-chat/src/chat-commands/types.ts
+++ b/packages/jupyter-chat/src/chat-commands/types.ts
@@ -22,7 +22,7 @@ export type ChatCommand = {
    * If set, this will be rendered as the icon for the command in the chat
    * commands menu. Jupyter Chat will choose a default if this is unset.
    */
-  icon?: LabIcon | string;
+  icon?: LabIcon | JSX.Element | string | null;
 
   /**
    * If set, this will be rendered as the description for the command in the
diff --git a/packages/jupyter-chat/src/components/chat-input.tsx b/packages/jupyter-chat/src/components/chat-input.tsx
index d8da4bc9..330c4269 100644
--- a/packages/jupyter-chat/src/components/chat-input.tsx
+++ b/packages/jupyter-chat/src/components/chat-input.tsx
@@ -22,8 +22,8 @@ import {
   useChatCommands
 } from './input';
 import { IInputModel, InputModel } from '../input-model';
-import { IAttachment } from '../types';
 import { IChatCommandRegistry } from '../chat-commands';
+import { IAttachment } from '../types';
 
 const INPUT_BOX_CLASS = 'jp-chat-input-container';
 const INPUT_TOOLBAR_CLASS = 'jp-chat-input-toolbar';
@@ -256,7 +256,7 @@ export namespace ChatInput {
    */
   export interface IProps {
     /**
-     * The chat model.
+     * The input model.
      */
     model: IInputModel;
     /**
diff --git a/packages/jupyter-chat/src/components/chat-messages.tsx b/packages/jupyter-chat/src/components/chat-messages.tsx
index 95b80ef5..ff356b9d 100644
--- a/packages/jupyter-chat/src/components/chat-messages.tsx
+++ b/packages/jupyter-chat/src/components/chat-messages.tsx
@@ -25,6 +25,7 @@ import { IChatCommandRegistry } from '../chat-commands';
 import { IInputModel, InputModel } from '../input-model';
 import { IChatModel } from '../model';
 import { IChatMessage, IUser } from '../types';
+import { replaceSpanToMention } from '../utils';
 
 const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
 const MESSAGE_CLASS = 'jp-chat-message';
@@ -375,19 +376,27 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
     // Create an input model only if the message is edited.
     useEffect(() => {
       if (edit && canEdit) {
-        setInputModel(
-          new InputModel({
+        setInputModel(() => {
+          let body = message.body;
+          message.mentions?.forEach(user => {
+            body = replaceSpanToMention(body, user);
+          });
+          return new InputModel({
+            chatContext: model.createChatContext(),
             onSend: (input: string, model?: IInputModel) =>
               updateMessage(message.id, input, model),
             onCancel: () => cancelEdition(),
-            value: message.body,
+            value: body,
+            activeCellManager: model.activeCellManager,
+            selectionWatcher: model.selectionWatcher,
+            documentManager: model.documentManager,
             config: {
               sendWithShiftEnter: model.config.sendWithShiftEnter
             },
             attachments: message.attachments,
-            documentManager: model.documentManager
-          })
-        );
+            mentions: message.mentions
+          });
+        });
       } else {
         setInputModel(null);
       }
@@ -411,6 +420,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
       const updatedMessage = { ...message };
       updatedMessage.body = input;
       updatedMessage.attachments = inputModel.attachments;
+      updatedMessage.mentions = inputModel.mentions;
       model.updateMessage!(id, updatedMessage);
       setEdit(false);
     };
diff --git a/packages/jupyter-chat/src/components/input/use-chat-commands.tsx b/packages/jupyter-chat/src/components/input/use-chat-commands.tsx
index 130c3c9f..324221ed 100644
--- a/packages/jupyter-chat/src/components/input/use-chat-commands.tsx
+++ b/packages/jupyter-chat/src/components/input/use-chat-commands.tsx
@@ -3,13 +3,13 @@
  * Distributed under the terms of the Modified BSD License.
  */
 
-import React from 'react';
-import { useEffect, useState } from 'react';
+import { LabIcon } from '@jupyterlab/ui-components';
 import type {
   AutocompleteChangeReason,
   AutocompleteProps as GenericAutocompleteProps
 } from '@mui/material';
 import { Box } from '@mui/material';
+import React, { useEffect, useState } from 'react';
 
 import { ChatCommand, IChatCommandRegistry } from '../../chat-commands';
 import { IInputModel } from '../../input-model';
@@ -131,9 +131,11 @@ export function useChatCommands(
         ___: unknown
       ) => {
         const { key, ...listItemProps } = defaultProps;
-        const commandIcon: JSX.Element = (
+        const commandIcon: JSX.Element = React.isValidElement(command.icon) ? (
+          command.icon
+        ) : (
           <span>
-            {typeof command.icon === 'object' ? (
+            {command.icon instanceof LabIcon ? (
               <command.icon.react />
             ) : (
               command.icon
diff --git a/packages/jupyter-chat/src/index.ts b/packages/jupyter-chat/src/index.ts
index 0f425c65..d567d929 100644
--- a/packages/jupyter-chat/src/index.ts
+++ b/packages/jupyter-chat/src/index.ts
@@ -4,6 +4,7 @@
  */
 
 export * from './active-cell-manager';
+export * from './chat-commands';
 export * from './components';
 export * from './icons';
 export * from './input-model';
@@ -14,4 +15,3 @@ export * from './types';
 export * from './widgets/chat-error';
 export * from './widgets/chat-sidebar';
 export * from './widgets/chat-widget';
-export * from './chat-commands';
diff --git a/packages/jupyter-chat/src/input-model.ts b/packages/jupyter-chat/src/input-model.ts
index 1b3d9b7f..d34a77a9 100644
--- a/packages/jupyter-chat/src/input-model.ts
+++ b/packages/jupyter-chat/src/input-model.ts
@@ -3,12 +3,13 @@
  * Distributed under the terms of the Modified BSD License.
  */
 
+import { IDocumentManager } from '@jupyterlab/docmanager';
 import { IDisposable } from '@lumino/disposable';
 import { ISignal, Signal } from '@lumino/signaling';
 import { IActiveCellManager } from './active-cell-manager';
 import { ISelectionWatcher } from './selection-watcher';
-import { IAttachment } from './types';
-import { IDocumentManager } from '@jupyterlab/docmanager';
+import { IChatContext } from './model';
+import { IAttachment, IUser } from './types';
 
 const WHITESPACE = new Set([' ', '\n', '\t']);
 
@@ -16,6 +17,11 @@ const WHITESPACE = new Set([' ', '\n', '\t']);
  * The chat input interface.
  */
 export interface IInputModel extends IDisposable {
+  /**
+   * The chat context (a readonly subset of the chat model).
+   */
+  readonly chatContext: IChatContext;
+
   /**
    * Function to send a message.
    */
@@ -121,6 +127,26 @@ export interface IInputModel extends IDisposable {
    * Replace the current word in the input with a new one.
    */
   replaceCurrentWord(newWord: string): void;
+
+  /**
+   * The mentioned user list.
+   */
+  readonly mentions: IUser[];
+
+  /**
+   * Add user mention.
+   */
+  addMention?(user: IUser): void;
+
+  /**
+   * Remove a user mention.
+   */
+  removeMention(user: IUser): void;
+
+  /**
+   * Clear mentions list.
+   */
+  clearMentions(): void;
 }
 
 /**
@@ -129,8 +155,10 @@ export interface IInputModel extends IDisposable {
 export class InputModel implements IInputModel {
   constructor(options: InputModel.IOptions) {
     this._onSend = options.onSend;
+    this._chatContext = options.chatContext;
     this._value = options.value || '';
     this._attachments = options.attachments || [];
+    this._mentions = options.mentions || [];
     this.cursorIndex = options.cursorIndex || this.value.length;
     this._activeCellManager = options.activeCellManager ?? null;
     this._selectionWatcher = options.selectionWatcher ?? null;
@@ -141,6 +169,13 @@ export class InputModel implements IInputModel {
     this.cancel = options.onCancel;
   }
 
+  /**
+   * The chat context (a readonly subset of the chat model);
+   */
+  get chatContext(): IChatContext {
+    return this._chatContext;
+  }
+
   /**
    * Function to send a message.
    */
@@ -335,6 +370,40 @@ export class InputModel implements IInputModel {
     this.value = this.value.slice(0, start) + newWord + this.value.slice(end);
   }
 
+  /**
+   * The mentioned user list.
+   */
+  get mentions(): IUser[] {
+    return this._mentions;
+  }
+
+  /**
+   * Add a user mention.
+   */
+  addMention(user: IUser): void {
+    const usernames = this._mentions.map(user => user.username);
+    if (!usernames.includes(user.username)) {
+      this._mentions.push(user);
+    }
+  }
+
+  /**
+   * Remove a user mention.
+   */
+  removeMention(user: IUser): void {
+    const index = this._mentions.indexOf(user);
+    if (index > -1) {
+      this._mentions.splice(index, 1);
+    }
+  }
+
+  /**
+   * Clear mentions list.
+   */
+  clearMentions = (): void => {
+    this._mentions = [];
+  };
+
   /**
    * Dispose the input model.
    */
@@ -353,10 +422,12 @@ export class InputModel implements IInputModel {
   }
 
   private _onSend: (input: string, model?: InputModel) => void;
+  private _chatContext: IChatContext;
   private _value: string;
   private _cursorIndex: number | null = null;
   private _currentWord: string | null = null;
   private _attachments: IAttachment[];
+  private _mentions: IUser[];
   private _activeCellManager: IActiveCellManager | null;
   private _selectionWatcher: ISelectionWatcher | null;
   private _documentManager: IDocumentManager | null;
@@ -372,6 +443,11 @@ export class InputModel implements IInputModel {
 
 export namespace InputModel {
   export interface IOptions {
+    /**
+     * The chat context (a readonly subset of the chat model).
+     */
+    chatContext: IChatContext;
+
     /**
      * The function that should send the message.
      * @param content - the content of the message.
@@ -394,6 +470,11 @@ export namespace InputModel {
      */
     attachments?: IAttachment[];
 
+    /**
+     * The initial mentions.
+     */
+    mentions?: IUser[];
+
     /**
      * The current cursor index.
      * This refers to the index of the character in front of the cursor.
diff --git a/packages/jupyter-chat/src/model.ts b/packages/jupyter-chat/src/model.ts
index 22c99b04..dd01a44a 100644
--- a/packages/jupyter-chat/src/model.ts
+++ b/packages/jupyter-chat/src/model.ts
@@ -8,6 +8,9 @@ import { CommandRegistry } from '@lumino/commands';
 import { IDisposable } from '@lumino/disposable';
 import { ISignal, Signal } from '@lumino/signaling';
 
+import { IActiveCellManager } from './active-cell-manager';
+import { IInputModel, InputModel } from './input-model';
+import { ISelectionWatcher } from './selection-watcher';
 import {
   IChatHistory,
   INewMessage,
@@ -15,9 +18,7 @@ import {
   IConfig,
   IUser
 } from './types';
-import { IActiveCellManager } from './active-cell-manager';
-import { ISelectionWatcher } from './selection-watcher';
-import { IInputModel, InputModel } from './input-model';
+import { replaceMentionToSpan } from './utils';
 
 /**
  * The chat model interface.
@@ -172,6 +173,11 @@ export interface IChatModel extends IDisposable {
    * Update the current writers list.
    */
   updateWriters(writers: IUser[]): void;
+
+  /**
+   * Create the chat context that will be passed to the input model.
+   */
+  createChatContext(): IChatContext;
 }
 
 /**
@@ -199,6 +205,7 @@ export abstract class AbstractChatModel implements IChatModel {
     };
 
     this._inputModel = new InputModel({
+      chatContext: this.createChatContext(),
       activeCellManager: options.activeCellManager,
       selectionWatcher: options.selectionWatcher,
       documentManager: options.documentManager,
@@ -456,6 +463,9 @@ export abstract class AbstractChatModel implements IChatModel {
    * Can be useful if some actions are required on the message.
    */
   protected formatChatMessage(message: IChatMessage): IChatMessage {
+    message.mentions?.forEach(user => {
+      message.body = replaceMentionToSpan(message.body, user);
+    });
     return message;
   }
 
@@ -540,6 +550,11 @@ export abstract class AbstractChatModel implements IChatModel {
     this._writersChanged.emit(writers);
   }
 
+  /**
+   * Create the chat context that will be passed to the input model.
+   */
+  abstract createChatContext(): IChatContext;
+
   /**
    * Add unread messages to the list.
    * @param indexes - list of new indexes.
@@ -648,3 +663,49 @@ export namespace IChatModel {
     documentManager?: IDocumentManager | null;
   }
 }
+
+/**
+ * Interface of the chat context, a 'subset' of the model with readonly attribute,
+ * which can be passed to the input model.
+ * This allows third party extensions to get some attribute of the model without
+ * exposing the method that can modify it.
+ */
+export interface IChatContext {
+  /**
+   * The name of the chat.
+   */
+  readonly name: string;
+  /**
+   * A copy of the messages.
+   */
+  readonly messages: IChatMessage[];
+  /**
+   * A list of all users who have connected to this chat.
+   */
+  readonly users: IUser[];
+}
+
+/**
+ * An abstract base class implementing `IChatContext`. This can be extended into
+ * a complete implementation, as done in `jupyterlab-chat`.
+ */
+export abstract class AbstractChatContext implements IChatContext {
+  constructor(options: { model: IChatModel }) {
+    this._model = options.model;
+  }
+
+  get name(): string {
+    return this._model.name;
+  }
+
+  get messages(): IChatMessage[] {
+    return [...this._model.messages];
+  }
+
+  /**
+   * ABSTRACT: Should return a list of users who have connected to this chat.
+   */
+  abstract get users(): IUser[];
+
+  protected _model: IChatModel;
+}
diff --git a/packages/jupyter-chat/src/types.ts b/packages/jupyter-chat/src/types.ts
index b7866233..4add9250 100644
--- a/packages/jupyter-chat/src/types.ts
+++ b/packages/jupyter-chat/src/types.ts
@@ -13,6 +13,10 @@ export interface IUser {
   initials?: string;
   color?: string;
   avatar_url?: string;
+  /**
+   * The string to use to mention a user in the chat.
+   */
+  mention_name?: string;
 }
 
 /**
@@ -51,6 +55,7 @@ export interface IChatMessage<T = IUser, U = IAttachment> {
   time: number;
   sender: T;
   attachments?: U[];
+  mentions?: T[];
   raw_time?: boolean;
   deleted?: boolean;
   edited?: boolean;
diff --git a/packages/jupyter-chat/src/utils.ts b/packages/jupyter-chat/src/utils.ts
index e610a033..11a282b3 100644
--- a/packages/jupyter-chat/src/utils.ts
+++ b/packages/jupyter-chat/src/utils.ts
@@ -10,6 +10,10 @@ import { FileEditor } from '@jupyterlab/fileeditor';
 import { Notebook } from '@jupyterlab/notebook';
 import { Widget } from '@lumino/widgets';
 
+import { IUser } from './types';
+
+const MENTION_CLASS = 'jp-chat-mention';
+
 /**
  * Gets the editor instance used by a document widget. Returns `null` if unable.
  */
@@ -45,3 +49,33 @@ export function getCellIndex(notebook: Notebook, cellId: string): number {
   );
   return idx === undefined ? -1 : idx;
 }
+
+/**
+ * Replace a mention to user (@someone) to a span, for markdown renderer.
+ *
+ * @param content - the content to update.
+ * @param user - the user mentioned.
+ */
+export function replaceMentionToSpan(content: string, user: IUser): string {
+  if (!user.mention_name) {
+    return content;
+  }
+  const regex = new RegExp(user.mention_name, 'g');
+  const mention = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
+  return content.replace(regex, mention);
+}
+
+/**
+ * Replace a span to a mentioned to user string (@someone).
+ *
+ * @param content - the content to update.
+ * @param user - the user mentioned.
+ */
+export function replaceSpanToMention(content: string, user: IUser): string {
+  if (!user.mention_name) {
+    return content;
+  }
+  const span = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
+  const regex = new RegExp(span, 'g');
+  return content.replace(regex, user.mention_name);
+}
diff --git a/packages/jupyter-chat/style/chat.css b/packages/jupyter-chat/style/chat.css
index 59875014..ced2417a 100644
--- a/packages/jupyter-chat/style/chat.css
+++ b/packages/jupyter-chat/style/chat.css
@@ -140,3 +140,9 @@
   color: gray;
   margin: 5px;
 }
+
+.jp-chat-mention {
+  border-radius: 10px;
+  padding: 0 0.2em;
+  background-color: var(--jp-brand-color4);
+}
diff --git a/packages/jupyterlab-chat-extension/src/chat-commands/providers/user-mention.tsx b/packages/jupyterlab-chat-extension/src/chat-commands/providers/user-mention.tsx
new file mode 100644
index 00000000..97107df0
--- /dev/null
+++ b/packages/jupyterlab-chat-extension/src/chat-commands/providers/user-mention.tsx
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import React from 'react';
+import {
+  IChatCommandProvider,
+  IChatCommandRegistry,
+  ChatCommand,
+  IInputModel,
+  Avatar,
+  IUser
+} from '@jupyter/chat';
+import { JupyterFrontEndPlugin } from '@jupyterlab/application';
+
+export const mentionCommandsPlugin: JupyterFrontEndPlugin<void> = {
+  id: 'jupyterlab-chat-extension:mentionCommandsPlugin',
+  description: 'Plugin which adds user mention commands.',
+  autoStart: true,
+  requires: [IChatCommandRegistry],
+  activate: (app, registry: IChatCommandRegistry) => {
+    registry.addProvider(new MentionCommandProvider());
+  }
+};
+
+class MentionCommandProvider implements IChatCommandProvider {
+  public id: string = 'jupyter-chat:mention-commands';
+
+  // regex used to test the current word
+  private _regex: RegExp = /^@[\w-]*:?/;
+
+  async getChatCommands(inputModel: IInputModel) {
+    this._users.clear();
+    const match = inputModel.currentWord?.match(this._regex)?.[0];
+    if (!match) {
+      return [];
+    }
+
+    const users = inputModel.chatContext.users;
+    users.forEach(user => {
+      let mentionName = user.mention_name;
+      if (!mentionName) {
+        mentionName = Private.getMentionName(user);
+        user.mention_name = mentionName;
+      }
+
+      this._users.set(mentionName, {
+        user,
+        icon: <Avatar user={user} />
+      });
+    });
+
+    // Build the commands for each user.
+    const commands: ChatCommand[] = Array.from(this._users)
+      .sort()
+      .filter(user => user[0].startsWith(match))
+      .map(user => {
+        return {
+          name: user[0],
+          providerId: this.id,
+          icon: user[1].icon
+        };
+      });
+    return commands;
+  }
+
+  async handleChatCommand(
+    command: ChatCommand,
+    inputModel: IInputModel
+  ): Promise<void> {
+    inputModel.replaceCurrentWord(`${command.name} `);
+    if (this._users.has(command.name)) {
+      inputModel.addMention?.(this._users.get(command.name)!.user);
+    }
+  }
+
+  private _users = new Map<string, Private.CommandUser>();
+}
+
+namespace Private {
+  export type CommandUser = {
+    user: IUser;
+    icon: JSX.Element | null;
+  };
+
+  /**
+   * Build the mention name from a User object.
+   */
+  export function getMentionName(user: IUser): string {
+    const username = (user.display_name ?? user.name ?? user.username).replace(
+      / /g,
+      '-'
+    );
+    return `@${username}`;
+  }
+}
diff --git a/packages/jupyterlab-chat-extension/src/index.ts b/packages/jupyterlab-chat-extension/src/index.ts
index 6acb74e0..47a80f53 100644
--- a/packages/jupyterlab-chat-extension/src/index.ts
+++ b/packages/jupyterlab-chat-extension/src/index.ts
@@ -66,6 +66,7 @@ import {
 } from 'jupyterlab-chat';
 import { chatCommandRegistryPlugin } from './chat-commands/plugins';
 import { emojiCommandsPlugin } from './chat-commands/providers/emoji';
+import { mentionCommandsPlugin } from './chat-commands/providers/user-mention';
 
 const FACTORY = 'Chat';
 
@@ -826,5 +827,6 @@ export default [
   docFactories,
   inputToolbarFactory,
   selectionWatcher,
-  emojiCommandsPlugin
+  emojiCommandsPlugin,
+  mentionCommandsPlugin
 ];
diff --git a/packages/jupyterlab-chat/src/model.ts b/packages/jupyterlab-chat/src/model.ts
index 43341710..5fa196e9 100644
--- a/packages/jupyterlab-chat/src/model.ts
+++ b/packages/jupyterlab-chat/src/model.ts
@@ -5,7 +5,9 @@
 
 import {
   AbstractChatModel,
+  AbstractChatContext,
   IAttachment,
+  IChatContext,
   IChatMessage,
   IChatModel,
   IInputModel,
@@ -137,6 +139,10 @@ export class LabChatModel
     // nothing to do
   }
 
+  createChatContext(): IChatContext {
+    return new LabChatContext({ model: this });
+  }
+
   async messagesInserted(
     index: number,
     messages: IChatMessage[]
@@ -177,6 +183,13 @@ export class LabChatModel
     }
     this.input.clearAttachments();
 
+    // Add the mentioned users.
+    const mentions = this._buildMentionList(this.input.mentions, message.body);
+    if (mentions.length) {
+      msg.mentions = mentions;
+    }
+    this.input.clearMentions();
+
     this.sharedModel.addMessage(msg);
   }
 
@@ -208,6 +221,8 @@ export class LabChatModel
         edited: true
       };
     }
+
+    // Update the attachments.
     const attachmentIds = updatedMessage.attachments?.map(attachment =>
       this.sharedModel.setAttachment(attachment)
     );
@@ -217,6 +232,17 @@ export class LabChatModel
       delete message.attachments;
     }
 
+    // Update the mentioned users.
+    const mentions = this._buildMentionList(
+      updatedMessage.mentions,
+      updatedMessage.body
+    );
+    if (mentions.length) {
+      message.mentions = mentions;
+    } else {
+      delete message.mentions;
+    }
+
     this.sharedModel.updateMessage(index, message as IYmessage);
   }
 
@@ -268,6 +294,33 @@ export class LabChatModel
     this.updateWriters(writers);
   };
 
+  private _buildMentionList(
+    userMentions: IUser[] | undefined,
+    body: string
+  ): string[] {
+    if (!userMentions) {
+      return [];
+    }
+    const mentions: string[] = [];
+    userMentions.forEach(user => {
+      // Make sure the user is still mentioned.
+      if (!user.mention_name) {
+        return;
+      }
+      const regex = new RegExp(user.mention_name);
+      if (!regex.exec(body)) {
+        return;
+      }
+
+      // Save the mention name if necessary.
+      if (!(this.sharedModel.getUser(user.username) === user)) {
+        this.sharedModel.setUser(user);
+      }
+      mentions.push(user.username);
+    });
+    return mentions;
+  }
+
   private _resetWritingStatus() {
     const awareness = this.sharedModel.awareness;
     const states = awareness.getLocalState();
@@ -288,6 +341,7 @@ export class LabChatModel
             const {
               sender,
               attachments: attachmentIds,
+              mentions: mentionsIds,
               ...baseMessage
             } = ymessage;
 
@@ -313,6 +367,16 @@ export class LabChatModel
               }
             }
 
+            const mentions = mentionsIds?.map(
+              user =>
+                this.sharedModel.getUser(user) || {
+                  username: 'User undefined'
+                }
+            );
+
+            if (mentions?.length) {
+              msg.mentions = mentions;
+            }
             return msg;
           });
           await this.messagesInserted(index, messages);
@@ -332,6 +396,15 @@ export class LabChatModel
         }
       });
     }
+
+    if (changes.userChanges) {
+      // Update the current user if it changes (if it has been mentioned for example).
+      changes.userChanges.forEach(change => {
+        if (change.key === this._user.username && change.newValue) {
+          this._user = change.newValue;
+        }
+      });
+    }
   };
 
   readonly defaultKernelName: string = '';
@@ -349,3 +422,32 @@ export class LabChatModel
 
   private _user: IUser;
 }
+
+/**
+ * The chat context to be sent to the input model.
+ */
+export class LabChatContext extends AbstractChatContext {
+  /**
+   * The list of users who have connected to this chat.
+   */
+  get users(): IUser[] {
+    const model = this._model as LabChatModel;
+    const users = new Set<IUser>();
+
+    // Get the user list from the shared YChat
+    Object.values(model.sharedModel.users).forEach(userObject => {
+      users.add(userObject);
+    });
+
+    // Add the users connected to the chat (even if they never sent a message).
+    model.sharedModel.awareness.getStates().forEach(value => {
+      const user = value.user as IUser;
+      if (!user) {
+        return;
+      }
+      users.add(user);
+    });
+
+    return Array.from(users);
+  }
+}
diff --git a/packages/jupyterlab-chat/src/ychat.ts b/packages/jupyterlab-chat/src/ychat.ts
index b295e624..36abd8ee 100644
--- a/packages/jupyterlab-chat/src/ychat.ts
+++ b/packages/jupyterlab-chat/src/ychat.ts
@@ -100,8 +100,13 @@ export class YChat extends YDocument<IChatChanges> {
     return (this._metadata.get('id') as string) || '';
   }
 
-  get users(): JSONObject {
-    return JSONExt.deepCopy(this._users.toJSON());
+  get users(): Record<string, IUser> {
+    const userDict = JSONExt.deepCopy(this._users.toJSON()) as Record<
+      string,
+      IUser
+    >;
+
+    return userDict;
   }
 
   get messages(): string[] {
diff --git a/python/jupyterlab-chat/jupyterlab_chat/models.py b/python/jupyterlab-chat/jupyterlab_chat/models.py
index 4e9d590c..4715af1e 100644
--- a/python/jupyterlab-chat/jupyterlab_chat/models.py
+++ b/python/jupyterlab-chat/jupyterlab_chat/models.py
@@ -1,7 +1,7 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Literal, Optional
 from jupyter_server.auth import User as JupyterUser
 
@@ -37,6 +37,9 @@ class Message:
 
     attachments: Optional[list[str]] = None
     """ The message attachments, a list of attachment ID """
+    
+    mentions: Optional[list[str]] = field(default_factory=list)
+    """ Users mentioned in the message """
 
     raw_time: Optional[bool] = None
     """
@@ -70,7 +73,10 @@ class NewMessage:
 
 @dataclass
 class User(JupyterUser):
-    """ Object representing a user (same as Jupyter User ) """
+    """ Object representing a user """
+
+    mention_name: Optional[str] = None
+    """ The string to use as mention in chat """
 
 
 @dataclass
diff --git a/ui-tests/tests/user-mention.spec.ts b/ui-tests/tests/user-mention.spec.ts
new file mode 100644
index 00000000..d4e6d61b
--- /dev/null
+++ b/ui-tests/tests/user-mention.spec.ts
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import {
+  expect,
+  galata,
+  IJupyterLabPageFixture,
+  test
+} from '@jupyterlab/galata';
+import { User } from '@jupyterlab/services';
+import { openChat, sendMessage, USER } from './test-utils';
+
+const FILENAME = 'my-chat.chat';
+
+test.use({
+  mockUser: USER
+});
+
+test.describe('#user-mention', () => {
+  test.beforeEach(async ({ page }) => {
+    // Create 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 open chat command', async ({ page }) => {
+    const chatPanel = await openChat(page, FILENAME);
+    const input = chatPanel
+      .locator('.jp-chat-input-container')
+      .getByRole('combobox');
+    const chatCommandName = page.locator('.jp-chat-command-name');
+    await input.press('@');
+    await expect(chatCommandName).toHaveCount(1);
+    expect(await chatCommandName.nth(0).textContent()).toBe('@jovyan');
+  });
+
+  test('should format mention in message', async ({ page }) => {
+    const chatPanel = await openChat(page, FILENAME);
+    const input = chatPanel
+      .locator('.jp-chat-input-container')
+      .getByRole('combobox');
+    const sendButton = chatPanel.locator(
+      '.jp-chat-input-container .jp-chat-send-button'
+    );
+    const chatCommandName = page.locator('.jp-chat-command-name');
+    await input.press('@');
+    await chatCommandName.nth(0).click();
+
+    await expect(input).toContainText('@jovyan');
+    await sendButton.click();
+
+    const message = chatPanel.locator('.jp-chat-messages-container').nth(0);
+    await expect(message).toBeAttached();
+    expect(message.locator('.jp-chat-mention')).toBeAttached();
+    expect(message.locator('.jp-chat-mention')).toContainText('@jovyan');
+  });
+});
+
+test.describe('#collaborator-mention', () => {
+  let guestPage: IJupyterLabPageFixture;
+  test.beforeEach(
+    async ({ baseURL, browser, page, tmpPath, waitForApplication }) => {
+      // Create chat file
+      await page.filebrowser.contents.uploadContent('{}', 'text', FILENAME);
+      // Create a new user.
+      const user2: Partial<User.IUser> = {
+        identity: {
+          username: 'jovyan_2',
+          name: 'jovyan_2',
+          display_name: 'jovyan_2',
+          initials: 'JP',
+          color: 'var(--jp-collaborator-color2)'
+        }
+      };
+
+      // Create a new page for guest.
+      const { page: newPage } = await galata.newPage({
+        baseURL: baseURL!,
+        browser,
+        mockUser: user2,
+        tmpPath,
+        waitForApplication
+      });
+      await newPage.evaluate(() => {
+        // Acknowledge any dialog
+        window.galataip.on('dialog', d => {
+          d?.resolve();
+        });
+      });
+      guestPage = newPage;
+    }
+  );
+
+  test.afterEach(async ({ page }) => {
+    guestPage.close();
+    if (await page.filebrowser.contents.fileExists(FILENAME)) {
+      await page.filebrowser.contents.deleteFile(FILENAME);
+    }
+  });
+
+  test('should display collaborators in chat commands', async ({ page }) => {
+    const chatPanel = await openChat(page, FILENAME);
+
+    // Send a message from guest.
+    await sendMessage(guestPage, FILENAME, 'test');
+
+    const input = chatPanel
+      .locator('.jp-chat-input-container')
+      .getByRole('combobox');
+    const chatCommandName = page.locator('.jp-chat-command-name');
+
+    await input.press('@');
+    await expect(chatCommandName).toHaveCount(2);
+    for (let i = 0; i < (await chatCommandName.count()); i++) {
+      expect(['@jovyan', '@jovyan_2']).toContain(
+        await chatCommandName.nth(i).textContent()
+      );
+    }
+  });
+});


Reviews:
user: login: dlqqq
body: @brichet Thank you for working on this! This is a huge contribution.

I left some feedback below. Not sure how much of it we want to address in this PR. I think you're in a better position to judge what can / can't be addressed here.

BTW, does this allow typing `@Jupyternaut` when Jupyter AI is installed?
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-02T18:11:27Z
user: login: brichet
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T15:51:06Z
user: login: brichet
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T15:53:30Z
user: login: dlqqq
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T16:01:45Z
user: login: dlqqq
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T16:04:28Z
user: login: brichet
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T16:26:03Z
user: login: dlqqq
body: @brichet Love the new chat context! I thought it would be a second argument, but placing it under `InputModel` still makes sense & keeps the API smaller. 🤗 

I'm not able to give a full review today, but I was able to find an opportunity to simplify the code further. Let me know what you think! 👋 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-04T22:28:49Z
user: login: dlqqq
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-04T22:31:33Z
user: login: brichet
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T06:53:08Z
user: login: brichet
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T07:00:17Z
user: login: dlqqq
body: Thank you for the updates @brichet! There are still some unsafe type casts being introduced by this branch, and I think we should continue exploring ways to keep the code safe before merging.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-08T20:57:08Z
user: login: dlqqq
body: 🎉 🎉 🎉 Thank you! We can revert some of the changes concerning chat context later, if we find a better way to provide a list of users to chat commands. 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-10T20:02:52Z

Review comments:
diff_hunk: @@ -22,7 +23,7 @@ export type ChatCommand = {
    * If set, this will be rendered as the icon for the command in the chat
    * commands menu. Jupyter Chat will choose a default if this is unset.
    */
-  icon?: LabIcon | string;
+  icon?: LabIcon | JSX.Element | string;
path: packages/jupyter-chat/src/chat-commands/types.ts
user: login: dlqqq
body: Thanks for adding this! I need this feature too.
created_at: 2025-04-02T17:43:54Z
author_association: MEMBER
diff_hunk: @@ -45,3 +49,33 @@ export function getCellIndex(notebook: Notebook, cellId: string): number {
   );
   return idx === undefined ? -1 : idx;
 }
+
+/**
+ * Replace a mention to user (@someone) to a span, for markdown renderer.
+ *
+ * @param content - the content to update.
+ * @param user - the user mentioned.
+ */
+export function replaceMentionToSpan(content: string, user: IUser): string {
+  if (!user.mention_name) {
+    return content;
+  }
+  const regex = new RegExp(user.mention_name, 'g');
+  const mention = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
+  return content.replace(regex, mention);
+}
+
+/**
+ * Replace a span to a mentioned to user string (@someone).
+ *
+ * @param content - the content to update.
+ * @param user - the user mentioned.
+ */
+export function replaceSpanToMention(content: string, user: IUser): string {
+  if (!user.mention_name) {
+    return content;
+  }
+  const span = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
+  const regex = new RegExp(span, 'g');
+  return content.replace(regex, user.mention_name);
+}
path: packages/jupyter-chat/src/utils.ts
user: login: dlqqq
body: This definitely works, but there are a few issues that we should address:

1. This may introduce a XSS vulnerability because it's unclear whether `user.mention_name` is sanitized before rendering. React might do this automatically, but if not, a malicious actor could exploit this by setting their username to `Good person <script src="https://evil.com/evil.js" />`.

2. This is handling a chat command outside of the `ChatCommandProvider` class. This conflicts with the design goals of the Chat Commands framework, which intended to ensure that chat commands could be fully defined in a single source file (for readability & maintainability).

3. The mentioned user isn't highlighted in the chat input, and is treated like a regular string of text.

I believe the ideal long-term solution is to extend the Chat Commands framework to allow replacing the current word with a HTML element within the chat box. I'm imagining a UX similar to that of Slack & Discord, where an `@username` is rendered as a single block once the command is run. I do not know how this can be implemented in MUI, but it must be possible since Slack & Discord support this.

created_at: 2025-04-02T17:55:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,123 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import {
+  IChatCommandProvider,
+  IChatCommandRegistry,
+  ChatCommand,
+  IInputModel,
+  Avatar,
+  IUser
+} from '@jupyter/chat';
+import { JupyterFrontEndPlugin } from '@jupyterlab/application';
+import { User } from '@jupyterlab/services';
+import { JSONObject } from '@lumino/coreutils';
+import { LabChatModel } from 'jupyterlab-chat';
+
+export const mentionCommandsPlugin: JupyterFrontEndPlugin<void> = {
+  id: 'jupyterlab-chat-extension:mentionCommandsPlugin',
+  description: 'Plugin which adds user mention commands.',
+  autoStart: true,
+  requires: [IChatCommandRegistry],
+  activate: (app, registry: IChatCommandRegistry) => {
+    registry.addProvider(new MentionCommandProvider());
+  }
+};
+
+class MentionCommandProvider implements IChatCommandProvider {
+  public id: string = 'jupyter-chat:mention-commands';
+
+  // regex used to test the current word
+  private _regex: RegExp = /^@[\w-]*:?/;
+
+  async getChatCommands(inputModel: IInputModel, chatModel: LabChatModel) {
path: python/jupyterlab-chat/src/chat-commands/providers/user-mention.ts
user: login: dlqqq
body: Passing the entire `chatModel` to each command provider may be providing an excessive amount of scope. Ideally, chat command providers should only have control over the input, and should not be able to affect the entire chat state.

It seems like it may make more sense to replace the `chatModel: ChatModel` argument with a `chatContext: ChatContext` object, a custom object that provides read-only APIs to the chat, like `getUsers()`. That would also greatly simplify this method, since `MentionCommandProvider` could just call `chatContext.getUsers()` to get the list of users.
created_at: 2025-04-02T18:08:29Z
author_association: MEMBER
diff_hunk: @@ -45,3 +49,33 @@ export function getCellIndex(notebook: Notebook, cellId: string): number {
   );
   return idx === undefined ? -1 : idx;
 }
+
+/**
+ * Replace a mention to user (@someone) to a span, for markdown renderer.
+ *
+ * @param content - the content to update.
+ * @param user - the user mentioned.
+ */
+export function replaceMentionToSpan(content: string, user: IUser): string {
+  if (!user.mention_name) {
+    return content;
+  }
+  const regex = new RegExp(user.mention_name, 'g');
+  const mention = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
+  return content.replace(regex, mention);
+}
+
+/**
+ * Replace a span to a mentioned to user string (@someone).
+ *
+ * @param content - the content to update.
+ * @param user - the user mentioned.
+ */
+export function replaceSpanToMention(content: string, user: IUser): string {
+  if (!user.mention_name) {
+    return content;
+  }
+  const span = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
+  const regex = new RegExp(span, 'g');
+  return content.replace(regex, user.mention_name);
+}
path: packages/jupyter-chat/src/utils.ts
user: login: brichet
body: > 1. This may introduce a XSS vulnerability because it's unclear whether `user.mention_name` is sanitized before rendering. React might do this automatically, but if not, a malicious actor could exploit this by setting their username to `Good person <script src="https://evil.com/evil.js" />`.

Good catch, we should definitely address this one before moving forward

> I believe the ideal long-term solution is to extend the Chat Commands framework to allow replacing the current word with a HTML element within the chat box.

The chat box is currently a `textarea`, which does not allow us to insert HTML Elements. The current behavior is the same as in Zulip for example I think.
For Slack, the input seems to be an editable div containing HTML elements. We can probably update the input to use a library providing these features (Quill, Code mirror...) in a follow up PR.


created_at: 2025-04-03T15:51:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,123 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import {
+  IChatCommandProvider,
+  IChatCommandRegistry,
+  ChatCommand,
+  IInputModel,
+  Avatar,
+  IUser
+} from '@jupyter/chat';
+import { JupyterFrontEndPlugin } from '@jupyterlab/application';
+import { User } from '@jupyterlab/services';
+import { JSONObject } from '@lumino/coreutils';
+import { LabChatModel } from 'jupyterlab-chat';
+
+export const mentionCommandsPlugin: JupyterFrontEndPlugin<void> = {
+  id: 'jupyterlab-chat-extension:mentionCommandsPlugin',
+  description: 'Plugin which adds user mention commands.',
+  autoStart: true,
+  requires: [IChatCommandRegistry],
+  activate: (app, registry: IChatCommandRegistry) => {
+    registry.addProvider(new MentionCommandProvider());
+  }
+};
+
+class MentionCommandProvider implements IChatCommandProvider {
+  public id: string = 'jupyter-chat:mention-commands';
+
+  // regex used to test the current word
+  private _regex: RegExp = /^@[\w-]*:?/;
+
+  async getChatCommands(inputModel: IInputModel, chatModel: LabChatModel) {
path: python/jupyterlab-chat/src/chat-commands/providers/user-mention.ts
user: login: brichet
body: :+1: I agree with you, and maybe that read-only chat model could be passed to the `ChatInput`, that way it could also be used for the buttons in the toolbar registry.
created_at: 2025-04-03T15:53:29Z
author_association: COLLABORATOR
diff_hunk: @@ -45,3 +49,33 @@ export function getCellIndex(notebook: Notebook, cellId: string): number {
   );
   return idx === undefined ? -1 : idx;
 }
+
+/**
+ * Replace a mention to user (@someone) to a span, for markdown renderer.
+ *
+ * @param content - the content to update.
+ * @param user - the user mentioned.
+ */
+export function replaceMentionToSpan(content: string, user: IUser): string {
+  if (!user.mention_name) {
+    return content;
+  }
+  const regex = new RegExp(user.mention_name, 'g');
+  const mention = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
+  return content.replace(regex, mention);
+}
+
+/**
+ * Replace a span to a mentioned to user string (@someone).
+ *
+ * @param content - the content to update.
+ * @param user - the user mentioned.
+ */
+export function replaceSpanToMention(content: string, user: IUser): string {
+  if (!user.mention_name) {
+    return content;
+  }
+  const span = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
+  const regex = new RegExp(span, 'g');
+  return content.replace(regex, user.mention_name);
+}
path: packages/jupyter-chat/src/utils.ts
user: login: dlqqq
body: @brichet Thanks for the quick research! I opened a new issue to track this: #204.
created_at: 2025-04-03T16:01:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,123 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import {
+  IChatCommandProvider,
+  IChatCommandRegistry,
+  ChatCommand,
+  IInputModel,
+  Avatar,
+  IUser
+} from '@jupyter/chat';
+import { JupyterFrontEndPlugin } from '@jupyterlab/application';
+import { User } from '@jupyterlab/services';
+import { JSONObject } from '@lumino/coreutils';
+import { LabChatModel } from 'jupyterlab-chat';
+
+export const mentionCommandsPlugin: JupyterFrontEndPlugin<void> = {
+  id: 'jupyterlab-chat-extension:mentionCommandsPlugin',
+  description: 'Plugin which adds user mention commands.',
+  autoStart: true,
+  requires: [IChatCommandRegistry],
+  activate: (app, registry: IChatCommandRegistry) => {
+    registry.addProvider(new MentionCommandProvider());
+  }
+};
+
+class MentionCommandProvider implements IChatCommandProvider {
+  public id: string = 'jupyter-chat:mention-commands';
+
+  // regex used to test the current word
+  private _regex: RegExp = /^@[\w-]*:?/;
+
+  async getChatCommands(inputModel: IInputModel, chatModel: LabChatModel) {
path: python/jupyterlab-chat/src/chat-commands/providers/user-mention.ts
user: login: dlqqq
body: @brichet That sounds good! Do you think that this should be done in a follow-up PR, or should we address it here?

Either way sounds fine to me, but I think we should solve this before the next release. Otherwise we might have to migrate Jupyter AI v3 to use `chatModel`, only to change it later to use `chatContext`.
created_at: 2025-04-03T16:04:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,123 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import {
+  IChatCommandProvider,
+  IChatCommandRegistry,
+  ChatCommand,
+  IInputModel,
+  Avatar,
+  IUser
+} from '@jupyter/chat';
+import { JupyterFrontEndPlugin } from '@jupyterlab/application';
+import { User } from '@jupyterlab/services';
+import { JSONObject } from '@lumino/coreutils';
+import { LabChatModel } from 'jupyterlab-chat';
+
+export const mentionCommandsPlugin: JupyterFrontEndPlugin<void> = {
+  id: 'jupyterlab-chat-extension:mentionCommandsPlugin',
+  description: 'Plugin which adds user mention commands.',
+  autoStart: true,
+  requires: [IChatCommandRegistry],
+  activate: (app, registry: IChatCommandRegistry) => {
+    registry.addProvider(new MentionCommandProvider());
+  }
+};
+
+class MentionCommandProvider implements IChatCommandProvider {
+  public id: string = 'jupyter-chat:mention-commands';
+
+  // regex used to test the current word
+  private _regex: RegExp = /^@[\w-]*:?/;
+
+  async getChatCommands(inputModel: IInputModel, chatModel: LabChatModel) {
path: python/jupyterlab-chat/src/chat-commands/providers/user-mention.ts
user: login: brichet
body: I'll update this PR
created_at: 2025-04-03T16:26:03Z
author_association: COLLABORATOR
diff_hunk: @@ -3,19 +3,26 @@
  * Distributed under the terms of the Modified BSD License.
  */
 
+import { IDocumentManager } from '@jupyterlab/docmanager';
 import { IDisposable } from '@lumino/disposable';
 import { ISignal, Signal } from '@lumino/signaling';
 import { IActiveCellManager } from './active-cell-manager';
 import { ISelectionWatcher } from './selection-watcher';
-import { IAttachment } from './types';
-import { IDocumentManager } from '@jupyterlab/docmanager';
+import { IChatContext } from './model';
+import { IAttachment, IUser } from './types';
 
 const WHITESPACE = new Set([' ', '\n', '\t']);
 
 /**
  * The chat input interface.
  */
-export interface IInputModel extends IDisposable {
+export interface IInputModel<T extends IChatContext = IChatContext>
+  extends IDisposable {
path: packages/jupyter-chat/src/input-model.ts
user: login: dlqqq
body: If we're only providing a single implementation of `IInputModel`, can this type be made non-generic? Generic types make the code more complex for multiple reasons:

1. A generic interface requires a user to inspect the interface & read through it to determine what the type argument `T` is specifying.
2. Type arguments often cannot be inferred, which encourages unsafe type casting (e.g. `as any`) as a quick fix.
3. A generic type argument `T` doesn't provide any insight into what `T` may possibly be. By contrast, a union like `type ChatContext = ChatContextA | ChatContextB` makes it clear that it can only be 1/2 of types, and [discriminators](https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#discriminating-unions) can be used to narrow the type of an instance of `ChatContext`.

To address this, I recommend that we:

1. Explore if a single type can be defined for `ChatContext`,
2. And if that is not possible, prefer defining `ChatContext` as a union of subtypes instead of as a generic type argument.
created_at: 2025-04-04T22:23:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,98 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import {
+  IChatCommandProvider,
+  IChatCommandRegistry,
+  ChatCommand,
+  IInputModel,
+  Avatar,
+  IUser
+} from '@jupyter/chat';
+import { JupyterFrontEndPlugin } from '@jupyterlab/application';
+import { User } from '@jupyterlab/services';
+import { LabChatContext } from 'jupyterlab-chat';
+
+export const mentionCommandsPlugin: JupyterFrontEndPlugin<void> = {
+  id: 'jupyterlab-chat-extension:mentionCommandsPlugin',
+  description: 'Plugin which adds user mention commands.',
+  autoStart: true,
+  requires: [IChatCommandRegistry],
+  activate: (app, registry: IChatCommandRegistry) => {
+    registry.addProvider(new MentionCommandProvider());
+  }
+};
+
+class MentionCommandProvider implements IChatCommandProvider {
+  public id: string = 'jupyter-chat:mention-commands';
+
+  // regex used to test the current word
+  private _regex: RegExp = /^@[\w-]*:?/;
+
+  async getChatCommands(inputModel: IInputModel) {
+    this._users.clear();
+    const match = inputModel.currentWord?.match(this._regex)?.[0];
+    if (!match) {
+      return [];
+    }
+
+    const users = (inputModel.chatContext as LabChatContext).users;
path: packages/jupyterlab-chat-extension/src/chat-commands/providers/user-mention.ts
user: login: dlqqq
body: This is an example of unsafe type casting encouraged by generic types. If `chatContext` were a union, this could be made type-safe by replacing this with:

```ts
// inputModel: IChatContext
if (inputModel.type !== 'lab') {
  return [];
}

// now inputModel is of type 'LabChatContext'
```
created_at: 2025-04-04T22:25:23Z
author_association: MEMBER
diff_hunk: @@ -648,3 +673,32 @@ export namespace IChatModel {
     documentManager?: IDocumentManager | null;
   }
 }
+
+/**
+ * Interface of the chat context, a 'subset' of the model with readonly attribute,
+ * which can be passed to the input model.
+ * This allows third party extensions to get some attribute of the model without
+ * exposing the method that can modify it.
+ */
+export interface IChatContext {
+  /**
+   * The name of the chat.
+   */
+  readonly name: string;
+}
+
+/**
+ * A default implementation of the IChatContext, that can be extended to provide more
+ * attributes.
+ */
+export class ChatContext implements IChatContext {
+  constructor(options: { model: IChatModel }) {
+    this._model = options.model;
+  }
+
+  get name(): string {
+    return this._model.name;
+  }
+
+  protected _model: IChatModel;
+}
path: packages/jupyter-chat/src/model.ts
user: login: dlqqq
body: Forgot to add this to my review.

Just curious, what is the reasoning behind have a `ChatContext` and a `LabChatContext`? It may not be clear to future devs which one is the "main" implementation used in Jupyter Chat. Instead, it could be better to stick to a single implementation per interface, and let developers provide their own implementation if they want to use another. What do you think?
created_at: 2025-04-04T22:31:29Z
author_association: MEMBER
diff_hunk: @@ -3,19 +3,26 @@
  * Distributed under the terms of the Modified BSD License.
  */
 
+import { IDocumentManager } from '@jupyterlab/docmanager';
 import { IDisposable } from '@lumino/disposable';
 import { ISignal, Signal } from '@lumino/signaling';
 import { IActiveCellManager } from './active-cell-manager';
 import { ISelectionWatcher } from './selection-watcher';
-import { IAttachment } from './types';
-import { IDocumentManager } from '@jupyterlab/docmanager';
+import { IChatContext } from './model';
+import { IAttachment, IUser } from './types';
 
 const WHITESPACE = new Set([' ', '\n', '\t']);
 
 /**
  * The chat input interface.
  */
-export interface IInputModel extends IDisposable {
+export interface IInputModel<T extends IChatContext = IChatContext>
+  extends IDisposable {
path: packages/jupyter-chat/src/input-model.ts
user: login: brichet
body: Thanks for the feedbacks.

Actually, since the chat context must implements `IChatContext`, there is no reason to use a generic type, you are right. I simplified it.
created_at: 2025-04-07T06:53:08Z
author_association: COLLABORATOR
diff_hunk: @@ -648,3 +673,32 @@ export namespace IChatModel {
     documentManager?: IDocumentManager | null;
   }
 }
+
+/**
+ * Interface of the chat context, a 'subset' of the model with readonly attribute,
+ * which can be passed to the input model.
+ * This allows third party extensions to get some attribute of the model without
+ * exposing the method that can modify it.
+ */
+export interface IChatContext {
+  /**
+   * The name of the chat.
+   */
+  readonly name: string;
+}
+
+/**
+ * A default implementation of the IChatContext, that can be extended to provide more
+ * attributes.
+ */
+export class ChatContext implements IChatContext {
+  constructor(options: { model: IChatModel }) {
+    this._model = options.model;
+  }
+
+  get name(): string {
+    return this._model.name;
+  }
+
+  protected _model: IChatModel;
+}
path: packages/jupyter-chat/src/model.ts
user: login: brichet
body: The `ChatContext` is the default implementation of the context, while `LabChatContext` is extending it to use it in `jupyterlab-chat`.
created_at: 2025-04-07T07:00:17Z
author_association: COLLABORATOR
diff_hunk: @@ -349,3 +427,37 @@ export class LabChatModel
 
   private _user: IUser;
 }
+
+/**
+ * The chat context to be sent to the input model.
+ */
+export class LabChatContext extends ChatContext {
path: packages/jupyterlab-chat/src/model.ts
user: login: dlqqq
body: Do we need to provide both `LabChatContext` and `ChatContext`? There are other areas in this branch where we are unsafely casting `IChatContext` objects as `LabChatContext` to access the `users` property defined here. If we require this property, then it doesn't seem to make sense to have the `IChatContext` exclude that property. 

I recommend that the `users` property be added to the `IChatContext` interface to remove the type casts. I also suggest we merge `ChatContext` and `LabChatContext` together, since one interface will only require one implementation. Is this possible? If not, what is the reason for providing 2 implementations in 2 different packages?


created_at: 2025-04-08T20:54:46Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-11T17:41:42Z
body: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/brichet/jupyter-chat/user_mention?urlpath=lab) :point_left: Launch a Binder on branch _brichet/jupyter-chat/user_mention_
author_association: CONTRIBUTOR
user: login: brichet
created_at: 2025-03-20T11:52:15Z
body: The current implementation creates the user list every time the mention command is requested (typing `@` in the input).

It seems to be quite reactive with a few users, but may become problematic if a lot of users are involved/connected to the chat.
It might be possible to build and update the user list for each chat using signals.

The `MentionCommandProvider` could receive `app.serviceManager.events.stream` signal to build the list of users when a chat is created, and clean it when a chat is deleted.
Then we would have to listen for (1) the user list in the shared model (users who wrote in the chat or have been mentioned) and (2) the user list in the shared model awareness (users currently connected to chat) to update the users of each chat in `MentionCommandProvider`.

Maybe we should wait for existing issues before implementing this.
author_association: COLLABORATOR
user: login: brichet
created_at: 2025-04-04T12:27:44Z
body: @dlqqq I added a chat context to the chat input as discussed https://github.com/jupyterlab/jupyter-chat/pull/190#discussion_r2025346815

The main idea to be able send a context (read-only subset of the chat model) to the chat input. This context is sent only if the `createChatContext()` method is overridden in the chat model.
author_association: COLLABORATOR
user: login: brichet
created_at: 2025-04-04T12:31:00Z
body: > BTW, does this allow typing @Jupyternaut when Jupyter AI is installed?

Yes, Jupyternaut is connected to the chat as a regular user: https://github.com/jupyterlab/jupyter-ai/blob/a82eecc220a032b9bfbef9beb3da900a8bfd4e59/packages/jupyter-ai/jupyter_ai/extension.py#L272
author_association: COLLABORATOR
user: login: brichet
created_at: 2025-04-04T13:35:46Z
body: > This may introduce a XSS vulnerability because it's unclear whether user.mention_name is sanitized before rendering. React might do this automatically, but if not, a malicious actor could exploit this by setting their username to Good person <script src="https://evil.com/evil.js" />.

IIUC the default rendermime [registry](https://github.com/jupyterlab/jupyterlab/blob/8ce37a58199674b1a7a44e2f5cb059465fcef411/packages/rendermime-extension/src/index.ts#L138) has a sanitizer, which doesn't allow `script` tags : https://github.com/jupyterlab/jupyterlab/blob/8ce37a58199674b1a7a44e2f5cb059465fcef411/packages/apputils/src/sanitizer.ts#L490

The additional span is displayed using the markdown renderer, so it should be sanitized. I did a test and the `script` tag is indeed removed from the rendered message.
Do you think that it'senough to prevent XSS attack (at least in a regular installation with the sanitizer available) ?
author_association: COLLABORATOR
user: login: dlqqq
created_at: 2025-04-04T22:34:03Z
body: @brichet 

> Do you think that it'senough to prevent XSS attack (at least in a regular installation with the sanitizer available) ?

Yes, and thank you for doing that research! It's safe to replace `@`-mentions with `<span>` elements for now, but I think we should improve this (#204) before JAI v3.0.0.
author_association: MEMBER
user: login: dlqqq
created_at: 2025-04-08T21:18:26Z
body: Documenting some usability issues I noticed while testing this branch. No need to address these in the same PR, just making a list so we can open an issue later.

- Unable to select text within the chat widget. The chat input keeps taking back the focus state.
- Previously-connected users are listed in the commands menu when `@` is typed. Even if the same user is connecting to the chat repeatedly, the list of users grows and becomes a list of `@Anonymous-XYZ`, `@Anonymous-FooBar`, etc. 
author_association: MEMBER
user: login: dlqqq
created_at: 2025-04-09T17:29:45Z
body: @brichet I've opened a new PR on your fork targeting this branch. This implements the feedback that I had suggested in my last review: https://github.com/brichet/jupyter-chat/pull/4
author_association: MEMBER
user: login: brichet
created_at: 2025-04-10T06:14:40Z
body: > @brichet I've opened a new PR on your fork targeting this branch. This implements the feedback that I had suggested in my last review: [brichet#4](https://github.com/brichet/jupyter-chat/pull/4)

Thanks @dlqqq for opening it.
I give some comments here, for a better visibility.

With this "double" implementation of the `ChatContext` (in `@jupyter/chat` and `jupyterlab-chat`), I wanted to avoid:
- forcing a third party extension (extending the `AbstractChatModel`) to implement the `createChatContext()` when it does not need it
- forcing the implementation of `get users()` when extending the `IChatContext` if not necessary

This is why I found it simpler to provide a default `ChatContext` anyway, that can be extended only if necessary.

For example, the `AbstractChatModel` is extended in [jupyterlite-ai](https://github.com/jupyterlite/ai/blob/7e2c434c66fc316f1b247c4eb11624afee989663/src/chat-handler.ts#L39):
- (1) in the current state, there is no need for a `ChatContext`
- (2) if we add a `ChatContext` in future, the concept of `users` does not really make sense

-------

1. If you really think that we should avoid type casting (I don't have a strong opinion on it), do you think it is possible to make `createChatContext()` optional in the `AbstractChatModel`, and `get user()` optional in the `IChatContext` ?


2. An other option could be to safely cast type, maybe something like 
```ts
if (inputModel.chatContext implements ILabChatContext):
```
author_association: COLLABORATOR
user: login: dlqqq
created_at: 2025-04-10T16:28:08Z
body: @brichet JupyterLite can either just return an empty array or an array with one item describing the current user. For example:

```ts
class LiteChatContext extends AbstractChatContext implements IChatContext {
   /**
   * The list of users who have connected to this chat. 
   */
  get users(): IUser[] {
    return []; // or, return a one-item array like [this.user]
  }
}
```

Even though it doesn't seem like JupyterLite needs to implement `IChatContext` right now, it may require this in the future if we add more properties. If not, we can make `createChatContext()` optional later on, before v1.0.0.
author_association: MEMBER