From 9df1d48da0c3f3c6e56f0fb2d5bb1b125867e08b Mon Sep 17 00:00:00 2001 From: Nicolas Brichet <32258950+brichet@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:19:51 +0200 Subject: [PATCH] Autocomplete commands (#57) * Add optional autocomplete to the input of the chat * Add a registry to register autocompletion and provide the plugin in extensions * Fix tests and padding on send button * Add tests on autocompletion * Renaming for clarity, and make the registry explicitly implement its interface * Try to fix #outofband test * Fix #outofband test * Explicit plugin ID Co-authored-by: Jeremy Tuloup <591645+jtpio@users.noreply.github.com> --------- Co-authored-by: Jeremy Tuloup <591645+jtpio@users.noreply.github.com> --- .../src/components/chat-input.tsx | 222 ++++++++--- packages/jupyter-chat/src/components/chat.tsx | 41 +- packages/jupyter-chat/src/index.ts | 1 + packages/jupyter-chat/src/registry.ts | 129 +++++++ packages/jupyter-chat/src/types.ts | 39 ++ .../src/factory.ts | 7 +- .../src/index.ts | 35 +- .../src/widget.tsx | 14 +- .../ui-tests/tests/autocompletion.spec.ts | 359 ++++++++++++++++++ .../jupyterlab_collaborative_chat.spec.ts | 24 +- packages/jupyterlab-ws-chat/src/index.ts | 43 ++- 11 files changed, 833 insertions(+), 81 deletions(-) create mode 100644 packages/jupyter-chat/src/registry.ts create mode 100644 packages/jupyterlab-collaborative-chat/ui-tests/tests/autocompletion.spec.ts diff --git a/packages/jupyter-chat/src/components/chat-input.tsx b/packages/jupyter-chat/src/components/chat-input.tsx index c1a0f8a..0dd6c5d 100644 --- a/packages/jupyter-chat/src/components/chat-input.tsx +++ b/packages/jupyter-chat/src/components/chat-input.tsx @@ -3,28 +3,99 @@ * Distributed under the terms of the Modified BSD License. */ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { + Autocomplete, Box, + IconButton, + InputAdornment, SxProps, TextField, - Theme, - IconButton, - InputAdornment + Theme } from '@mui/material'; import { Send, Cancel } from '@mui/icons-material'; import clsx from 'clsx'; +import { AutocompleteCommand, IAutocompletionCommandsProps } from '../types'; +import { IAutocompletionRegistry } from '../registry'; const INPUT_BOX_CLASS = 'jp-chat-input-container'; const SEND_BUTTON_CLASS = 'jp-chat-send-button'; const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button'; export function ChatInput(props: ChatInput.IProps): JSX.Element { + const { autocompletionName, autocompletionRegistry, sendWithShiftEnter } = + props; + const autocompletion = useRef(); const [input, setInput] = useState(props.value || ''); - const { sendWithShiftEnter } = props; + + // The autocomplete commands options. + const [commandOptions, setCommandOptions] = useState( + [] + ); + // whether any option is highlighted in the slash command autocomplete + const [highlighted, setHighlighted] = useState(false); + // controls whether the slash command autocomplete is open + const [open, setOpen] = useState(false); + + /** + * Effect: fetch the list of available autocomplete commands. + */ + useEffect(() => { + if (autocompletionRegistry === undefined) { + return; + } + autocompletion.current = autocompletionName + ? autocompletionRegistry.get(autocompletionName) + : autocompletionRegistry.getDefaultCompletion(); + + if (autocompletion.current === undefined) { + return; + } + + if (Array.isArray(autocompletion.current.commands)) { + setCommandOptions(autocompletion.current.commands); + } else if (typeof autocompletion.current.commands === 'function') { + autocompletion.current + .commands() + .then((commands: AutocompleteCommand[]) => { + setCommandOptions(commands); + }); + } + }, []); + + /** + * Effect: Open the autocomplete when the user types the 'opener' string into an + * empty chat input. Close the autocomplete and reset the last selected value when + * the user clears the chat input. + */ + useEffect(() => { + if (!autocompletion.current?.opener) { + return; + } + + if (input === autocompletion.current?.opener) { + setOpen(true); + return; + } + + if (input === '') { + setOpen(false); + return; + } + }, [input]); function handleKeyDown(event: React.KeyboardEvent) { + if (event.key !== 'Enter') { + return; + } + + // do not send the message if the user was selecting a suggested command from the + // Autocomplete component. + if (highlighted) { + return; + } + if ( event.key === 'Enter' && ((sendWithShiftEnter && event.shiftKey) || @@ -65,49 +136,106 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { return ( - - setInput(e.target.value)} - fullWidth - variant="outlined" - multiline - onKeyDown={handleKeyDown} - placeholder="Start chatting" - InputProps={{ - endAdornment: ( - - {props.onCancel && ( + ( + + {props.onCancel && ( + + + + )} - + - )} - - - - - ) - }} - FormHelperTextProps={{ - sx: { marginLeft: 'auto', marginRight: 0 } - }} - helperText={input.length > 2 ? helperText : ' '} - /> - + + ) + }} + FormHelperTextProps={{ + sx: { marginLeft: 'auto', marginRight: 0 } + }} + helperText={input.length > 2 ? helperText : ' '} + /> + )} + {...autocompletion.current?.props} + inputValue={input} + onInputChange={(_, newValue: string) => { + setInput(newValue); + }} + onHighlightChange={ + /** + * On highlight change: set `highlighted` to whether an option is + * highlighted by the user. + * + * This isn't called when an option is selected for some reason, so we + * need to call `setHighlighted(false)` in `onClose()`. + */ + (_, highlightedOption) => { + setHighlighted(!!highlightedOption); + } + } + onClose={ + /** + * On close: set `highlighted` to `false` and close the popup by + * setting `open` to `false`. + */ + () => { + setHighlighted(false); + setOpen(false); + } + } + // hide default extra right padding in the text field + disableClearable + /> ); } @@ -140,5 +268,13 @@ export namespace ChatInput { * Custom mui/material styles. */ sx?: SxProps; + /** + * Autocompletion properties. + */ + autocompletionRegistry?: IAutocompletionRegistry; + /** + * Autocompletion name. + */ + autocompletionName?: string; } } diff --git a/packages/jupyter-chat/src/components/chat.tsx b/packages/jupyter-chat/src/components/chat.tsx index f7f2c70..9e416d9 100644 --- a/packages/jupyter-chat/src/components/chat.tsx +++ b/packages/jupyter-chat/src/components/chat.tsx @@ -15,16 +15,14 @@ import { JlThemeProvider } from './jl-theme-provider'; import { ChatMessages } from './chat-messages'; import { ChatInput } from './chat-input'; import { IChatModel } from '../model'; +import { IAutocompletionRegistry } from '../registry'; -type ChatBodyProps = { - model: IChatModel; - rmRegistry: IRenderMimeRegistry; -}; - -function ChatBody({ - model, - rmRegistry: renderMimeRegistry -}: ChatBodyProps): JSX.Element { +export function ChatBody(props: Chat.IChatBodyProps): JSX.Element { + const { + model, + rmRegistry: renderMimeRegistry, + autocompletionRegistry + } = props; // no need to append to messageGroups imperatively here. all of that is // handled by the listeners registered in the effect hooks above. const onSend = async (input: string) => { @@ -45,6 +43,7 @@ function ChatBody({ borderTop: '1px solid var(--jp-border-color1)' }} sendWithShiftEnter={model.config.sendWithShiftEnter ?? false} + autocompletionRegistry={autocompletionRegistry} /> ); @@ -85,7 +84,11 @@ export function Chat(props: Chat.IOptions): JSX.Element { {/* body */} {view === Chat.View.chat && ( - + )} {view === Chat.View.settings && props.settingsPanel && ( @@ -100,9 +103,9 @@ export function Chat(props: Chat.IOptions): JSX.Element { */ export namespace Chat { /** - * The options to build the Chat UI. + * The props for the chat body component. */ - export interface IOptions { + export interface IChatBodyProps { /** * The chat model. */ @@ -111,6 +114,20 @@ export namespace Chat { * The rendermime registry. */ rmRegistry: IRenderMimeRegistry; + /** + * Autocompletion registry. + */ + autocompletionRegistry?: IAutocompletionRegistry; + /** + * Autocompletion name. + */ + autocompletionName?: string; + } + + /** + * The options to build the Chat UI. + */ + export interface IOptions extends IChatBodyProps { /** * The theme manager. */ diff --git a/packages/jupyter-chat/src/index.ts b/packages/jupyter-chat/src/index.ts index ab63495..fb73421 100644 --- a/packages/jupyter-chat/src/index.ts +++ b/packages/jupyter-chat/src/index.ts @@ -5,6 +5,7 @@ export * from './icons'; export * from './model'; +export * from './registry'; export * from './types'; export * from './widgets/chat-error'; export * from './widgets/chat-sidebar'; diff --git a/packages/jupyter-chat/src/registry.ts b/packages/jupyter-chat/src/registry.ts new file mode 100644 index 0000000..344b456 --- /dev/null +++ b/packages/jupyter-chat/src/registry.ts @@ -0,0 +1,129 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ +import { Token } from '@lumino/coreutils'; +import { IAutocompletionCommandsProps } from './types'; + +/** + * The token for the autocomplete registry, which can be provided by an extension + * using @jupyter/chat package. + */ +export const IAutocompletionRegistry = new Token( + '@jupyter/chat:IAutocompleteRegistry' +); + +/** + * The interface of a registry to provide autocompleters. + */ +export interface IAutocompletionRegistry { + /** + * The default autocompletion name. + */ + default: string | null; + /** + * Get the default autocompletion. + */ + getDefaultCompletion(): IAutocompletionCommandsProps | undefined; + /** + * Return a registered autocomplete props. + * + * @param name - the name of the registered autocomplete props. + */ + get(name: string): IAutocompletionCommandsProps | undefined; + + /** + * Register autocomplete props. + * + * @param name - the name for the registration. + * @param autocompletion - the autocomplete props. + */ + add(name: string, autocompletion: IAutocompletionCommandsProps): boolean; + + /** + * Remove a registered autocomplete props. + * + * @param name - the name of the autocomplete props. + */ + remove(name: string): boolean; +} + +/** + * A registry to provide autocompleters. + */ +export class AutocompletionRegistry implements IAutocompletionRegistry { + /** + * Getter and setter for the default autocompletion name. + */ + get default(): string | null { + return this._default; + } + set default(name: string | null) { + if (name === null || this._autocompletions.has(name)) { + this._default = name; + } else { + console.warn(`There is no registered completer with the name '${name}'`); + } + } + + /** + * Get the default autocompletion. + */ + getDefaultCompletion(): IAutocompletionCommandsProps | undefined { + if (this._default === null) { + return undefined; + } + return this._autocompletions.get(this._default); + } + + /** + * Return a registered autocomplete props. + * + * @param name - the name of the registered autocomplete props. + */ + get(name: string): IAutocompletionCommandsProps | undefined { + return this._autocompletions.get(name); + } + + /** + * Register autocomplete props. + * + * @param name - the name for the registration. + * @param autocompletion - the autocomplete props. + */ + add( + name: string, + autocompletion: IAutocompletionCommandsProps, + isDefault: boolean = false + ): boolean { + if (!this._autocompletions.has(name)) { + this._autocompletions.set(name, autocompletion); + if (this._autocompletions.size === 1 || isDefault) { + this.default = name; + } + return true; + } else { + console.warn(`A completer with the name '${name}' is already registered`); + return false; + } + } + + /** + * Remove a registered autocomplete props. + * + * @param name - the name of the autocomplete props. + */ + remove(name: string): boolean { + return this._autocompletions.delete(name); + } + + /** + * Remove all registered autocompletions. + */ + removeAll(): void { + this._autocompletions.clear(); + } + + private _default: string | null = null; + private _autocompletions = new Map(); +} diff --git a/packages/jupyter-chat/src/types.ts b/packages/jupyter-chat/src/types.ts index 6e1c258..f55d667 100644 --- a/packages/jupyter-chat/src/types.ts +++ b/packages/jupyter-chat/src/types.ts @@ -71,3 +71,42 @@ export interface INewMessage { * An empty interface to describe optional settings that could be fetched from server. */ export interface ISettings {} + +/** + * The autocomplete command type. + */ +export type AutocompleteCommand = { + label: string; +}; + +/** + * The properties of the autocompletion. + * + * The autocompletion component will open if the 'opener' string is typed at the + * beginning of the input field. + */ +export interface IAutocompletionCommandsProps { + /** + * The string that open the completer. + */ + opener: string; + /** + * The list of available commands. + */ + commands?: AutocompleteCommand[] | (() => Promise); + /** + * The props for the Autocomplete component. + * + * Must be compatible with https://mui.com/material-ui/api/autocomplete/#props. + * + * ## NOTES: + * - providing `options` will overwrite the commands argument. + * - providing `renderInput` will overwrite the input component. + * - providing `renderOptions` allows to customize the rendering of the component. + * - some arguments should not be provided and would be overwritten: + * - inputValue + * - onInputChange + * - onHighlightChange + */ + props?: any; +} diff --git a/packages/jupyterlab-collaborative-chat/src/factory.ts b/packages/jupyterlab-collaborative-chat/src/factory.ts index 45c7e5f..de75608 100644 --- a/packages/jupyterlab-collaborative-chat/src/factory.ts +++ b/packages/jupyterlab-collaborative-chat/src/factory.ts @@ -3,7 +3,7 @@ * Distributed under the terms of the Modified BSD License. */ -import { ChatWidget, IConfig } from '@jupyter/chat'; +import { ChatWidget, IAutocompletionRegistry, IConfig } from '@jupyter/chat'; import { IThemeManager } from '@jupyterlab/apputils'; import { ABCWidgetFactory, DocumentRegistry } from '@jupyterlab/docregistry'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; @@ -52,6 +52,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< super(options); this._themeManager = options.themeManager; this._rmRegistry = options.rmRegistry; + this._autocompletionRegistry = options.autocompletionRegistry; } /** @@ -65,6 +66,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< ): CollaborativeChatPanel { context.rmRegistry = this._rmRegistry; context.themeManager = this._themeManager; + context.autocompletionRegistry = this._autocompletionRegistry; return new CollaborativeChatPanel({ context, content: new ChatWidget(context) @@ -73,6 +75,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< private _themeManager: IThemeManager | null; private _rmRegistry: IRenderMimeRegistry; + private _autocompletionRegistry?: IAutocompletionRegistry; } export namespace ChatWidgetFactory { @@ -80,12 +83,14 @@ export namespace ChatWidgetFactory { extends DocumentRegistry.IContext { themeManager: IThemeManager | null; rmRegistry: IRenderMimeRegistry; + autocompletionRegistry?: IAutocompletionRegistry; } export interface IOptions extends DocumentRegistry.IWidgetFactoryOptions { themeManager: IThemeManager | null; rmRegistry: IRenderMimeRegistry; + autocompletionRegistry?: IAutocompletionRegistry; } } diff --git a/packages/jupyterlab-collaborative-chat/src/index.ts b/packages/jupyterlab-collaborative-chat/src/index.ts index 28b1ca7..f01eeaa 100644 --- a/packages/jupyterlab-collaborative-chat/src/index.ts +++ b/packages/jupyterlab-collaborative-chat/src/index.ts @@ -3,7 +3,12 @@ * Distributed under the terms of the Modified BSD License. */ -import { chatIcon, readIcon } from '@jupyter/chat'; +import { + AutocompletionRegistry, + IAutocompletionRegistry, + chatIcon, + readIcon +} from '@jupyter/chat'; import { ICollaborativeDrive } from '@jupyter/docprovider'; import { ILayoutRestorer, @@ -42,11 +47,26 @@ import { YChat } from './ychat'; const FACTORY = 'Chat'; const pluginIds = { + autocompletionRegistry: + 'jupyterlab-collaborative-chat:autocompletionRegistry', chatCommands: 'jupyterlab-collaborative-chat:commands', docFactories: 'jupyterlab-collaborative-chat:factory', chatPanel: 'jupyterlab-collaborative-chat:chat-panel' }; +/** + * Extension providing the autocompletion registry. + */ +const autocompletionPlugin: JupyterFrontEndPlugin = { + id: pluginIds.autocompletionRegistry, + description: 'An autocompletion registry', + autoStart: true, + provides: IAutocompletionRegistry, + activate: (app: JupyterFrontEnd): IAutocompletionRegistry => { + return new AutocompletionRegistry(); + } +}; + /** * Extension registering the chat file type. */ @@ -56,6 +76,7 @@ const docFactories: JupyterFrontEndPlugin = { autoStart: true, requires: [IRenderMimeRegistry], optional: [ + IAutocompletionRegistry, ICollaborativeDrive, ILayoutRestorer, ISettingRegistry, @@ -67,6 +88,7 @@ const docFactories: JupyterFrontEndPlugin = { activate: ( app: JupyterFrontEnd, rmRegistry: IRenderMimeRegistry, + autocompletionRegistry: IAutocompletionRegistry, drive: ICollaborativeDrive | null, restorer: ILayoutRestorer | null, settingRegistry: ISettingRegistry | null, @@ -187,7 +209,8 @@ const docFactories: JupyterFrontEndPlugin = { themeManager, rmRegistry, toolbarFactory, - translator + translator, + autocompletionRegistry }); // Add the widget to the tracker when it's created @@ -480,11 +503,12 @@ const chatPanel: JupyterFrontEndPlugin = { autoStart: true, provides: IChatPanel, requires: [ICollaborativeDrive, IRenderMimeRegistry], - optional: [ILayoutRestorer, IThemeManager], + optional: [IAutocompletionRegistry, ILayoutRestorer, IThemeManager], activate: ( app: JupyterFrontEnd, drive: ICollaborativeDrive, rmRegistry: IRenderMimeRegistry, + autocompletionRegistry: IAutocompletionRegistry, restorer: ILayoutRestorer | null, themeManager: IThemeManager | null ): ChatPanel => { @@ -497,7 +521,8 @@ const chatPanel: JupyterFrontEndPlugin = { commands, drive, rmRegistry, - themeManager + themeManager, + autocompletionRegistry }); chatPanel.id = 'JupyterCollaborationChat:sidepanel'; chatPanel.title.icon = chatIcon; @@ -560,4 +585,4 @@ const chatPanel: JupyterFrontEndPlugin = { } }; -export default [chatCommands, docFactories, chatPanel]; +export default [autocompletionPlugin, chatCommands, docFactories, chatPanel]; diff --git a/packages/jupyterlab-collaborative-chat/src/widget.tsx b/packages/jupyterlab-collaborative-chat/src/widget.tsx index 0855258..abb9258 100644 --- a/packages/jupyterlab-collaborative-chat/src/widget.tsx +++ b/packages/jupyterlab-collaborative-chat/src/widget.tsx @@ -3,7 +3,13 @@ * Distributed under the terms of the Modified BSD License. */ -import { ChatWidget, IChatModel, IConfig, readIcon } from '@jupyter/chat'; +import { + ChatWidget, + IAutocompletionRegistry, + IChatModel, + IConfig, + readIcon +} from '@jupyter/chat'; import { ICollaborativeDrive } from '@jupyter/docprovider'; import { IThemeManager } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; @@ -101,6 +107,7 @@ export class ChatPanel extends SidePanel { this._drive = options.drive; this._rmRegistry = options.rmRegistry; this._themeManager = options.themeManager; + this._autocompletionRegistry = options.autocompletionRegistry; const addChat = new CommandToolbarButton({ commands: this._commands, @@ -158,7 +165,8 @@ export class ChatPanel extends SidePanel { const widget = new ChatWidget({ model: model, rmRegistry: this._rmRegistry, - themeManager: this._themeManager + themeManager: this._themeManager, + autocompletionRegistry: this._autocompletionRegistry }); this.addWidget(new ChatSection({ name, widget, commands: this._commands })); } @@ -231,6 +239,7 @@ export class ChatPanel extends SidePanel { private _openChat: ReactWidget; private _rmRegistry: IRenderMimeRegistry; private _themeManager: IThemeManager | null; + private _autocompletionRegistry?: IAutocompletionRegistry; } /** @@ -245,6 +254,7 @@ export namespace ChatPanel { drive: ICollaborativeDrive; rmRegistry: IRenderMimeRegistry; themeManager: IThemeManager | null; + autocompletionRegistry?: IAutocompletionRegistry; } } diff --git a/packages/jupyterlab-collaborative-chat/ui-tests/tests/autocompletion.spec.ts b/packages/jupyterlab-collaborative-chat/ui-tests/tests/autocompletion.spec.ts new file mode 100644 index 0000000..e1fb3ba --- /dev/null +++ b/packages/jupyterlab-collaborative-chat/ui-tests/tests/autocompletion.spec.ts @@ -0,0 +1,359 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { expect, IJupyterLabPageFixture, test } from '@jupyterlab/galata'; +import { Locator } from '@playwright/test'; + +const FILENAME = 'my-chat.chat'; +const opener = '?'; +const commands = ['?test', '?other-test', '?last-test']; + +// Workaround to expose a function using 'window' in the browser context. +// Copied from https://github.com/puppeteer/puppeteer/issues/724#issuecomment-896755822 +const exposeDepsJs = (deps: Record any>): string => { + return Object.keys(deps) + .map(key => { + return `window["${key}"] = ${deps[key]};`; + }) + .join('\n'); +}; + +// The function running in browser context to get a plugin. +const getPlugin = (pluginId: string): Promise => { + return new Promise((resolve, reject) => { + const app = window.jupyterapp; + const hasPlugin = app.hasPlugin(pluginId); + + if (hasPlugin) { + try { + const appAny = app as any; + const plugin: any = appAny._plugins + ? appAny._plugins.get(pluginId) + : undefined; + if (plugin.activated) { + resolve(plugin.service); + } else { + void app.activatePlugin(pluginId).then(response => { + resolve(plugin.service); + }); + } + } catch (error) { + console.error('Failed to get plugin', error); + } + } + }); +}; + +const openChat = async ( + page: IJupyterLabPageFixture, + filename: string +): Promise => { + const panel = await page.activity.getPanelLocator(filename); + if (panel !== null && (await panel.count())) { + return panel; + } + + await page.evaluate(async filepath => { + await window.jupyterapp.commands.execute('collaborative-chat:open', { + filepath + }); + }, filename); + await page.waitForCondition( + async () => await page.activity.isTabActive(filename) + ); + return (await page.activity.getPanelLocator(filename)) as Locator; +}; + +test.beforeEach(async ({ page }) => { + // Expose a function to get a plugin. + await page.evaluate(exposeDepsJs({ getPlugin })); + + // Create a chat file + await page.filebrowser.contents.uploadContent('{}', 'text', FILENAME); +}); + +test.afterEach(async ({ page }) => { + if (await page.filebrowser.contents.fileExists(FILENAME)) { + await page.filebrowser.contents.deleteFile(FILENAME); + } +}); + +test.describe('#autocompletionRegistry', () => { + test.beforeEach(async ({ page }) => { + await page.evaluate( + async options => { + // register a basic autocompletion object in registry. + const registry = await window.getPlugin( + 'jupyterlab-collaborative-chat:autocompletionRegistry' + ); + + registry.removeAll(); + + const autocompletion = { + opener: options.opener, + commands: async () => + options.commands.map(str => ({ + label: str, + id: str.replace('?', '') + })) + }; + registry.add('test-completion', autocompletion, true); + }, + { opener, commands } + ); + }); + + test('should open autocompletion with opener string', async ({ page }) => { + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + // Autocompletion should no be attached by default. + await expect(completionPopup).not.toBeAttached(); + + // Autocompletion should no be attached with other character than the opener. + await input.fill('/'); + await expect(completionPopup).not.toBeAttached(); + + // Autocompletion should no be attached with opener character. + await input.fill(opener); + await expect(completionPopup).toBeAttached(); + + // The autocompletion should be closed when removing opener string. + await input.press('Backspace'); + await expect(completionPopup).not.toBeAttached(); + }); + + test('autocompletion should contain correct tags', async ({ page }) => { + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + // Autocompletion should no be attached by default. + await expect(completionPopup).not.toBeAttached(); + + // Autocompletion should no be attached with opener character. + await input.fill(opener); + await expect(completionPopup).toBeAttached(); + + const options = completionPopup.locator('.MuiAutocomplete-option'); + await expect(options).toHaveCount(3); + for (let i = 0; i < (await options.count()); i++) { + await expect(options.nth(i)).toHaveText(commands[i]); + } + }); + + test('should open autocompletion with a tag highlighted', async ({ + page + }) => { + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + + // Autocompletion should no be attached with opener character. + await input.fill(opener); + await expect(completionPopup).toBeAttached(); + await expect(completionPopup.locator('.Mui-focused')).toHaveCount(1); + }); + + test('should remove autocompletion from registry', async ({ page }) => { + await page.evaluate( + async options => { + const registry = await window.getPlugin( + 'jupyterlab-collaborative-chat:autocompletionRegistry' + ); + registry.remove('test-completion'); + }, + { opener, commands } + ); + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + + // Autocompletion should not be attached with opener character. + await input.fill(opener); + await expect(completionPopup).not.toBeAttached(); + }); + + test('should change the default completion when adding a new default', async ({ + page + }) => { + const newOpener = '/'; + await page.evaluate( + async options => { + const registry = await window.getPlugin( + 'jupyterlab-collaborative-chat:autocompletionRegistry' + ); + const autocompletion = { + opener: options.newOpener, + commands: async () => + options.commands.map(str => ({ + label: str.replace('?', '/'), + id: str.replace('?', '') + })) + }; + registry.add('test-completion-other', autocompletion, true); + }, + { newOpener, commands } + ); + + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + + // Autocompletion should not be attached with the previous opener character. + await input.fill(opener); + await expect(completionPopup).not.toBeAttached(); + + await input.fill(newOpener); + await expect(completionPopup).toBeAttached(); + + const options = completionPopup.locator('.MuiAutocomplete-option'); + await expect(options).toHaveCount(3); + for (let i = 0; i < (await options.count()); i++) { + await expect(options.nth(i)).toHaveText(commands[i].replace('?', '/')); + } + }); + + test('should not add autocompletion with the same name', async ({ page }) => { + const newOpener = '/'; + const added = await page.evaluate( + async options => { + const registry = await window.getPlugin( + 'jupyterlab-collaborative-chat:autocompletionRegistry' + ); + const autocompletion = { + opener: options.newOpener, + commands: async () => + options.commands.map(str => ({ + label: str.replace('?', '/'), + id: str.replace('?', '') + })) + }; + return registry.add('test-completion', autocompletion, true); + }, + { newOpener, commands } + ); + + expect(added).toBe(false); + + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + + // Autocompletion should be attached with the previous opener character. + await input.fill(opener); + await expect(completionPopup).toBeAttached(); + }); + + test('should not change the default completion when adding a non default', async ({ + page + }) => { + const newOpener = '/'; + await page.evaluate( + async options => { + const registry = await window.getPlugin( + 'jupyterlab-collaborative-chat:autocompletionRegistry' + ); + const autocompletion = { + opener: options.newOpener, + commands: async () => + options.commands.map(str => ({ + label: str.replace('?', '/'), + id: str.replace('?', '') + })) + }; + registry.add('test-completion-other', autocompletion, false); + }, + { newOpener, commands } + ); + + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + + // Autocompletion should be attached with the previous opener character. + await input.fill(opener); + await expect(completionPopup).toBeAttached(); + }); +}); + +test('should use properties from autocompletion object', async ({ page }) => { + await page.evaluate( + async options => { + const registry = await window.getPlugin( + 'jupyterlab-collaborative-chat:autocompletionRegistry' + ); + registry.removeAll(); + const autocompletion = { + opener: options.opener, + commands: async () => + options.commands.map(str => ({ + label: str, + id: str.replace('?', '') + })), + props: { + autoHighlight: false + } + }; + registry.add('test-completion', autocompletion, true); + }, + { opener, commands } + ); + + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + + // There should be no highlighted option. + await input.fill(opener); + await expect(completionPopup).toBeAttached(); + await expect(completionPopup.locator('.Mui-focused')).toHaveCount(0); +}); + +test('single autocompletion should be the default', async ({ page }) => { + await page.evaluate( + async options => { + const registry = await window.getPlugin( + 'jupyterlab-collaborative-chat:autocompletionRegistry' + ); + registry.removeAll(); + const autocompletion = { + opener: options.opener, + commands: async () => + options.commands.map(str => ({ + label: str, + id: str.replace('?', '') + })) + }; + registry.add('test-completion', autocompletion, false); + }, + { opener, commands } + ); + + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const completionPopup = page.locator('.MuiAutocomplete-popper'); + + // Autocompletion should be attached with the opener character. + await input.fill(opener); + await expect(completionPopup).toBeAttached(); +}); diff --git a/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts index 8497744..d4251fa 100644 --- a/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts +++ b/packages/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts @@ -10,7 +10,7 @@ import { test } from '@jupyterlab/galata'; import { Contents, User } from '@jupyterlab/services'; -import { PartialJSONObject, ReadonlyJSONObject, UUID } from '@lumino/coreutils'; +import { ReadonlyJSONObject, UUID } from '@lumino/coreutils'; import { Locator } from '@playwright/test'; const FILENAME = 'my-chat.chat'; @@ -128,7 +128,7 @@ const sendMessage = async ( const chatPanel = await openChat(page, filename); const input = chatPanel .locator('.jp-chat-input-container') - .getByRole('textbox'); + .getByRole('combobox'); const sendButton = chatPanel .locator('.jp-chat-input-container') .getByRole('button'); @@ -304,7 +304,7 @@ test.describe('#sendMessages', () => { const chatPanel = await openChat(page, FILENAME); const input = chatPanel .locator('.jp-chat-input-container') - .getByRole('textbox'); + .getByRole('combobox'); const sendButton = chatPanel .locator('.jp-chat-input-container') .getByRole('button'); @@ -323,7 +323,7 @@ test.describe('#sendMessages', () => { const chatPanel = await openChat(page, FILENAME); const input = chatPanel .locator('.jp-chat-input-container') - .getByRole('textbox'); + .getByRole('combobox'); await input.pressSequentially(MSG_CONTENT); await input.press('Enter'); @@ -358,7 +358,7 @@ test.describe('#sendMessages', () => { const messages = chatPanel.locator('.jp-chat-messages-container'); const input = chatPanel .locator('.jp-chat-input-container') - .getByRole('textbox'); + .getByRole('combobox'); await input!.pressSequentially(MSG_CONTENT); await input!.press('Enter'); @@ -404,7 +404,7 @@ test.describe('#sendMessages', () => { const messages = chatPanel.locator('.jp-chat-messages-container'); const input = chatPanel .locator('.jp-chat-input-container') - .getByRole('textbox'); + .getByRole('combobox'); await input!.pressSequentially(MSG_CONTENT); await input!.press('Enter'); @@ -1033,7 +1033,7 @@ test.describe('#messageToolbar', () => { const editInput = chatPanel .locator('.jp-chat-messages-container .jp-chat-input-container') - .getByRole('textbox'); + .getByRole('combobox'); await expect(editInput).toBeVisible(); await editInput.focus(); @@ -1065,7 +1065,7 @@ test.describe('#messageToolbar', () => { const editInput = chatPanel .locator('.jp-chat-messages-container .jp-chat-input-container') - .getByRole('textbox'); + .getByRole('combobox'); await expect(editInput).toBeVisible(); await editInput.focus(); @@ -1130,7 +1130,7 @@ test.describe('#ychat', () => { const chatPanel = await openChat(page, FILENAME); await chatPanel .locator('.jp-chat-input-container') - .getByRole('textbox') + .getByRole('combobox') .waitFor(); const hasId = async () => { const model = await readFileContent(page, FILENAME); @@ -1150,7 +1150,8 @@ test.describe('#outofband', () => { id: UUID.uuid4(), sender: USERNAME, body: MSG_CONTENT, - time: 1714116341 + time: 1714116341, + raw_time: false }; const chatContent = { messages: [msg], @@ -1206,7 +1207,8 @@ test.describe('#outofband', () => { id: UUID.uuid4(), sender: USERNAME, body: newMsgContent, - time: msg.time + 5 + time: msg.time + 5, + raw_time: false }; const newContent = { messages: [msg, newMsg], diff --git a/packages/jupyterlab-ws-chat/src/index.ts b/packages/jupyterlab-ws-chat/src/index.ts index 7f127df..33bd87e 100644 --- a/packages/jupyterlab-ws-chat/src/index.ts +++ b/packages/jupyterlab-ws-chat/src/index.ts @@ -3,7 +3,12 @@ * Distributed under the terms of the Modified BSD License. */ -import { buildChatSidebar, buildErrorWidget } from '@jupyter/chat'; +import { + AutocompletionRegistry, + IAutocompletionRegistry, + buildChatSidebar, + buildErrorWidget +} from '@jupyter/chat'; import { ILayoutRestorer, JupyterFrontEnd, @@ -15,19 +20,42 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { WebSocketHandler } from './handlers/websocket-handler'; -const pluginId = 'jupyterlab-ws-chat:chat'; +const pluginIds = { + autocompletionRegistry: 'jupyterlab-ws-chat:autocompletionRegistry', + chat: 'jupyterlab-ws-chat:chat' +}; + +/** + * Extension providing the autocompletion registry. + */ +const autocompletionPlugin: JupyterFrontEndPlugin = { + id: pluginIds.autocompletionRegistry, + description: 'An autocompletion registry', + autoStart: true, + provides: IAutocompletionRegistry, + activate: (app: JupyterFrontEnd): IAutocompletionRegistry => { + return new AutocompletionRegistry(); + } +}; + /** * Initialization of the @jupyterlab/chat extension. */ const chat: JupyterFrontEndPlugin = { - id: pluginId, + id: pluginIds.chat, description: 'A chat extension for Jupyterlab', autoStart: true, - optional: [ILayoutRestorer, ISettingRegistry, IThemeManager], requires: [IRenderMimeRegistry], + optional: [ + IAutocompletionRegistry, + ILayoutRestorer, + ISettingRegistry, + IThemeManager + ], activate: async ( app: JupyterFrontEnd, rmRegistry: IRenderMimeRegistry, + autocompletionRegistry: IAutocompletionRegistry, restorer: ILayoutRestorer | null, settingsRegistry: ISettingRegistry | null, themeManager: IThemeManager | null @@ -59,7 +87,7 @@ const chat: JupyterFrontEndPlugin = { // Wait for the application to be restored and // for the settings to be loaded - Promise.all([app.restored, settingsRegistry?.load(pluginId)]) + Promise.all([app.restored, settingsRegistry?.load(pluginIds.chat)]) .then(([, settings]) => { if (!settings) { console.warn( @@ -86,7 +114,8 @@ const chat: JupyterFrontEndPlugin = { chatWidget = buildChatSidebar({ model: chatHandler, themeManager, - rmRegistry + rmRegistry, + autocompletionRegistry }); } catch (e) { chatWidget = buildErrorWidget(themeManager); @@ -105,4 +134,4 @@ const chat: JupyterFrontEndPlugin = { } }; -export default chat; +export default [autocompletionPlugin, chat];