Skip to content

Fix v0.14.0 bugs #242

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/jupyter-chat-example/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class MyChatModel extends AbstractChatModel {
id: newMessage.id ?? UUID.uuid4(),
type: 'msg',
time: Date.now() / 1000,
sender: { username: 'me' },
sender: { username: 'me', mention_name: 'me' },
attachments: this.input.attachments
};
this.messageAdded(message);
Expand Down
2 changes: 1 addition & 1 deletion packages/jupyter-chat/src/components/input/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
) {
// Run all command providers
await props.chatCommandRegistry?.onSubmit(model);
model.send(input);
model.send(model.value);
event.stopPropagation();
event.preventDefault();
}
Expand Down
14 changes: 11 additions & 3 deletions packages/jupyter-chat/src/components/input/use-chat-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export function useChatCommands(
// whether an option is highlighted in the chat commands menu
const [highlighted, setHighlighted] = useState(false);

// whether the chat commands menu is open
// whether the chat commands menu is open.
// NOTE: every `setOpen(false)` call should be followed by a
// `setHighlighted(false)` call.
const [open, setOpen] = useState(false);

// current list of chat commands matched by the current word.
Expand Down Expand Up @@ -90,7 +92,12 @@ export function useChatCommands(

// Otherwise, open/close the menu based on the presence of command
// completions and set the menu entries.
setOpen(!!commandCompletions.length);
if (commandCompletions.length) {
setOpen(true);
} else {
setOpen(false);
setHighlighted(false);
}
setCommands(commandCompletions);
}

Expand Down Expand Up @@ -138,7 +145,8 @@ export function useChatCommands(
autocompleteProps: {
open,
options: commands,
getOptionLabel: (command: ChatCommand) => command.name,
getOptionLabel: (command: ChatCommand | string) =>
typeof command === 'string' ? '' : command.name,
renderOption: (
defaultProps,
command: ChatCommand,
Expand Down
37 changes: 33 additions & 4 deletions packages/jupyter-chat/src/input-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import { ISelectionWatcher } from './selection-watcher';
import { IChatContext } from './model';
import { IAttachment, IUser } from './types';

const WHITESPACE = new Set([' ', '\n', '\t']);

/**
* The chat input interface.
*/
Expand Down Expand Up @@ -525,6 +523,18 @@ export namespace InputModel {
}

namespace Private {
const WHITESPACE = new Set([' ', '\n', '\t']);

/**
* Returns the start index (inclusive) & end index (exclusive) that contain
* the current word. The start & end index can be passed to `String.slice()`
* to extract the current word. The returned range never includes any
* whitespace character unless it is escaped by a backslash `\`.
*
* NOTE: the escape sequence handling here is naive and non-recursive. This
* function considers the space in "`\\ `" as escaped, even though "`\\ `"
* defines a backslash followed by an _unescaped_ space in most languages.
*/
export function getCurrentWordBoundaries(
input: string,
cursorIndex: number
Expand All @@ -533,11 +543,30 @@ namespace Private {
let end = cursorIndex;
const n = input.length;

while (start > 0 && !WHITESPACE.has(input[start - 1])) {
while (
// terminate when `input[start - 1]` is whitespace
// i.e. `input[start]` is never whitespace after exiting
(start - 1 >= 0 && !WHITESPACE.has(input[start - 1])) ||
// unless it is preceded by a backslash
(start - 2 >= 0 &&
input[start - 2] === '\\' &&
WHITESPACE.has(input[start - 1]))
) {
start--;
}

while (end < n && !WHITESPACE.has(input[end])) {
// `end` is an exclusive index unlike `start`, hence the different `while`
// condition here
while (
// terminate when `input[end]` is whitespace
// i.e. `input[end]` may be whitespace after exiting
(end < n && !WHITESPACE.has(input[end])) ||
// unless it is preceded by a backslash
(end < n &&
end - 1 >= 0 &&
input[end - 1] === '\\' &&
WHITESPACE.has(input[end]))
) {
end++;
}

Expand Down
9 changes: 7 additions & 2 deletions packages/jupyter-chat/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ export interface IUser {
color?: string;
avatar_url?: string;
/**
* The string to use to mention a user in the chat.
* The string to use to mention a user in the chat. This is computed via the
* following procedure:
*
* 1. Let `mention_name = user.display_name || user.name || user.username`.
*
* 2. Replace each ' ' character with '-' in `mention_name`.
*/
mention_name?: string;
mention_name: string;
Copy link
Member

Choose a reason for hiding this comment

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

Could this have been kept optional?

/**
* Boolean identifying if user is a bot.
*/
Expand Down
14 changes: 8 additions & 6 deletions packages/jupyter-chat/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ 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);
const mention = '@' + user.mention_name;
const regex = new RegExp(mention, 'g');
const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
return content.replace(regex, mentionEl);
}

/**
Expand All @@ -75,7 +76,8 @@ 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);
const mention = '@' + user.mention_name;
const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
const regex = new RegExp(mentionEl, 'g');
return content.replace(regex, mention);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,50 @@ export const mentionCommandsPlugin: JupyterFrontEndPlugin<void> = {
class MentionCommandProvider implements IChatCommandProvider {
public id: string = 'jupyter-chat:mention-commands';

// Regex used to find all mentions in a message.
// IMPORTANT: the global flag must be specified to find >1 mentions.
private _regex: RegExp = /@[\w-]*/g;
/**
* Regex that matches all mentions in a message. The first capturing group
* contains the mention name of the mentioned user.
*
* IMPORTANT: the 'g' flag must be set to return multiple `@`-mentions when
* parsing the entire input `inputModel.value`.
*/
private _regex: RegExp = /@([\w-]*)/g;

/**
* Lists all valid user mentions that complete the current word.
*/
async listCommandCompletions(inputModel: IInputModel) {
// Return early if the current word does not match the expected syntax
const match = inputModel.currentWord?.match(this._regex)?.[0];
if (!match) {
const { currentWord } = inputModel;
if (!currentWord) {
return [];
}

// Get current mention, which will always be the first captured group in the
// first match.
// `matchAll()` is used here because `match()` does not return captured
// groups when the regex is global.
const currentMention = Array.from(
currentWord.matchAll(this._regex)
)?.[0]?.[1];

// Return early if no user is currently mentioned
if (currentMention === undefined || currentMention === null) {
return [];
}

// Otherwise, build the list of `@`-mention commands that complete the
// current word.
const existingMentions = new Set(inputModel.value?.match(this._regex));
// Otherwise, build the list of users that complete `currentMention`, and
// return them as command completions
const existingMentions = this._getExistingMentions(inputModel);
const commands: ChatCommand[] = Array.from(this._getUsers(inputModel))
// remove users whose mention names that do not complete the match
.filter(user => user[0].toLowerCase().startsWith(match.toLowerCase()))
.filter(user =>
user[0].toLowerCase().startsWith(currentMention.toLowerCase())
)
// remove users already mentioned in the message
.filter(user => !existingMentions.has(user[0]))
.map(user => {
return {
name: user[0],
name: '@' + user[0],
providerId: this.id,
icon: user[1].icon,
spaceOnAccept: true
Expand All @@ -61,16 +80,40 @@ class MentionCommandProvider implements IChatCommandProvider {
return commands;
}

/**
* Returns the set of mention names that have already been @-mentioned in the
* input.
*/
_getExistingMentions(inputModel: IInputModel): Set<string> {
const matches = inputModel.value?.matchAll(this._regex);
const existingMentions = new Set<string>();
for (const match of matches) {
const mention = match?.[1];
// ignore if 1st group capturing the mention name is an empty string
if (!mention) {
continue;
}
existingMentions.add(mention);
}

return existingMentions;
}

/**
* Adds all users identified via `@` as mentions to the new message
* immediately prior to submission.
*/
async onSubmit(inputModel: IInputModel) {
const input = inputModel.value;
const matches = input.match(this._regex) ?? [];
const matches = input.matchAll(this._regex);

for (const match of matches) {
const mentionedUser = this._getUsers(inputModel).get(match);
const mention = match?.[1];
if (!mention) {
continue;
}

const mentionedUser = this._getUsers(inputModel).get(mention);
if (mentionedUser) {
inputModel.addMention?.(mentionedUser.user);
}
Expand All @@ -85,13 +128,7 @@ class MentionCommandProvider implements IChatCommandProvider {
const users = new Map();
const userList = inputModel.chatContext.users;
userList.forEach(user => {
let mentionName = user.mention_name;
if (!mentionName) {
mentionName = Private.getMentionName(user);
user.mention_name = mentionName;
}

users.set(mentionName, {
users.set(user.mention_name, {
user,
icon: <Avatar user={user} />
});
Expand All @@ -106,15 +143,4 @@ namespace Private {
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}`;
}
}
Loading
Loading