From ff010157cf6dc6b691367cfac4971b685b2f169e Mon Sep 17 00:00:00 2001 From: Nicolas Brichet <32258950+brichet@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:35:08 +0100 Subject: [PATCH] Adds side panel widgets to the tracker (#146) * Adds side panel widgets to the tracker * Focus input when clicking in a chat, on a non focussable element * Activate the sidepanel and the expected chat before focusing input * Add tests * lint --- .../jupyter-chat/src/widgets/chat-widget.tsx | 3 +- packages/jupyterlab-chat/src/token.ts | 7 ++- packages/jupyterlab-chat/src/widget.tsx | 9 ++- python/jupyterlab-chat/src/index.ts | 57 ++++++++++++------- ui-tests/tests/commands.spec.ts | 50 ++++++++++++++++ ui-tests/tests/general.spec.ts | 37 ++++++++++++ ui-tests/tests/side-panel.spec.ts | 27 +++------ ui-tests/tests/test-utils.ts | 13 +++++ 8 files changed, 155 insertions(+), 48 deletions(-) create mode 100644 ui-tests/tests/general.spec.ts diff --git a/packages/jupyter-chat/src/widgets/chat-widget.tsx b/packages/jupyter-chat/src/widgets/chat-widget.tsx index a1a9aa2d..da62d2d1 100644 --- a/packages/jupyter-chat/src/widgets/chat-widget.tsx +++ b/packages/jupyter-chat/src/widgets/chat-widget.tsx @@ -14,11 +14,12 @@ export class ChatWidget extends ReactWidget { constructor(options: Chat.IOptions) { super(); - this.id = 'jupyter-chat::widget'; this.title.icon = chatIcon; this.title.caption = 'Jupyter Chat'; // TODO: i18n this._chatOptions = options; + this.id = `jupyter-chat::widget::${options.model.name}`; + this.node.onclick = () => this.model.focusInput(); } /** diff --git a/packages/jupyterlab-chat/src/token.ts b/packages/jupyterlab-chat/src/token.ts index 29404636..3807eff1 100644 --- a/packages/jupyterlab-chat/src/token.ts +++ b/packages/jupyterlab-chat/src/token.ts @@ -7,9 +7,10 @@ import { IConfig, chatIcon, IActiveCellManager, - ISelectionWatcher + ISelectionWatcher, + ChatWidget } from '@jupyter/chat'; -import { IWidgetTracker } from '@jupyterlab/apputils'; +import { WidgetTracker } from '@jupyterlab/apputils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { Token } from '@lumino/coreutils'; import { ISignal } from '@lumino/signaling'; @@ -56,7 +57,7 @@ export interface IChatFactory { /** * The chat panel tracker. */ - tracker: IWidgetTracker; + tracker: WidgetTracker; } /** diff --git a/packages/jupyterlab-chat/src/widget.tsx b/packages/jupyterlab-chat/src/widget.tsx index a0986b20..c2a44171 100644 --- a/packages/jupyterlab-chat/src/widget.tsx +++ b/packages/jupyterlab-chat/src/widget.tsx @@ -152,16 +152,13 @@ export class ChatPanel extends SidePanel { * @param model - the model of the chat widget * @param name - the name of the chat. */ - addChat(model: IChatModel, path: string): void { + 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); } - // Set the name of the model. - model.name = path; - // Create a new widget. const widget = new ChatWidget({ model: model, @@ -174,10 +171,12 @@ export class ChatPanel extends SidePanel { new ChatSection({ widget, commands: this._commands, - path, + path: model.name, defaultDirectory: this._defaultDirectory }) ); + + return widget; } /** diff --git a/python/jupyterlab-chat/src/index.ts b/python/jupyterlab-chat/src/index.ts index 4b862fd0..0db5c4c0 100644 --- a/python/jupyterlab-chat/src/index.ts +++ b/python/jupyterlab-chat/src/index.ts @@ -7,6 +7,7 @@ import { NotebookShell } from '@jupyter-notebook/application'; import { ActiveCellManager, AutocompletionRegistry, + ChatWidget, IActiveCellManager, IAutocompletionRegistry, ISelectionWatcher, @@ -42,6 +43,7 @@ import { Contents } from '@jupyterlab/services'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { launchIcon } from '@jupyterlab/ui-components'; +import { PromiseDelegate } from '@lumino/coreutils'; import { IActiveCellManagerToken, chatFileType, @@ -236,8 +238,7 @@ const docFactories: JupyterFrontEndPlugin = { const namespace = 'chat'; // Creating the tracker for the document - const tracker = new WidgetTracker({ namespace }); - + const tracker = new WidgetTracker({ namespace }); app.docRegistry.addFileType(chatFileType); if (drive) { @@ -298,11 +299,24 @@ const docFactories: JupyterFrontEndPlugin = { // Handle state restoration. if (restorer) { + // Promise that resolve when the openChat command is ready. + const openCommandReady = new PromiseDelegate(); + const commandChanged = () => { + if (app.commands.hasCommand(CommandIDs.openChat)) { + openCommandReady.resolve(); + app.commands.commandChanged.disconnect(commandChanged); + } + }; + app.commands.commandChanged.connect(commandChanged); + void restorer.restore(tracker, { - command: 'docmanager:open', - args: panel => ({ path: panel.context.path, factory: FACTORY }), - name: panel => panel.context.path, - when: app.serviceManager.ready + command: CommandIDs.openChat, + args: widget => ({ + filepath: widget.model.name ?? '', + inSidePanel: widget instanceof ChatWidget + }), + name: widget => widget.model.name, + when: openCommandReady.promise }); } @@ -551,7 +565,7 @@ const chatCommands: JupyterFrontEndPlugin = { }) as YChat; // Initialize the chat model with the share model - const chat = new LabChatModel({ + const chatModel = new LabChatModel({ user, sharedModel, widgetConfig, @@ -560,8 +574,12 @@ const chatCommands: JupyterFrontEndPlugin = { selectionWatcher }); - // Add a chat widget to the side panel. - chatPanel.addChat(chat, model.path); + // Set the name of the model. + chatModel.name = model.path; + + // Add a chat widget to the side panel and to the tracker. + const widget = chatPanel.addChat(chatModel); + factory.tracker.add(widget); } else { // The chat is opened in the main area commands.execute('docmanager:open', { @@ -588,18 +606,19 @@ const chatCommands: JupyterFrontEndPlugin = { commands.addCommand(CommandIDs.focusInput, { caption: 'Focus the input of the current chat widget', isEnabled: () => tracker.currentWidget !== null, - execute: async () => { + execute: () => { const widget = tracker.currentWidget; - // Ensure widget is a LabChatPanel and is in main area - if ( - !widget || - !(widget instanceof LabChatPanel) || - !Array.from(app.shell.widgets('main')).includes(widget) - ) { - return; + if (widget) { + if (widget instanceof ChatWidget && chatPanel) { + // The chat is the side panel. + app.shell.activateById(chatPanel.id); + chatPanel.openIfExists(widget.model.name); + } else { + // The chat is in the main area. + app.shell.activateById(widget.id); + } + widget.model.focusInput(); } - app.shell.activateById(widget.id); - widget.model.focusInput(); } }); } diff --git a/ui-tests/tests/commands.spec.ts b/ui-tests/tests/commands.spec.ts index 531586b7..7622f254 100644 --- a/ui-tests/tests/commands.spec.ts +++ b/ui-tests/tests/commands.spec.ts @@ -4,6 +4,7 @@ */ import { expect, IJupyterLabPageFixture, test } from '@jupyterlab/galata'; +import { openChat, openChatToSide } from './test-utils'; const FILENAME = 'my-chat.chat'; @@ -128,3 +129,52 @@ test.describe('#launcher', () => { ); }); }); + +test.describe('#focusInput', () => { + test.beforeEach(async ({ page }) => { + // 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('should focus on the main area chat input', async ({ page }) => { + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + + // hide the chat + await page.activity.activateTab('Launcher'); + + // focus input + await page.keyboard.press('Control+Shift+1'); + + // expect the chat to be visible and the input to be focussed + await expect(chatPanel).toBeVisible(); + await expect(input).toBeFocused(); + }); + + test('should focus on the side panel chat input', async ({ page }) => { + const chatPanel = await openChatToSide(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + + // hide the chat + const chatIcon = page.getByTitle('Jupyter Chat'); + await chatIcon.click(); + await expect(chatPanel).not.toBeVisible(); + + // focus input + await page.keyboard.press('Control+Shift+1'); + + // expect the chat to be visible and the input to be focussed + await expect(chatPanel).toBeVisible(); + await expect(input).toBeFocused(); + }); +}); diff --git a/ui-tests/tests/general.spec.ts b/ui-tests/tests/general.spec.ts new file mode 100644 index 00000000..aa16067d --- /dev/null +++ b/ui-tests/tests/general.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { expect, test } from '@jupyterlab/galata'; +import { openChat, openChatToSide, openSidePanel } from './test-utils'; + +const CHAT1 = 'test1.chat'; +const CHAT2 = 'test2.chat'; + +test.describe('#restorer', () => { + test.beforeEach(async ({ page }) => { + // Create chat filed + await page.filebrowser.contents.uploadContent('{}', 'text', CHAT1); + await page.filebrowser.contents.uploadContent('{}', 'text', CHAT2); + }); + + test.afterEach(async ({ page }) => { + [CHAT1, CHAT2].forEach(async file => { + if (await page.filebrowser.contents.fileExists(file)) { + await page.filebrowser.contents.deleteFile(file); + } + }); + }); + + test('should restore the previous session', async ({ page }) => { + const chat1 = await openChat(page, CHAT1); + const chat2 = await openChatToSide(page, CHAT2); + await page.reload({ waitForIsReady: false }); + + await expect(chat1).toBeVisible(); + // open the side panel if it is not + await openSidePanel(page); + await expect(chat2).toBeVisible(); + }); +}); diff --git a/ui-tests/tests/side-panel.spec.ts b/ui-tests/tests/side-panel.spec.ts index f9f4afc6..8a50cf2e 100644 --- a/ui-tests/tests/side-panel.spec.ts +++ b/ui-tests/tests/side-panel.spec.ts @@ -3,31 +3,18 @@ * Distributed under the terms of the Modified BSD License. */ -import { - IJupyterLabPageFixture, - expect, - galata, - test -} from '@jupyterlab/galata'; +import { expect, galata, test } from '@jupyterlab/galata'; import { Locator } from '@playwright/test'; -import { openChat, openChatToSide, openSettings } from './test-utils'; +import { + openChat, + openChatToSide, + openSettings, + openSidePanel +} from './test-utils'; const FILENAME = 'my-chat.chat'; -const openSidePanel = async ( - page: IJupyterLabPageFixture -): Promise => { - const panel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel'); - - if (!(await panel?.isVisible())) { - const chatIcon = page.getByTitle('Jupyter Chat'); - await chatIcon.click(); - await expect(panel).toBeVisible(); - } - return panel.first(); -}; - test.describe('#sidepanel', () => { test.describe('#initialization', () => { test('should contain the chat panel icon', async ({ page }) => { diff --git a/ui-tests/tests/test-utils.ts b/ui-tests/tests/test-utils.ts index 6f9eec23..b4854bcb 100644 --- a/ui-tests/tests/test-utils.ts +++ b/ui-tests/tests/test-utils.ts @@ -157,3 +157,16 @@ export const openSettings = async ( ); return (await page.activity.getPanelLocator('Settings')) as Locator; }; + +export const openSidePanel = async ( + page: IJupyterLabPageFixture +): Promise => { + const panel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel'); + + if (!(await panel?.isVisible())) { + const chatIcon = page.locator('.jp-SideBar').getByTitle('Jupyter Chat'); + await chatIcon.click(); + page.waitForCondition(async () => await panel.isVisible()); + } + return panel.first(); +};