diff --git a/packages/jupyter-chat/src/components/input/toolbar-registry.tsx b/packages/jupyter-chat/src/components/input/toolbar-registry.tsx index 00c7c9c9..84638abd 100644 --- a/packages/jupyter-chat/src/components/input/toolbar-registry.tsx +++ b/packages/jupyter-chat/src/components/input/toolbar-registry.tsx @@ -2,11 +2,12 @@ * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ +import { Token } from '@lumino/coreutils'; +import { ISignal, Signal } from '@lumino/signaling'; import * as React from 'react'; import { AttachButton, CancelButton, SendButton } from './buttons'; import { IInputModel } from '../../input-model'; -import { ISignal, Signal } from '@lumino/signaling'; import { IChatCommandRegistry } from '../../registers'; /** @@ -166,3 +167,22 @@ export namespace InputToolbarRegistry { return registry; } } + +/** + * A factory interface for creating a new Input Toolbar Registry + * for each Chat Panel. + */ +export interface IInputToolbarRegistryFactory { + /** + * Create a new input toolbar registry instance. + */ + create: () => IInputToolbarRegistry; +} + +/** + * The token of the factory to create an input toolbar registry. + */ +export const IInputToolbarRegistryFactory = + new Token( + '@jupyter/chat:IInputToolbarRegistryFactory' + ); diff --git a/packages/jupyter-chat/src/model.ts b/packages/jupyter-chat/src/model.ts index eb0aa9d6..e0f31ac9 100644 --- a/packages/jupyter-chat/src/model.ts +++ b/packages/jupyter-chat/src/model.ts @@ -20,6 +20,7 @@ import { IUser } from './types'; import { replaceMentionToSpan } from './utils'; +import { PromiseDelegate } from '@lumino/coreutils'; /** * The chat model interface. @@ -40,6 +41,11 @@ export interface IChatModel extends IDisposable { */ unreadMessages: number[]; + /** + * The promise resolving when the model is ready. + */ + readonly ready: Promise; + /** * The indexes list of the messages currently in the viewport. */ @@ -241,6 +247,8 @@ export abstract class AbstractChatModel implements IChatModel { this._activeCellManager = options.activeCellManager ?? null; this._selectionWatcher = options.selectionWatcher ?? null; this._documentManager = options.documentManager ?? null; + + this._readyDelegate = new PromiseDelegate(); } /** @@ -328,6 +336,20 @@ export abstract class AbstractChatModel implements IChatModel { localStorage.setItem(`@jupyter/chat:${this._id}`, JSON.stringify(storage)); } + /** + * Promise that resolves when the model is ready. + */ + get ready(): Promise { + return this._readyDelegate.promise; + } + + /** + * Mark the model as ready. + */ + protected markReady(): void { + this._readyDelegate.resolve(); + } + /** * The chat settings. */ @@ -677,6 +699,7 @@ export abstract class AbstractChatModel implements IChatModel { private _id: string | undefined; private _name: string = ''; private _config: IConfig; + private _readyDelegate = new PromiseDelegate(); private _inputModel: IInputModel; private _isDisposed = false; private _commands?: CommandRegistry; diff --git a/packages/jupyter-chat/src/widgets/index.ts b/packages/jupyter-chat/src/widgets/index.ts index d2696485..7452956e 100644 --- a/packages/jupyter-chat/src/widgets/index.ts +++ b/packages/jupyter-chat/src/widgets/index.ts @@ -6,3 +6,4 @@ export * from './chat-error'; export * from './chat-sidebar'; export * from './chat-widget'; +export * from './multichat-panel'; diff --git a/packages/jupyter-chat/src/widgets/multichat-panel.tsx b/packages/jupyter-chat/src/widgets/multichat-panel.tsx new file mode 100644 index 00000000..0bd2a4fb --- /dev/null +++ b/packages/jupyter-chat/src/widgets/multichat-panel.tsx @@ -0,0 +1,466 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +/* + * Multi-chat panel for @jupyter/chat + * Originally adapted from jupyterlab-chat's ChatPanel + */ + +import { + ChatWidget, + IAttachmentOpenerRegistry, + IChatCommandRegistry, + IChatModel, + IInputToolbarRegistry, + IMessageFooterRegistry, + readIcon +} from '../index'; +import { InputDialog, IThemeManager } from '@jupyterlab/apputils'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import { + addIcon, + closeIcon, + HTMLSelect, + launchIcon, + PanelWithToolbar, + ReactWidget, + SidePanel, + Spinner, + ToolbarButton +} from '@jupyterlab/ui-components'; +import { ISignal, Signal } from '@lumino/signaling'; +import { AccordionPanel, Panel } from '@lumino/widgets'; +import React, { useState } from 'react'; + +const SIDEPANEL_CLASS = 'jp-chat-sidepanel'; +const ADD_BUTTON_CLASS = 'jp-chat-add'; +const OPEN_SELECT_CLASS = 'jp-chat-open'; +const SECTION_CLASS = 'jp-chat-section'; +const TOOLBAR_CLASS = 'jp-chat-section-toolbar'; + +/** + * Generic sidepanel widget including multiple chats and the add chat button. + */ +export class MultiChatPanel extends SidePanel { + constructor(options: ChatPanel.IOptions) { + super(options); + this.addClass(SIDEPANEL_CLASS); + + this._rmRegistry = options.rmRegistry; + this._themeManager = options.themeManager; + this._chatCommandRegistry = options.chatCommandRegistry; + this._attachmentOpenerRegistry = options.attachmentOpenerRegistry; + this._inputToolbarFactory = options.inputToolbarFactory; + this._messageFooterRegistry = options.messageFooterRegistry; + this._welcomeMessage = options.welcomeMessage; + this._getChatNames = options.getChatNames; + + // Use the passed callback functions + this._openChat = options.openChat; + this._openInMain = options.openInMain; + this._createChat = options.createChat ?? (() => {}); + this._renameChat = options.renameChat; + + // Add chat button calls the createChat callback + const addChat = new ToolbarButton({ + onClick: () => this._createChat(), + icon: addIcon, + label: 'Chat', + tooltip: 'Add a new chat' + }); + addChat.addClass(ADD_BUTTON_CLASS); + this.toolbar.addItem('createChat', addChat); + + // Chat select dropdown + this._openChatWidget = ReactWidget.create( + + ); + this._openChatWidget.addClass(OPEN_SELECT_CLASS); + this.toolbar.addItem('openChat', this._openChatWidget); + + const content = this.content as AccordionPanel; + content.expansionToggled.connect(this._onExpansionToggled, this); + } + + /** + * The sections of the side panel. + */ + get sections(): ChatSection[] { + return this.widgets as ChatSection[]; + } + + /** + * Add a new widget to the chat panel. + * + * @param model - the model of the chat widget + * @param displayName - the name of the chat. + */ + + addChat(model: IChatModel, displayName?: string): ChatWidget { + const content = this.content as AccordionPanel; + for (let i = 0; i < this.widgets.length; i++) { + content.collapse(i); + } + + // Create the toolbar registry. + let inputToolbarRegistry: IInputToolbarRegistry | undefined; + if (this._inputToolbarFactory) { + inputToolbarRegistry = this._inputToolbarFactory.create(); + } + + // Create a new widget. + const widget = new ChatWidget({ + model, + rmRegistry: this._rmRegistry, + themeManager: this._themeManager, + chatCommandRegistry: this._chatCommandRegistry, + attachmentOpenerRegistry: this._attachmentOpenerRegistry, + inputToolbarRegistry, + messageFooterRegistry: this._messageFooterRegistry, + welcomeMessage: this._welcomeMessage + }); + + const section = new ChatSection({ + widget, + openInMain: this._openInMain, + renameChat: this._renameChat, + displayName + }); + + this.addWidget(section); + content.expand(this.widgets.length - 1); + + return widget; + } + + /** + * Update the list of available chats. + */ + updateChatList = async (): Promise => { + try { + const chatNames = await this._getChatNames(); + this._chatNamesChanged.emit(chatNames); + } catch (e) { + console.error('Error getting chat files', e); + } + }; + + /** + * Open a chat if it exists in the side panel. + * + * @param path - the path of the chat. + * @returns a boolean, whether the chat existed in the side panel or not. + */ + openIfExists(name: string): boolean { + const index = this._getChatIndex(name); + if (index > -1) { + this._expandChat(index); + } + return index > -1; + } + + /** + * A message handler invoked on an `'after-attach'` message. + */ + protected onAfterAttach(): void { + this._openChatWidget.renderPromise?.then(() => this.updateChatList()); + } + + /** + * Return the index of the chat in the list (-1 if not opened). + * + * @param name - the chat name. + */ + private _getChatIndex(name: string) { + return this.widgets.findIndex(w => (w as ChatSection).model?.name === name); + } + + /** + * Expand the chat from its index. + */ + private _expandChat(index: number): void { + if (!this.widgets[index].isVisible) { + (this.content as AccordionPanel).expand(index); + } + } + + /** + * Handle `change` events for the HTMLSelect component. + */ + private _chatSelected(event: React.ChangeEvent): void { + const selection = event.target.value; + if (selection === '-') { + return; + } + this._openChat(selection); + event.target.selectedIndex = 0; + } + + /** + * Triggered when a section is toggled. If the section is opened, all others + * sections are closed. + */ + private _onExpansionToggled(panel: AccordionPanel, index: number) { + if (!this.widgets[index].isVisible) { + return; + } + for (let i = 0; i < this.widgets.length; i++) { + if (i !== index) { + panel.collapse(i); + } + } + } + + private _chatNamesChanged = new Signal( + this + ); + + private _rmRegistry: IRenderMimeRegistry; + private _themeManager: IThemeManager | null; + private _chatCommandRegistry?: IChatCommandRegistry; + private _attachmentOpenerRegistry?: IAttachmentOpenerRegistry; + private _inputToolbarFactory?: ChatPanel.IInputToolbarRegistryFactory; + private _messageFooterRegistry?: IMessageFooterRegistry; + private _welcomeMessage?: string; + private _getChatNames: () => Promise<{ [name: string]: string }>; + + private _createChat: () => void; + private _openChat: (name: string) => void; + private _openInMain?: (name: string) => void; + private _renameChat?: (oldName: string, newName: string) => Promise; + + private _openChatWidget: ReactWidget; +} + +/** + * The chat panel namespace. + */ +export namespace ChatPanel { + /** + * Options of the constructor of the chat panel. + */ + export interface IOptions extends SidePanel.IOptions { + rmRegistry: IRenderMimeRegistry; + themeManager: IThemeManager | null; + getChatNames: () => Promise<{ [name: string]: string }>; + + // Callback functions instead of command strings + openChat: (name: string) => void; + createChat: () => void; + openInMain?: (name: string) => void; + renameChat?: (oldName: string, newName: string) => Promise; + + chatCommandRegistry?: IChatCommandRegistry; + attachmentOpenerRegistry?: IAttachmentOpenerRegistry; + inputToolbarFactory?: IInputToolbarRegistryFactory; + messageFooterRegistry?: IMessageFooterRegistry; + welcomeMessage?: string; + } + + export interface IInputToolbarRegistryFactory { + create(): IInputToolbarRegistry; + } +} + +/** + * The chat section containing a chat widget. + */ +export class ChatSection extends PanelWithToolbar { + /** + * Constructor of the chat section. + */ + constructor(options: ChatSection.IOptions) { + super(options); + this.addWidget(options.widget); + this.addWidget(this._spinner); + this.addClass(SECTION_CLASS); + this.toolbar.addClass(TOOLBAR_CLASS); + this._displayName = + options.displayName ?? options.widget.model.name ?? 'Chat'; + this._updateTitle(); + + this._markAsRead = new ToolbarButton({ + icon: readIcon, + iconLabel: 'Mark chat as read', + className: 'jp-mod-styled', + onClick: () => { + if (this.model) { + this.model.unreadMessages = []; + } + } + }); + this.toolbar.addItem('markRead', this._markAsRead); + + if (options.renameChat) { + const renameButton = new ToolbarButton({ + iconClass: 'jp-EditIcon', + iconLabel: 'Rename chat', + className: 'jp-mod-styled', + onClick: async () => { + const oldName = this.model.name ?? 'Chat'; + const result = await InputDialog.getText({ + title: 'Rename Chat', + text: this.model.name, + placeholder: 'new-name' + }); + if (!result.button.accept) { + return; // user cancelled + } + const newName = result.value; + if (this.model && newName && newName !== oldName) { + if (await options.renameChat?.(oldName, newName)) { + this.model.name = newName; + this._displayName = newName; + this._updateTitle(); + } + } + } + }); + this.toolbar.addItem('rename', renameButton); + } + + if (options.openInMain) { + const moveToMain = new ToolbarButton({ + icon: launchIcon, + iconLabel: 'Move the chat to the main area', + className: 'jp-mod-styled', + onClick: () => { + const name = this.model.name; + this.model.dispose(); + options.openInMain?.(name); + this.dispose(); + } + }); + this.toolbar.addItem('moveMain', moveToMain); + } + + const closeButton = new ToolbarButton({ + icon: closeIcon, + iconLabel: 'Close the chat', + className: 'jp-mod-styled', + onClick: () => { + this.model.dispose(); + this.dispose(); + } + }); + + this.toolbar.addItem('close', closeButton); + + this.model.unreadChanged?.connect(this._unreadChanged); + this._markAsRead.enabled = (this.model?.unreadMessages.length ?? 0) > 0; + + options.widget.node.style.height = '100%'; + + /** + * Remove the spinner when the chat is ready. + */ + this.model.ready.then(() => { + this._spinner.dispose(); + }); + } + + /** + * The display name. + */ + get displayName(): string { + return this._displayName; + } + set displayName(value: string) { + this._displayName = value; + this._updateTitle(); + } + + /** + * The model of the widget. + */ + get model(): IChatModel { + return (this.widgets[0] as ChatWidget).model; + } + + /** + * Dispose of the resources held by the widget. + */ + dispose(): void { + const model = this.model; + if (model) { + model.unreadChanged?.disconnect(this._unreadChanged); + } + super.dispose(); + } + + /** + * * Update the section’s title based on the chat name. + * */ + + private _updateTitle(): void { + this.title.label = this._displayName; + this.title.caption = this._displayName; + } + + /** + * Change the title when messages are unread. + * + * TODO: fix it upstream in @jupyterlab/ui-components. + * Updating the title create a new Title widget, but does not attach again the + * toolbar. The toolbar is attached only when the title widget is attached the first + * time. + */ + private _unreadChanged = (_: IChatModel, unread: number[]) => { + this._markAsRead.enabled = unread.length > 0; + }; + + private _markAsRead: ToolbarButton; + private _spinner = new Spinner(); + private _displayName: string; +} + +/** + * The chat section namespace. + */ +export namespace ChatSection { + /** + * Options to build a chat section. + */ + export interface IOptions extends Panel.IOptions { + widget: ChatWidget; + openInMain?: (name: string) => void; + renameChat?: (oldName: string, newName: string) => Promise; + displayName?: string; + } +} + +type ChatSelectProps = { + chatNamesChanged: ISignal; + handleChange: (event: React.ChangeEvent) => void; +}; + +/** + * A component to select a chat from the drive. + */ +function ChatSelect({ + chatNamesChanged, + handleChange +}: ChatSelectProps): JSX.Element { + // An object associating a chat name to its path. Both are purely indicative, the name + // is the section title and the path is used as caption. + const [chatNames, setChatNames] = useState<{ [name: string]: string }>({}); + + // Update the chat list. + chatNamesChanged.connect((_, chatNames) => { + setChatNames(chatNames); + }); + + return ( + + + {Object.keys(chatNames).map(name => ( + + ))} + + ); +} diff --git a/packages/jupyterlab-chat-extension/src/index.ts b/packages/jupyterlab-chat-extension/src/index.ts index 7271549d..3caba603 100644 --- a/packages/jupyterlab-chat-extension/src/index.ts +++ b/packages/jupyterlab-chat-extension/src/index.ts @@ -18,7 +18,9 @@ import { MessageFooterRegistry, SelectionWatcher, chatIcon, - readIcon + readIcon, + IInputToolbarRegistryFactory, + MultiChatPanel } from '@jupyter/chat'; import { ICollaborativeContentProvider } from '@jupyter/collaborative-drive'; import { @@ -49,13 +51,11 @@ import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { launchIcon } from '@jupyterlab/ui-components'; import { PromiseDelegate } from '@lumino/coreutils'; import { - ChatPanel, ChatWidgetFactory, CommandIDs, IActiveCellManagerToken, IChatFactory, IChatPanel, - IInputToolbarRegistryFactory, ISelectionWatcherToken, IWelcomeMessage, LabChatModel, @@ -63,7 +63,8 @@ import { LabChatPanel, WidgetConfig, YChat, - chatFileType + chatFileType, + getDisplayName } from 'jupyterlab-chat'; import { chatCommandRegistryPlugin } from './chat-commands/plugins'; import { emojiCommandsPlugin } from './chat-commands/providers/emoji'; @@ -379,7 +380,7 @@ const chatCommands: JupyterFrontEndPlugin = { drive: ICollaborativeContentProvider, factory: IChatFactory, activeCellManager: IActiveCellManager | null, - chatPanel: ChatPanel | null, + chatPanel: MultiChatPanel | null, commandPalette: ICommandPalette | null, filebrowser: IDefaultFileBrowser | null, launcher: ILauncher | null, @@ -644,8 +645,13 @@ const chatCommands: JupyterFrontEndPlugin = { // Set the name of the model. chatModel.name = model.path; + const displayName = getDisplayName( + model.path, + widgetConfig.config.defaultDirectory + ); + // Add a chat widget to the side panel and to the tracker. - const widget = chatPanel.addChat(chatModel); + const widget = chatPanel.addChat(chatModel, displayName); factory.tracker.add(widget); } else { // The chat is opened in the main area @@ -670,6 +676,58 @@ const chatCommands: JupyterFrontEndPlugin = { console.error('The command to open a chat is not initialized\n', e) ); + // Command to rename a chat + commands.addCommand(CommandIDs.renameChat, { + label: 'Rename chat', + execute: async (args: any): Promise => { + const oldPath = args.oldPath as string; + let newPath = args.newPath as string | null; + if (!oldPath) { + showErrorMessage('Error renaming chat', 'Missing old path'); + return false; + } + + // Ask user if new name not passed in args + if (!newPath) { + const result = await InputDialog.getText({ + title: 'Rename Chat', + text: PathExt.basename(oldPath).replace(/\.chat$/, ''), // strip extension + placeholder: 'new-name' + }); + if (!result.button.accept) { + return false; // user cancelled + } + newPath = result.value; + } + + if (!newPath) { + return false; + } + + // Ensure `.chat` extension + if (!newPath.endsWith(chatFileType.extensions[0])) { + newPath = `${newPath}${chatFileType.extensions[0]}`; + } + + try { + await app.serviceManager.contents.rename(oldPath, newPath); + return true; + } catch (err) { + console.error('Error renaming chat', err); + showErrorMessage('Error renaming chat', `${err}`); + } + return false; + } + }); + + // Optional: add to palette + if (commandPalette) { + commandPalette.addItem({ + category: 'Chat', + command: CommandIDs.renameChat + }); + } + // The command to focus the input of the current chat widget. commands.addCommand(CommandIDs.focusInput, { caption: 'Focus the input of the current chat widget', @@ -693,9 +751,9 @@ const chatCommands: JupyterFrontEndPlugin = { }; /* - * Extension providing a chat panel. + * Extension providing a multi-chat side panel. */ -const chatPanel: JupyterFrontEndPlugin = { +const chatPanel: JupyterFrontEndPlugin = { id: pluginIds.chatPanel, description: 'The chat panel widget.', autoStart: true, @@ -722,20 +780,48 @@ const chatPanel: JupyterFrontEndPlugin = { messageFooterRegistry: IMessageFooterRegistry, themeManager: IThemeManager | null, welcomeMessage: string - ): ChatPanel => { - const { commands } = app; + ): MultiChatPanel => { + const { commands, serviceManager } = app; - const defaultDirectory = factory.widgetConfig.config.defaultDirectory || ''; + const chatFileExtension = chatFileType.extensions[0]; - /** - * Add Chat widget to left sidebar - */ - const chatPanel = new ChatPanel({ - commands, - contentsManager: app.serviceManager.contents, + // Get the chat in default directory + const getChatNames = async () => { + const dirContents = await serviceManager.contents.get( + factory.widgetConfig.config.defaultDirectory ?? '' + ); + const names: { [name: string]: string } = {}; + for (const file of dirContents.content) { + if (file.type === 'file' && file.name.endsWith(chatFileExtension)) { + const nameWithoutExt = file.name.replace(chatFileExtension, ''); + names[nameWithoutExt] = file.path; + } + } + return names; + }; + + const chatPanel = new MultiChatPanel({ rmRegistry, themeManager, - defaultDirectory, + getChatNames, + createChat: () => { + commands.execute(CommandIDs.createChat, { inSidePanel: true }); + }, + openChat: path => { + commands.execute(CommandIDs.openChat, { + filepath: path, + inSidePanel: true + }); + }, + openInMain: path => { + commands.execute(CommandIDs.openChat, { filepath: path }); + }, + renameChat: (oldPath, newPath) => { + return commands.execute(CommandIDs.renameChat, { + oldPath, + newPath + }) as Promise; + }, chatCommandRegistry, attachmentOpenerRegistry, inputToolbarFactory, @@ -746,9 +832,28 @@ const chatPanel: JupyterFrontEndPlugin = { chatPanel.title.icon = chatIcon; chatPanel.title.caption = 'Jupyter Chat'; // TODO: i18n/ + // Update available chats and section title when default directory changed. factory.widgetConfig.configChanged.connect((_, config) => { if (config.defaultDirectory !== undefined) { - chatPanel.defaultDirectory = config.defaultDirectory; + chatPanel.updateChatList(); + chatPanel.sections.forEach(section => { + section.displayName = getDisplayName( + section.model.name, + config.defaultDirectory + ); + }); + } + }); + + // Listen for the file changes to update the chat list. + serviceManager.contents.fileChanged.connect((_sender, change) => { + if ( + change.type === 'new' || + change.type === 'delete' || + (change.type === 'rename' && + change.oldValue?.path !== change.newValue?.path) + ) { + chatPanel.updateChatList(); } }); diff --git a/packages/jupyterlab-chat/src/factory.ts b/packages/jupyterlab-chat/src/factory.ts index 3242817b..2badb370 100644 --- a/packages/jupyterlab-chat/src/factory.ts +++ b/packages/jupyterlab-chat/src/factory.ts @@ -10,7 +10,8 @@ import { IChatCommandRegistry, IInputToolbarRegistry, IMessageFooterRegistry, - ISelectionWatcher + ISelectionWatcher, + IInputToolbarRegistryFactory } from '@jupyter/chat'; import { IThemeManager } from '@jupyterlab/apputils'; import { IDocumentManager } from '@jupyterlab/docmanager'; @@ -23,11 +24,7 @@ import { ISignal, Signal } from '@lumino/signaling'; import { LabChatModel } from './model'; import { LabChatPanel } from './widget'; import { YChat } from './ychat'; -import { - IInputToolbarRegistryFactory, - ILabChatConfig, - IWidgetConfig -} from './token'; +import { ILabChatConfig, IWidgetConfig } from './token'; /** * The object provided by the chatDocument extension. diff --git a/packages/jupyterlab-chat/src/index.ts b/packages/jupyterlab-chat/src/index.ts index bffa5bea..f3368b08 100644 --- a/packages/jupyterlab-chat/src/index.ts +++ b/packages/jupyterlab-chat/src/index.ts @@ -6,5 +6,6 @@ export * from './factory'; export * from './model'; export * from './token'; +export * from './utils'; export * from './widget'; export * from './ychat'; diff --git a/packages/jupyterlab-chat/src/model.ts b/packages/jupyterlab-chat/src/model.ts index 4939450c..fad02926 100644 --- a/packages/jupyterlab-chat/src/model.ts +++ b/packages/jupyterlab-chat/src/model.ts @@ -17,7 +17,7 @@ import { import { IChangedArgs } from '@jupyterlab/coreutils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { User } from '@jupyterlab/services'; -import { PartialJSONObject, PromiseDelegate, UUID } from '@lumino/coreutils'; +import { PartialJSONObject, UUID } from '@lumino/coreutils'; import { ISignal, Signal } from '@lumino/signaling'; import { IWidgetConfig } from './token'; @@ -137,10 +137,6 @@ export class LabChatModel return this._stateChanged; } - get ready(): Promise { - return this._ready.promise; - } - get dirty(): boolean { return this._dirty; } @@ -162,7 +158,7 @@ export class LabChatModel set id(value: string | undefined) { super.id = value; if (value) { - this._ready.resolve(); + this.markReady(); } } @@ -202,7 +198,7 @@ export class LabChatModel ): Promise { // Ensure the chat has an ID before inserting the messages, to properly catch the // unread messages (the last read message is saved using the chat ID). - return this._ready.promise.then(() => { + return this.ready.then(() => { super.messagesInserted(index, messages); }); } @@ -489,7 +485,6 @@ export class LabChatModel readonly defaultKernelName: string = ''; readonly defaultKernelLanguage: string = ''; - private _ready = new PromiseDelegate(); private _sharedModel: YChat; private _dirty = false; diff --git a/packages/jupyterlab-chat/src/token.ts b/packages/jupyterlab-chat/src/token.ts index 5b180276..225c8f8c 100644 --- a/packages/jupyterlab-chat/src/token.ts +++ b/packages/jupyterlab-chat/src/token.ts @@ -8,14 +8,14 @@ import { chatIcon, IActiveCellManager, ISelectionWatcher, - ChatWidget, - IInputToolbarRegistry + ChatWidget } from '@jupyter/chat'; import { WidgetTracker } from '@jupyterlab/apputils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { Token } from '@lumino/coreutils'; import { ISignal } from '@lumino/signaling'; -import { ChatPanel, LabChatPanel } from './widget'; +import { LabChatPanel } from './widget'; +import { MultiChatPanel as ChatPanel } from '@jupyter/chat'; /** * The file type for a chat document. @@ -105,7 +105,11 @@ export const CommandIDs = { /** * Focus the input of the current chat. */ - focusInput: 'jupyterlab-chat:focusInput' + focusInput: 'jupyterlab-chat:focusInput', + /** + * Rename the current chat. + */ + renameChat: 'jupyterlab-chat:renameChat' }; /** @@ -127,24 +131,6 @@ export const ISelectionWatcherToken = new Token( 'jupyterlab-chat:ISelectionWatcher' ); -/** - * The input toolbar registry factory. - */ -export interface IInputToolbarRegistryFactory { - /** - * Create an input toolbar registry. - */ - create: () => IInputToolbarRegistry; -} - -/** - * The token of the factory to create an input toolbar registry. - */ -export const IInputToolbarRegistryFactory = - new Token( - 'jupyterlab-chat:IInputToolbarRegistryFactory' - ); - /** * The token to add a welcome message to the chat. * This token is not provided by default, but can be provided by third party extensions diff --git a/packages/jupyterlab-chat/src/utils.ts b/packages/jupyterlab-chat/src/utils.ts new file mode 100644 index 00000000..77ca95ec --- /dev/null +++ b/packages/jupyterlab-chat/src/utils.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { PathExt } from '@jupyterlab/coreutils'; + +import { chatFileType } from './token'; + +/** + * + * @param defaultDirectory - the default directory. + * @param path - the path of the chat file. + * @returns - the display name of the chat. + */ +export function getDisplayName( + path: string, + defaultDirectory?: string +): string { + const inDefault = defaultDirectory + ? !PathExt.relative(defaultDirectory, path).startsWith('..') + : true; + + const pattern = new RegExp(`${chatFileType.extensions[0]}$`, 'g'); + return ( + inDefault + ? defaultDirectory + ? PathExt.relative(defaultDirectory, path) + : path + : '/' + path + ).replace(pattern, ''); +} diff --git a/packages/jupyterlab-chat/src/widget.tsx b/packages/jupyterlab-chat/src/widget.tsx index 786c9dc0..fbb1d17a 100644 --- a/packages/jupyterlab-chat/src/widget.tsx +++ b/packages/jupyterlab-chat/src/widget.tsx @@ -3,52 +3,13 @@ * Distributed under the terms of the Modified BSD License. */ -import { - ChatWidget, - IAttachmentOpenerRegistry, - IChatCommandRegistry, - IChatModel, - IInputToolbarRegistry, - IMessageFooterRegistry, - readIcon -} from '@jupyter/chat'; -import { Contents } from '@jupyterlab/services'; -import { IThemeManager } from '@jupyterlab/apputils'; -import { PathExt } from '@jupyterlab/coreutils'; +import { ChatWidget, IChatModel } from '@jupyter/chat'; import { DocumentWidget } from '@jupyterlab/docregistry'; -import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; -import { - addIcon, - closeIcon, - CommandToolbarButton, - HTMLSelect, - launchIcon, - PanelWithToolbar, - ReactWidget, - SidePanel, - Spinner, - ToolbarButton -} from '@jupyterlab/ui-components'; -import { CommandRegistry } from '@lumino/commands'; -import { Message } from '@lumino/messaging'; -import { ISignal, Signal } from '@lumino/signaling'; -import { AccordionPanel, Panel } from '@lumino/widgets'; -import React, { useState } from 'react'; import { LabChatModel } from './model'; -import { - CommandIDs, - IInputToolbarRegistryFactory, - chatFileType -} from './token'; const MAIN_PANEL_CLASS = 'jp-lab-chat-main-panel'; const TITLE_UNREAD_CLASS = 'jp-lab-chat-title-unread'; -const SIDEPANEL_CLASS = 'jp-lab-chat-sidepanel'; -const ADD_BUTTON_CLASS = 'jp-lab-chat-add'; -const OPEN_SELECT_CLASS = 'jp-lab-chat-open'; -const SECTION_CLASS = 'jp-lab-chat-section'; -const TOOLBAR_CLASS = 'jp-lab-chat-toolbar'; /** * DocumentWidget: widget that represents the view or editor for a file type. @@ -94,443 +55,3 @@ export class LabChatPanel extends DocumentWidget { } }; } - -/** - * Sidepanel widget including the chats and the add chat button. - */ -export class ChatPanel extends SidePanel { - /** - * The constructor of the chat panel. - */ - constructor(options: ChatPanel.IOptions) { - super(options); - this.addClass(SIDEPANEL_CLASS); - this._commands = options.commands; - this._contentsManager = options.contentsManager; - this._rmRegistry = options.rmRegistry; - this._themeManager = options.themeManager; - this._defaultDirectory = options.defaultDirectory; - this._chatCommandRegistry = options.chatCommandRegistry; - this._attachmentOpenerRegistry = options.attachmentOpenerRegistry; - this._inputToolbarFactory = options.inputToolbarFactory; - this._messageFooterRegistry = options.messageFooterRegistry; - this._welcomeMessage = options.welcomeMessage; - - const addChat = new CommandToolbarButton({ - commands: this._commands, - id: CommandIDs.createChat, - args: { inSidePanel: true }, - icon: addIcon - }); - addChat.addClass(ADD_BUTTON_CLASS); - this.toolbar.addItem('createChat', addChat); - - this._openChat = ReactWidget.create( - - ); - - this._openChat.addClass(OPEN_SELECT_CLASS); - this.toolbar.addItem('openChat', this._openChat); - - const content = this.content as AccordionPanel; - content.expansionToggled.connect(this._onExpansionToggled, this); - - this._contentsManager.fileChanged.connect((_, args) => { - if (args.type === 'delete') { - this.widgets.forEach(widget => { - if ((widget as ChatSection).path === args.oldValue?.path) { - widget.dispose(); - } - }); - this.updateChatList(); - } - const updateActions = ['new', 'rename']; - if ( - updateActions.includes(args.type) && - args.newValue?.path?.endsWith(chatFileType.extensions[0]) - ) { - this.updateChatList(); - } - }); - } - - /** - * Getter and setter of the defaultDirectory. - */ - get defaultDirectory(): string { - return this._defaultDirectory; - } - set defaultDirectory(value: string) { - if (value === this._defaultDirectory) { - return; - } - this._defaultDirectory = value; - // Update the list of discoverable chat (in default directory) - this.updateChatList(); - // Update the sections names. - this.widgets.forEach(w => { - (w as ChatSection).defaultDirectory = value; - }); - } - - /** - * Add a new widget to the chat panel. - * - * @param model - the model of the chat widget - * @param name - the name of the chat. - */ - addChat(model: IChatModel): ChatWidget { - // Collapse all chats - const content = this.content as AccordionPanel; - for (let i = 0; i < this.widgets.length; i++) { - content.collapse(i); - } - - // Create the toolbar registry. - let inputToolbarRegistry: IInputToolbarRegistry | undefined; - if (this._inputToolbarFactory) { - inputToolbarRegistry = this._inputToolbarFactory.create(); - } - - // Create a new widget. - const widget = new ChatWidget({ - model: model, - rmRegistry: this._rmRegistry, - themeManager: this._themeManager, - chatCommandRegistry: this._chatCommandRegistry, - attachmentOpenerRegistry: this._attachmentOpenerRegistry, - inputToolbarRegistry, - messageFooterRegistry: this._messageFooterRegistry, - welcomeMessage: this._welcomeMessage - }); - - this.addWidget( - new ChatSection({ - widget, - commands: this._commands, - path: model.name, - defaultDirectory: this._defaultDirectory - }) - ); - - return widget; - } - - /** - * Update the list of available chats in the default directory. - */ - updateChatList = async (): Promise => { - const extension = chatFileType.extensions[0]; - this._contentsManager - .get(this._defaultDirectory) - .then((contentModel: Contents.IModel) => { - const chatsNames: { [name: string]: string } = {}; - (contentModel.content as any[]) - .filter(f => f.type === 'file' && f.name.endsWith(extension)) - .forEach(f => { - chatsNames[PathExt.basename(f.name, extension)] = f.path; - }); - - this._chatNamesChanged.emit(chatsNames); - }) - .catch(e => console.error('Error getting the chat files from drive', e)); - }; - - /** - * Open a chat if it exists in the side panel. - * - * @param path - the path of the chat. - * @returns a boolean, whether the chat existed in the side panel or not. - */ - openIfExists(path: string): boolean { - const index = this._getChatIndex(path); - if (index > -1) { - this._expandChat(index); - } - return index > -1; - } - - /** - * A message handler invoked on an `'after-attach'` message. - */ - protected onAfterAttach(msg: Message): void { - // Wait for the component to be rendered. - this._openChat.renderPromise?.then(() => this.updateChatList()); - } - - /** - * Return the index of the chat in the list (-1 if not opened). - * - * @param name - the chat name. - */ - private _getChatIndex(path: string) { - return this.widgets.findIndex(w => (w as ChatSection).path === path); - } - - /** - * Expand the chat from its index. - */ - private _expandChat(index: number): void { - if (!this.widgets[index].isVisible) { - (this.content as AccordionPanel).expand(index); - } - } - - /** - * Handle `change` events for the HTMLSelect component. - */ - private _chatSelected = ( - event: React.ChangeEvent - ): void => { - const select = event.target; - const path = select.value; - const name = select.options[select.selectedIndex].textContent; - if (name === '-') { - return; - } - - this._commands.execute(CommandIDs.openChat, { - filepath: path, - inSidePanel: true - }); - event.target.selectedIndex = 0; - }; - - /** - * Triggered when a section is toogled. If the section is opened, all others - * sections are closed. - */ - private _onExpansionToggled(panel: AccordionPanel, index: number) { - if (!this.widgets[index].isVisible) { - return; - } - for (let i = 0; i < this.widgets.length; i++) { - if (i !== index) { - panel.collapse(i); - } - } - } - - private _chatNamesChanged = new Signal( - this - ); - private _commands: CommandRegistry; - private _defaultDirectory: string; - private _contentsManager: Contents.IManager; - private _openChat: ReactWidget; - private _rmRegistry: IRenderMimeRegistry; - private _themeManager: IThemeManager | null; - private _chatCommandRegistry?: IChatCommandRegistry; - private _attachmentOpenerRegistry?: IAttachmentOpenerRegistry; - private _inputToolbarFactory?: IInputToolbarRegistryFactory; - private _messageFooterRegistry?: IMessageFooterRegistry; - private _welcomeMessage?: string; -} - -/** - * The chat panel namespace. - */ -export namespace ChatPanel { - /** - * Options of the constructor of the chat panel. - */ - export interface IOptions extends SidePanel.IOptions { - commands: CommandRegistry; - contentsManager: Contents.IManager; - rmRegistry: IRenderMimeRegistry; - themeManager: IThemeManager | null; - defaultDirectory: string; - chatCommandRegistry?: IChatCommandRegistry; - attachmentOpenerRegistry?: IAttachmentOpenerRegistry; - inputToolbarFactory?: IInputToolbarRegistryFactory; - messageFooterRegistry?: IMessageFooterRegistry; - welcomeMessage?: string; - } -} - -/** - * The chat section containing a chat widget. - */ -class ChatSection extends PanelWithToolbar { - /** - * Constructor of the chat section. - */ - constructor(options: ChatSection.IOptions) { - super(options); - - this.addWidget(options.widget); - this.addWidget(this._spinner); - - this.addClass(SECTION_CLASS); - this._defaultDirectory = options.defaultDirectory; - this._path = options.path; - this._updateTitle(); - this.toolbar.addClass(TOOLBAR_CLASS); - - this._markAsRead = new ToolbarButton({ - icon: readIcon, - iconLabel: 'Mark chat as read', - className: 'jp-mod-styled', - onClick: () => (this.model.unreadMessages = []) - }); - - const moveToMain = new ToolbarButton({ - icon: launchIcon, - iconLabel: 'Move the chat to the main area', - className: 'jp-mod-styled', - onClick: () => { - this.model.dispose(); - options.commands.execute(CommandIDs.openChat, { - filepath: this._path - }); - this.dispose(); - } - }); - - const closeButton = new ToolbarButton({ - icon: closeIcon, - iconLabel: 'Close the chat', - className: 'jp-mod-styled', - onClick: () => { - this.model.dispose(); - this.dispose(); - } - }); - - this.toolbar.addItem('jupyterlabChat-markRead', this._markAsRead); - this.toolbar.addItem('jupyterlabChat-moveMain', moveToMain); - this.toolbar.addItem('jupyterlabChat-close', closeButton); - - this.model.unreadChanged?.connect(this._unreadChanged); - - this._markAsRead.enabled = this.model.unreadMessages.length > 0; - - options.widget.node.style.height = '100%'; - - /** - * Remove the spinner when the chat is ready. - */ - const model = this.model as LabChatModel; - model.ready.then(() => { - this._spinner.dispose(); - }); - } - - /** - * The path of the chat. - */ - get path(): string { - return this._path; - } - - /** - * Set the default directory property. - */ - set defaultDirectory(value: string) { - this._defaultDirectory = value; - this._updateTitle(); - } - - /** - * The model of the widget. - */ - get model(): IChatModel { - return (this.widgets[0] as ChatWidget).model; - } - - /** - * Dispose of the resources held by the widget. - */ - dispose(): void { - this.model.unreadChanged?.disconnect(this._unreadChanged); - super.dispose(); - } - - /** - * Update the section's title, depending on the default directory and chat file name. - * If the chat file is in the default directory, the section's name is its relative - * path to that default directory. Otherwise, it is it absolute path. - */ - private _updateTitle(): void { - const inDefault = this._defaultDirectory - ? !PathExt.relative(this._defaultDirectory, this._path).startsWith('..') - : true; - - const pattern = new RegExp(`${chatFileType.extensions[0]}$`, 'g'); - this.title.label = ( - inDefault - ? this._defaultDirectory - ? PathExt.relative(this._defaultDirectory, this._path) - : this._path - : '/' + this._path - ).replace(pattern, ''); - this.title.caption = this._path; - } - - /** - * Change the title when messages are unread. - * - * TODO: fix it upstream in @jupyterlab/ui-components. - * Updating the title create a new Title widget, but does not attach again the - * toolbar. The toolbar is attached only when the title widget is attached the first - * time. - */ - private _unreadChanged = (_: IChatModel, unread: number[]) => { - this._markAsRead.enabled = unread.length > 0; - // this.title.label = `${unread.length ? '* ' : ''}${this._name}`; - }; - - private _defaultDirectory: string; - private _markAsRead: ToolbarButton; - private _path: string; - private _spinner = new Spinner(); -} - -/** - * The chat section namespace. - */ -export namespace ChatSection { - /** - * Options to build a chat section. - */ - export interface IOptions extends Panel.IOptions { - commands: CommandRegistry; - defaultDirectory: string; - widget: ChatWidget; - path: string; - } -} - -type ChatSelectProps = { - chatNamesChanged: ISignal; - handleChange: (event: React.ChangeEvent) => void; -}; - -/** - * A component to select a chat from the drive. - */ -function ChatSelect({ - chatNamesChanged, - handleChange -}: ChatSelectProps): JSX.Element { - // An object associating a chat name to its path. Both are purely indicative, the name - // is the section title and the path is used as caption. - const [chatNames, setChatNames] = useState<{ [name: string]: string }>({}); - - // Update the chat list. - chatNamesChanged.connect((_, chatNames) => { - setChatNames(chatNames); - }); - - return ( - - - {Object.keys(chatNames).map(name => ( - - ))} - - ); -} diff --git a/ui-tests/tests/commands.spec.ts b/ui-tests/tests/commands.spec.ts index 7622f254..fe2d9e83 100644 --- a/ui-tests/tests/commands.spec.ts +++ b/ui-tests/tests/commands.spec.ts @@ -33,14 +33,15 @@ test.describe('#commandPalette', () => { } }); - test('should have 2 commands in palette', async ({ page }) => { + test('should have 3 commands in palette', async ({ page }) => { await expect( page.locator('#modal-command-palette li[data-command^="jupyterlab-chat"]') - ).toHaveCount(2); + ).toHaveCount(3); }); test('should create a chat with name from command palette', async ({ - page + page, + tmpPath }) => { await page .locator( @@ -49,7 +50,8 @@ test.describe('#commandPalette', () => { .click(); await fillModal(page, name); await page.waitForCondition( - async () => await page.filebrowser.contents.fileExists(FILENAME) + async () => + await page.filebrowser.contents.fileExists(`${tmpPath}/${FILENAME}`) ); await expect(page.activity.getTabLocator(FILENAME)).toBeVisible(); }); diff --git a/ui-tests/tests/notebook-application.spec.ts b/ui-tests/tests/notebook-application.spec.ts index 06907024..444b4f35 100644 --- a/ui-tests/tests/notebook-application.spec.ts +++ b/ui-tests/tests/notebook-application.spec.ts @@ -40,10 +40,10 @@ test.describe('#NotebookApp', () => { await page.menu.clickMenuItem('View>Left Sidebar>Show Jupyter Chat'); const panel = page.locator('#jp-left-stack'); await expect(panel).toBeVisible(); - await expect(panel.locator('.jp-lab-chat-sidepanel')).toBeVisible(); + await expect(panel.locator('.jp-chat-sidepanel')).toBeVisible(); const select = panel.locator( - '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-lab-chat-open select' + '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-chat-open select' ); await expect(select.locator('option')).toHaveCount(2); diff --git a/ui-tests/tests/side-panel.spec.ts b/ui-tests/tests/side-panel.spec.ts index cd514632..c7d7bfb7 100644 --- a/ui-tests/tests/side-panel.spec.ts +++ b/ui-tests/tests/side-panel.spec.ts @@ -30,8 +30,8 @@ test.describe('#sidepanel', () => { const items = toolbar.locator('.jp-Toolbar-item'); await expect(items).toHaveCount(2); - await expect(items.first()).toHaveClass(/.jp-lab-chat-add/); - await expect(items.last()).toHaveClass(/.jp-lab-chat-open/); + await expect(items.first()).toHaveClass(/.jp-chat-add/); + await expect(items.last()).toHaveClass(/.jp-chat-open/); }); test('chat panel should not contain a chat at init', async ({ page }) => { @@ -50,7 +50,7 @@ test.describe('#sidepanel', () => { test.beforeEach(async ({ page }) => { panel = await openSidePanel(page); addButton = panel.locator( - '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-lab-chat-add' + '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-chat-add' ); await addButton.click(); @@ -167,7 +167,7 @@ test.describe('#sidepanel', () => { test('should list existing chat', async ({ page }) => { panel = await openSidePanel(page); select = panel.locator( - '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-lab-chat-open select' + '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-chat-open select' ); await expect(select.locator('option')).toHaveCount(2); @@ -177,7 +177,7 @@ test.describe('#sidepanel', () => { test('should attach a spinner while loading the chat', async ({ page }) => { panel = await openSidePanel(page); select = panel.locator( - '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-lab-chat-open select' + '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-chat-open select' ); await select.selectOption(name); await expect(panel.locator('.jp-Spinner')).toBeAttached(); @@ -187,7 +187,7 @@ test.describe('#sidepanel', () => { test('should open an existing chat and close it', async ({ page }) => { panel = await openSidePanel(page); select = panel.locator( - '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-lab-chat-open select' + '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-chat-open select' ); await select.selectOption(name); @@ -207,7 +207,7 @@ test.describe('#sidepanel', () => { test('should list existing chat in default directory', async ({ page }) => { panel = await openSidePanel(page); select = panel.locator( - '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-lab-chat-open select' + '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-chat-open select' ); // changing the default directory to an empty one should empty the list. @@ -216,7 +216,6 @@ test.describe('#sidepanel', () => { 'input[label="defaultDirectory"]' ); await defaultDirectory.pressSequentially(NEW_DIR); - // wait for the settings to be saved await expect(page.activity.getTabLocator('Settings')).toHaveAttribute( 'class', @@ -232,7 +231,7 @@ test.describe('#sidepanel', () => { // creating a chat should populate the list. const addButton = panel.locator( - '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-lab-chat-add' + '.jp-SidePanel-toolbar .jp-Toolbar-item.jp-chat-add' ); await addButton.click(); const dialog = page.locator('.jp-Dialog'); @@ -288,7 +287,7 @@ test.describe('#sidepanel', () => { await button.click(); await expect(chatPanel).not.toBeAttached(); - const sidePanel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel'); + const sidePanel = page.locator('.jp-SidePanel.jp-chat-sidepanel'); await expect(sidePanel).toBeVisible(); const chatTitle = sidePanel.locator( '.jp-SidePanel-content .jp-AccordionPanel-title' @@ -307,7 +306,7 @@ test.describe('#sidepanel', () => { await button.click(); await expect(chatPanel).not.toBeAttached(); - const sidePanel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel'); + const sidePanel = page.locator('.jp-SidePanel.jp-chat-sidepanel'); await expect(sidePanel).toBeVisible(); const chatTitle = sidePanel.locator( '.jp-SidePanel-content .jp-AccordionPanel-title' @@ -339,7 +338,7 @@ test.describe('#sidepanel', () => { await expect(chatPanel).not.toBeAttached(); // Move the chat to the side panel. - const sidePanel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel'); + const sidePanel = page.locator('.jp-SidePanel.jp-chat-sidepanel'); await expect(sidePanel).toBeVisible(); const chatTitle = sidePanel.locator( '.jp-SidePanel-content .jp-AccordionPanel-title' diff --git a/ui-tests/tests/side-panel.spec.ts-snapshots/moveToMain-linux.png b/ui-tests/tests/side-panel.spec.ts-snapshots/moveToMain-linux.png index de8b4b1b..a218eb26 100644 Binary files a/ui-tests/tests/side-panel.spec.ts-snapshots/moveToMain-linux.png and b/ui-tests/tests/side-panel.spec.ts-snapshots/moveToMain-linux.png differ diff --git a/ui-tests/tests/test-utils.ts b/ui-tests/tests/test-utils.ts index a6643558..af529806 100644 --- a/ui-tests/tests/test-utils.ts +++ b/ui-tests/tests/test-utils.ts @@ -90,7 +90,7 @@ export const openChatToSide = async ( filename: string, content?: any ): Promise => { - const panel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel'); + const panel = page.locator('.jp-SidePanel.jp-chat-sidepanel'); await page.evaluate(async filepath => { const inSidePanel = true; await window.jupyterapp.commands.execute('jupyterlab-chat:open', { @@ -175,7 +175,7 @@ export const openSettings = async ( export const openSidePanel = async ( page: IJupyterLabPageFixture ): Promise => { - const panel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel'); + const panel = page.locator('.jp-SidePanel.jp-chat-sidepanel'); if (!(await panel?.isVisible())) { const chatIcon = page.locator('.jp-SideBar').getByTitle('Jupyter Chat');