diff --git a/packages/jupyter-chat/src/index.ts b/packages/jupyter-chat/src/index.ts index ad7b8f2..1b5f971 100644 --- a/packages/jupyter-chat/src/index.ts +++ b/packages/jupyter-chat/src/index.ts @@ -13,3 +13,5 @@ export * from './registers'; export * from './selection-watcher'; export * from './types'; export * from './widgets'; +export * from './multiChatPanel'; +export * from './token'; diff --git a/packages/jupyter-chat/src/model.ts b/packages/jupyter-chat/src/model.ts index eb0aa9d..f75f294 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,9 @@ 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(); + this.ready = this._readyDelegate.promise; } /** @@ -328,6 +337,18 @@ export abstract class AbstractChatModel implements IChatModel { localStorage.setItem(`@jupyter/chat:${this._id}`, JSON.stringify(storage)); } + /** + * Promise that resolves when the model is ready. + */ + readonly ready: Promise; + + /** + * Mark the model as ready. + */ + protected markReady(): void { + this._readyDelegate.resolve(); + } + /** * The chat settings. */ @@ -677,6 +698,7 @@ export abstract class AbstractChatModel implements IChatModel { private _id: string | undefined; private _name: string = ''; private _config: IConfig; + private _readyDelegate: PromiseDelegate; private _inputModel: IInputModel; private _isDisposed = false; private _commands?: CommandRegistry; diff --git a/packages/jupyter-chat/src/multiChatPanel.tsx b/packages/jupyter-chat/src/multiChatPanel.tsx new file mode 100644 index 0000000..262013d --- /dev/null +++ b/packages/jupyter-chat/src/multiChatPanel.tsx @@ -0,0 +1,551 @@ +/* + * 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 { IThemeManager } from '@jupyterlab/apputils'; +import { PathExt } from '@jupyterlab/coreutils'; +import { ContentsManager } from '@jupyterlab/services'; +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'; +import { showRenameDialog } from './utils/renameDialog'; + +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-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._defaultDirectory = options.defaultDirectory; + 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; + this._onChatsChanged = options.onChatsChanged; + + // Use the passed callback functions + this._openChat = options.openChat ?? (() => {}); + this._createChat = options.createChat ?? (() => {}); + this._closeChat = options.closeChat ?? (() => {}); + this._moveToMain = options.moveToMain ?? (() => {}); + + // 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); + + if (this._onChatsChanged) { + this._onChatsChanged(() => { + 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 { + 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, + path: model.name, + defaultDirectory: this._defaultDirectory, + openChat: this._openChat, + closeChat: this._closeChat, + moveToMain: this._moveToMain, + renameChat: this._renameChat + }); + + this.addWidget(section); + content.expand(this.widgets.length - 1); + + return widget; + } + + /** + * Update the list of available chats in the default directory. + */ + updateChatList = async (): Promise => { + try { + const chatsNames = await this._getChatNames(); + this._chatNamesChanged.emit(chatsNames); + } 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(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(): 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(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 path = event.target.value; + if (path === '-') { + return; + } + this._openChat(path); + event.target.selectedIndex = 0; + } + + /** + * Rename a chat. + */ + private _renameChat = async ( + section: ChatSection, + path: string, + newName: string + ) => { + try { + const oldPath = path; + const newPath = PathExt.join(this.defaultDirectory, newName); + + const ext = '.chat'; + if (!newName.endsWith(ext)) { + newName += ext; + } + + const contentsManager = new ContentsManager(); + await contentsManager.rename(oldPath, newPath); + + // Now update UI after backend rename + section.updateDisplayName(newName); + section.updatePath(newPath); + this.updateChatList(); + + console.log(`Renamed chat ${oldPath} to ${newPath}`); + } catch (e) { + console.error('Error renaming chat', e); + } + }; + + /** + * 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 _defaultDirectory: string; + 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 }>; + + // Replaced command strings with callback functions: + private _openChat: (path: string) => void; + private _createChat: () => void; + private _closeChat: (path: string) => void; + private _moveToMain: (path: string) => void; + + private _onChatsChanged?: (cb: () => void) => void; + 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; + defaultDirectory: string; + chatFileExtension: string; + getChatNames: () => Promise<{ [name: string]: string }>; + onChatsChanged?: (cb: () => void) => void; + + // Callback functions instead of command strings + openChat: (path: string) => void; + createChat: () => void; + closeChat: (path: string) => void; + moveToMain: (path: string) => void; + renameChat: ( + section: ChatSection.IOptions, + path: string, + newName: string + ) => void; + + chatCommandRegistry?: IChatCommandRegistry; + attachmentOpenerRegistry?: IAttachmentOpenerRegistry; + inputToolbarFactory?: IInputToolbarRegistryFactory; + messageFooterRegistry?: IMessageFooterRegistry; + welcomeMessage?: string; + } + + export interface IInputToolbarRegistryFactory { + create(): IInputToolbarRegistry; + } +} + +/** + * 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._closeChat = options.closeChat; + this._renameChat = options.renameChat; + this.toolbar.addClass(TOOLBAR_CLASS); + this._displayName = PathExt.basename(this._path); + this._updateTitle(); + + this._markAsRead = new ToolbarButton({ + icon: readIcon, + iconLabel: 'Mark chat as read', + className: 'jp-mod-styled', + onClick: () => (this.model.unreadMessages = []) + }); + + const renameButton = new ToolbarButton({ + iconClass: 'jp-EditIcon', + iconLabel: 'Rename chat', + className: 'jp-mod-styled', + onClick: async () => { + const newName = await showRenameDialog(this.title.label); + if (newName && newName.trim() && newName !== this.title.label) { + this._renameChat(this, this._path, newName.trim()); + } + } + }); + + const moveToMain = new ToolbarButton({ + icon: launchIcon, + iconLabel: 'Move the chat to the main area', + className: 'jp-mod-styled', + onClick: () => { + options.openChat(this._path); + options.renameChat(this, this._path, this._displayName); + this.dispose(); + } + }); + + const closeButton = new ToolbarButton({ + icon: closeIcon, + iconLabel: 'Close the chat', + className: 'jp-mod-styled', + onClick: () => { + this.model.dispose(); + this._closeChat(this._path); + this.dispose(); + } + }); + + this.toolbar.addItem('markRead', this._markAsRead); + this.toolbar.addItem('rename', renameButton); + this.toolbar.addItem('moveMain', moveToMain); + this.toolbar.addItem('close', closeButton); + + this.toolbar.node.style.backgroundColor = 'js-toolbar-background'; + this.toolbar.node.style.minHeight = '32px'; + this.toolbar.node.style.display = 'flex'; + + 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. + */ + this.model.ready.then(() => { + this._spinner.dispose(); + }); + } + + /** + * The path of the chat. + */ + get path(): string { + return this._path; + } + + /** + * The default directory of the chat. + */ + get defaultDirectory(): string { + return this._defaultDirectory; + } + + /** + * 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 { + console.log('Updating title label:', this._displayName); + this.title.label = this._displayName; + this.title.caption = this._path; + } + + public updateDisplayName(newName: string) { + this._path = PathExt.join(this.defaultDirectory, newName); + this._displayName = newName; + this._updateTitle(); + } + + public updatePath(newPath: string) { + this._path = newPath; + this._updateTitle(); + } + + /** + * 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 _defaultDirectory: string; + private _path: string; + private _markAsRead: ToolbarButton; + private _spinner = new Spinner(); + private _displayName: string; + + private _closeChat: (path: string) => void; + private _renameChat: ( + section: ChatSection, + path: string, + newName: string + ) => void; +} + +/** + * The chat section namespace. + */ +export namespace ChatSection { + /** + * Options to build a chat section. + */ + export interface IOptions extends Panel.IOptions { + widget: ChatWidget; + path: string; + defaultDirectory: string; + openChat: (path: string) => void; + closeChat: (path: string) => void; + moveToMain: (path: string) => void; + renameChat: (section: ChatSection, path: string, newName: string) => void; + } +} + +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((_, names) => setChatNames(names)); + return ( + + + {Object.keys(chatNames).map(name => ( + + ))} + + ); +} diff --git a/packages/jupyter-chat/src/token.ts b/packages/jupyter-chat/src/token.ts new file mode 100644 index 0000000..abc7171 --- /dev/null +++ b/packages/jupyter-chat/src/token.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Token } from '@lumino/coreutils'; +import { IInputToolbarRegistry } from './index'; + +/** + * 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/utils/renameDialog.ts b/packages/jupyter-chat/src/utils/renameDialog.ts new file mode 100644 index 0000000..e30752b --- /dev/null +++ b/packages/jupyter-chat/src/utils/renameDialog.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ +import '../../style/input.css'; + +export async function showRenameDialog( + currentName: string +): Promise { + return new Promise(resolve => { + const modal = document.createElement('div'); + modal.className = 'rename-modal'; + + const dialog = document.createElement('div'); + dialog.className = 'rename-dialog'; + modal.appendChild(dialog); + + const title = document.createElement('h3'); + title.textContent = 'Rename Chat'; + dialog.appendChild(title); + + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentName; + dialog.appendChild(input); + + const buttons = document.createElement('div'); + buttons.className = 'rename-buttons'; + + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.className = 'cancel-btn'; + cancelBtn.onclick = () => { + document.body.removeChild(modal); + resolve(null); + }; + buttons.appendChild(cancelBtn); + + const okBtn = document.createElement('button'); + okBtn.textContent = 'Rename'; + okBtn.className = 'rename-ok'; + okBtn.onclick = () => { + const val = input.value.trim(); + if (val) { + document.body.removeChild(modal); + resolve(val); + } else { + input.focus(); + } + }; + buttons.appendChild(okBtn); + + dialog.appendChild(buttons); + + document.body.appendChild(modal); + input.focus(); + + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { + okBtn.click(); + } else if (e.key === 'Escape') { + cancelBtn.click(); + } + }); + }); +} diff --git a/packages/jupyter-chat/style/input.css b/packages/jupyter-chat/style/input.css index f9d4009..56abb8e 100644 --- a/packages/jupyter-chat/style/input.css +++ b/packages/jupyter-chat/style/input.css @@ -72,3 +72,77 @@ border-radius: 3px; white-space: nowrap; } + +.rename-modal { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgb(41 41 41 / 40%); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.rename-dialog { + background: var(--jp-layout-color2); + padding: 1rem 1.5rem; + border-radius: 8px; + min-width: 300px; + box-shadow: 0 4px 8px rgb(24 23 23 / 26%); + color: var(--jp-ui-font-color1); + border: 1px solid var(--jp-border-color0); +} + +.rename-dialog h3 { + margin-top: 0; + color: var(--jp-ui-font-color1); +} + +.rename-dialog input[type='text'] { + width: 100%; + padding: 0.4rem 0.6rem; + margin-bottom: 1rem; + font-size: 1rem; + border: 1px solid var(--jp-border-color1); + border-radius: 3px; + background-color: var(--jp-layout-color1); + color: var(--jp-ui-font-color1); +} + +.rename-dialog input[type='text']::placeholder { + color: var(--jp-ui-font-color2); +} + +.rename-buttons { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.rename-buttons button { + cursor: pointer; + padding: 0.3rem 0.7rem; + border-radius: 3px; + border: 1px solid var(--jp-border-color2); + background-color: var(--jp-layout-color1); + color: var(--jp-ui-font-color1); + font-size: 0.9rem; + transition: background-color 0.2s ease; +} + +.rename-buttons button.cancel-btn { + background-color: var(--jp-layout-color1); + border-color: var(--jp-border-color2); + color: var(--jp-ui-font-color1); +} + +.rename-buttons button.rename-ok { + font-weight: bold; + background-color: var(--jp-brand-color1); + border-color: var(--jp-brand-color1); + color: var(--jp-ui-font-color1); +} diff --git a/packages/jupyterlab-chat-extension/src/index.ts b/packages/jupyterlab-chat-extension/src/index.ts index 7271549..1796592 100644 --- a/packages/jupyterlab-chat-extension/src/index.ts +++ b/packages/jupyterlab-chat-extension/src/index.ts @@ -18,7 +18,8 @@ import { MessageFooterRegistry, SelectionWatcher, chatIcon, - readIcon + readIcon, + IInputToolbarRegistryFactory } from '@jupyter/chat'; import { ICollaborativeContentProvider } from '@jupyter/collaborative-drive'; import { @@ -49,13 +50,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, @@ -65,6 +64,7 @@ import { YChat, chatFileType } from 'jupyterlab-chat'; +import { MultiChatPanel as ChatPanel, ChatSection } from '@jupyter/chat'; import { chatCommandRegistryPlugin } from './chat-commands/plugins'; import { emojiCommandsPlugin } from './chat-commands/providers/emoji'; import { mentionCommandsPlugin } from './chat-commands/providers/user-mention'; @@ -723,24 +723,78 @@ const chatPanel: JupyterFrontEndPlugin = { themeManager: IThemeManager | null, welcomeMessage: string ): ChatPanel => { - const { commands } = app; + const { commands, serviceManager } = app; const defaultDirectory = factory.widgetConfig.config.defaultDirectory || ''; + const chatFileExtension = chatFileType.extensions[0]; + + const getChatNames = async () => { + const dirContents = await serviceManager.contents.get(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; + }; + + // Hook that fires when files change + const onChatsChanged = (cb: () => void) => { + serviceManager.contents.fileChanged.connect( + (_sender: any, change: { type: string }) => { + if ( + change.type === 'new' || + change.type === 'delete' || + change.type === 'rename' + ) { + cb(); + } + } + ); + }; /** * Add Chat widget to left sidebar */ const chatPanel = new ChatPanel({ - commands, - contentsManager: app.serviceManager.contents, rmRegistry, + getChatNames, + onChatsChanged, themeManager, defaultDirectory, chatCommandRegistry, attachmentOpenerRegistry, inputToolbarFactory, messageFooterRegistry, - welcomeMessage + welcomeMessage, + chatFileExtension, + createChat: () => { + commands.execute(CommandIDs.createChat); + }, + openChat: (path: string) => { + commands.execute(CommandIDs.openChat, { filepath: path }); + }, + closeChat: (path: string) => { + commands.execute(CommandIDs.closeChat, { filepath: path }); + }, + moveToMain: (path: string) => { + commands.execute(CommandIDs.moveToMain, { filepath: path }); + }, + renameChat: ( + section: ChatSection.IOptions, + path: string, + newName: string + ) => { + if (section.widget.title.label !== newName) { + const newPath = `${defaultDirectory}/${newName}${chatFileExtension}`; + serviceManager.contents + .rename(path, newPath) + .catch(err => console.error('Rename failed:', err)); + section.widget.title.label = newName; + } + } }); chatPanel.id = 'JupyterlabChat:sidepanel'; chatPanel.title.icon = chatIcon; diff --git a/packages/jupyterlab-chat/src/factory.ts b/packages/jupyterlab-chat/src/factory.ts index 3242817..2badb37 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/model.ts b/packages/jupyterlab-chat/src/model.ts index 4939450..fad0292 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 5b18027..4100014 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,15 @@ export const CommandIDs = { /** * Focus the input of the current chat. */ - focusInput: 'jupyterlab-chat:focusInput' + focusInput: 'jupyterlab-chat:focusInput', + /** + * Close the current chat. + */ + closeChat: 'jupyterlab-chat:closeChat', + /** + * Move a main widget to the main area. + */ + moveToMain: 'jupyterlab-chat:moveToMain' }; /** @@ -127,24 +135,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/widget.tsx b/packages/jupyterlab-chat/src/widget.tsx index 786c9dc..0d29137 100644 --- a/packages/jupyterlab-chat/src/widget.tsx +++ b/packages/jupyterlab-chat/src/widget.tsx @@ -8,47 +8,21 @@ import { IAttachmentOpenerRegistry, IChatCommandRegistry, IChatModel, - IInputToolbarRegistry, IMessageFooterRegistry, - readIcon + IInputToolbarRegistryFactory } from '@jupyter/chat'; +import { MultiChatPanel, ChatSection } from '@jupyter/chat'; import { Contents } from '@jupyterlab/services'; import { IThemeManager } from '@jupyterlab/apputils'; -import { PathExt } from '@jupyterlab/coreutils'; 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'; +import { CommandIDs, 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. @@ -95,442 +69,84 @@ 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(); +export function createMultiChatPanel(options: { + commands: CommandRegistry; + contentsManager: Contents.IManager; + rmRegistry: IRenderMimeRegistry; + themeManager: IThemeManager | null; + defaultDirectory: string; + chatCommandRegistry?: IChatCommandRegistry; + attachmentOpenerRegistry?: IAttachmentOpenerRegistry; + inputToolbarFactory?: IInputToolbarRegistryFactory; + messageFooterRegistry?: IMessageFooterRegistry; + welcomeMessage?: string; +}): MultiChatPanel { + const { contentsManager, defaultDirectory } = options; + const chatFileExtension = chatFileType.extensions[0]; + + // This function replaces updateChatList's file lookup + const getChatNames = async () => { + const dirContents = await contentsManager.get(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; } - 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; + return names; }; - /** - * 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(); + // Hook that fires when files change + const onChatsChanged = (cb: () => void) => { + contentsManager.fileChanged.connect((_sender, change) => { + if ( + change.type === 'new' || + change.type === 'delete' || + (change.type === 'rename' && + change.oldValue?.path !== change.newValue?.path) + ) { + cb(); } }); - - 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 new MultiChatPanel({ + rmRegistry: options.rmRegistry, + themeManager: options.themeManager, + defaultDirectory: options.defaultDirectory, + chatFileExtension: chatFileType.extensions[0], + getChatNames, + onChatsChanged, + createChat: () => { + options.commands.execute(CommandIDs.createChat); + }, + openChat: path => { + options.commands.execute(CommandIDs.openChat, { filepath: path }); + }, + closeChat: path => { + options.commands.execute(CommandIDs.closeChat, { filepath: path }); + }, + moveToMain: path => { + options.commands.execute(CommandIDs.moveToMain, { filepath: path }); + }, + renameChat: ( + section: ChatSection.IOptions, + path: string, + newName: string + ) => { + if (section.widget.title.label !== newName) { + const newPath = `${defaultDirectory}/${newName}${chatFileExtension}`; + contentsManager + .rename(path, newPath) + .catch(err => console.error('Rename failed:', err)); + section.widget.title.label = newName; + } + }, + chatCommandRegistry: options.chatCommandRegistry, + attachmentOpenerRegistry: options.attachmentOpenerRegistry, + inputToolbarFactory: options.inputToolbarFactory, + messageFooterRegistry: options.messageFooterRegistry, + welcomeMessage: options.welcomeMessage }); - - return ( - - - {Object.keys(chatNames).map(name => ( - - ))} - - ); }