Skip to content

Commit

Permalink
Adds side panel widgets to the tracker (#146)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
brichet authored Feb 6, 2025
1 parent c4ecf59 commit ff01015
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 48 deletions.
3 changes: 2 additions & 1 deletion packages/jupyter-chat/src/widgets/chat-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
7 changes: 4 additions & 3 deletions packages/jupyterlab-chat/src/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,7 +57,7 @@ export interface IChatFactory {
/**
* The chat panel tracker.
*/
tracker: IWidgetTracker<LabChatPanel>;
tracker: WidgetTracker<LabChatPanel | ChatWidget>;
}

/**
Expand Down
9 changes: 4 additions & 5 deletions packages/jupyterlab-chat/src/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -174,10 +171,12 @@ export class ChatPanel extends SidePanel {
new ChatSection({
widget,
commands: this._commands,
path,
path: model.name,
defaultDirectory: this._defaultDirectory
})
);

return widget;
}

/**
Expand Down
57 changes: 38 additions & 19 deletions python/jupyterlab-chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NotebookShell } from '@jupyter-notebook/application';
import {
ActiveCellManager,
AutocompletionRegistry,
ChatWidget,
IActiveCellManager,
IAutocompletionRegistry,
ISelectionWatcher,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -236,8 +238,7 @@ const docFactories: JupyterFrontEndPlugin<IChatFactory> = {
const namespace = 'chat';

// Creating the tracker for the document
const tracker = new WidgetTracker<LabChatPanel>({ namespace });

const tracker = new WidgetTracker<LabChatPanel | ChatWidget>({ namespace });
app.docRegistry.addFileType(chatFileType);

if (drive) {
Expand Down Expand Up @@ -298,11 +299,24 @@ const docFactories: JupyterFrontEndPlugin<IChatFactory> = {

// Handle state restoration.
if (restorer) {
// Promise that resolve when the openChat command is ready.
const openCommandReady = new PromiseDelegate<void>();
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
});
}

Expand Down Expand Up @@ -551,7 +565,7 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
}) as YChat;

// Initialize the chat model with the share model
const chat = new LabChatModel({
const chatModel = new LabChatModel({
user,
sharedModel,
widgetConfig,
Expand All @@ -560,8 +574,12 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
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', {
Expand All @@ -588,18 +606,19 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
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();
}
});
}
Expand Down
50 changes: 50 additions & 0 deletions ui-tests/tests/commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { expect, IJupyterLabPageFixture, test } from '@jupyterlab/galata';
import { openChat, openChatToSide } from './test-utils';

const FILENAME = 'my-chat.chat';

Expand Down Expand Up @@ -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();
});
});
37 changes: 37 additions & 0 deletions ui-tests/tests/general.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
27 changes: 7 additions & 20 deletions ui-tests/tests/side-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Locator> => {
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 }) => {
Expand Down
13 changes: 13 additions & 0 deletions ui-tests/tests/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,16 @@ export const openSettings = async (
);
return (await page.activity.getPanelLocator('Settings')) as Locator;
};

export const openSidePanel = async (
page: IJupyterLabPageFixture
): Promise<Locator> => {
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();
};

0 comments on commit ff01015

Please sign in to comment.