Skip to content

Commit ff01015

Browse files
authored
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
1 parent c4ecf59 commit ff01015

File tree

8 files changed

+155
-48
lines changed

8 files changed

+155
-48
lines changed

packages/jupyter-chat/src/widgets/chat-widget.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ export class ChatWidget extends ReactWidget {
1414
constructor(options: Chat.IOptions) {
1515
super();
1616

17-
this.id = 'jupyter-chat::widget';
1817
this.title.icon = chatIcon;
1918
this.title.caption = 'Jupyter Chat'; // TODO: i18n
2019

2120
this._chatOptions = options;
21+
this.id = `jupyter-chat::widget::${options.model.name}`;
22+
this.node.onclick = () => this.model.focusInput();
2223
}
2324

2425
/**

packages/jupyterlab-chat/src/token.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import {
77
IConfig,
88
chatIcon,
99
IActiveCellManager,
10-
ISelectionWatcher
10+
ISelectionWatcher,
11+
ChatWidget
1112
} from '@jupyter/chat';
12-
import { IWidgetTracker } from '@jupyterlab/apputils';
13+
import { WidgetTracker } from '@jupyterlab/apputils';
1314
import { DocumentRegistry } from '@jupyterlab/docregistry';
1415
import { Token } from '@lumino/coreutils';
1516
import { ISignal } from '@lumino/signaling';
@@ -56,7 +57,7 @@ export interface IChatFactory {
5657
/**
5758
* The chat panel tracker.
5859
*/
59-
tracker: IWidgetTracker<LabChatPanel>;
60+
tracker: WidgetTracker<LabChatPanel | ChatWidget>;
6061
}
6162

6263
/**

packages/jupyterlab-chat/src/widget.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,13 @@ export class ChatPanel extends SidePanel {
152152
* @param model - the model of the chat widget
153153
* @param name - the name of the chat.
154154
*/
155-
addChat(model: IChatModel, path: string): void {
155+
addChat(model: IChatModel): ChatWidget {
156156
// Collapse all chats
157157
const content = this.content as AccordionPanel;
158158
for (let i = 0; i < this.widgets.length; i++) {
159159
content.collapse(i);
160160
}
161161

162-
// Set the name of the model.
163-
model.name = path;
164-
165162
// Create a new widget.
166163
const widget = new ChatWidget({
167164
model: model,
@@ -174,10 +171,12 @@ export class ChatPanel extends SidePanel {
174171
new ChatSection({
175172
widget,
176173
commands: this._commands,
177-
path,
174+
path: model.name,
178175
defaultDirectory: this._defaultDirectory
179176
})
180177
);
178+
179+
return widget;
181180
}
182181

183182
/**

python/jupyterlab-chat/src/index.ts

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { NotebookShell } from '@jupyter-notebook/application';
77
import {
88
ActiveCellManager,
99
AutocompletionRegistry,
10+
ChatWidget,
1011
IActiveCellManager,
1112
IAutocompletionRegistry,
1213
ISelectionWatcher,
@@ -42,6 +43,7 @@ import { Contents } from '@jupyterlab/services';
4243
import { ISettingRegistry } from '@jupyterlab/settingregistry';
4344
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
4445
import { launchIcon } from '@jupyterlab/ui-components';
46+
import { PromiseDelegate } from '@lumino/coreutils';
4547
import {
4648
IActiveCellManagerToken,
4749
chatFileType,
@@ -236,8 +238,7 @@ const docFactories: JupyterFrontEndPlugin<IChatFactory> = {
236238
const namespace = 'chat';
237239

238240
// Creating the tracker for the document
239-
const tracker = new WidgetTracker<LabChatPanel>({ namespace });
240-
241+
const tracker = new WidgetTracker<LabChatPanel | ChatWidget>({ namespace });
241242
app.docRegistry.addFileType(chatFileType);
242243

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

299300
// Handle state restoration.
300301
if (restorer) {
302+
// Promise that resolve when the openChat command is ready.
303+
const openCommandReady = new PromiseDelegate<void>();
304+
const commandChanged = () => {
305+
if (app.commands.hasCommand(CommandIDs.openChat)) {
306+
openCommandReady.resolve();
307+
app.commands.commandChanged.disconnect(commandChanged);
308+
}
309+
};
310+
app.commands.commandChanged.connect(commandChanged);
311+
301312
void restorer.restore(tracker, {
302-
command: 'docmanager:open',
303-
args: panel => ({ path: panel.context.path, factory: FACTORY }),
304-
name: panel => panel.context.path,
305-
when: app.serviceManager.ready
313+
command: CommandIDs.openChat,
314+
args: widget => ({
315+
filepath: widget.model.name ?? '',
316+
inSidePanel: widget instanceof ChatWidget
317+
}),
318+
name: widget => widget.model.name,
319+
when: openCommandReady.promise
306320
});
307321
}
308322

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

553567
// Initialize the chat model with the share model
554-
const chat = new LabChatModel({
568+
const chatModel = new LabChatModel({
555569
user,
556570
sharedModel,
557571
widgetConfig,
@@ -560,8 +574,12 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
560574
selectionWatcher
561575
});
562576

563-
// Add a chat widget to the side panel.
564-
chatPanel.addChat(chat, model.path);
577+
// Set the name of the model.
578+
chatModel.name = model.path;
579+
580+
// Add a chat widget to the side panel and to the tracker.
581+
const widget = chatPanel.addChat(chatModel);
582+
factory.tracker.add(widget);
565583
} else {
566584
// The chat is opened in the main area
567585
commands.execute('docmanager:open', {
@@ -588,18 +606,19 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
588606
commands.addCommand(CommandIDs.focusInput, {
589607
caption: 'Focus the input of the current chat widget',
590608
isEnabled: () => tracker.currentWidget !== null,
591-
execute: async () => {
609+
execute: () => {
592610
const widget = tracker.currentWidget;
593-
// Ensure widget is a LabChatPanel and is in main area
594-
if (
595-
!widget ||
596-
!(widget instanceof LabChatPanel) ||
597-
!Array.from(app.shell.widgets('main')).includes(widget)
598-
) {
599-
return;
611+
if (widget) {
612+
if (widget instanceof ChatWidget && chatPanel) {
613+
// The chat is the side panel.
614+
app.shell.activateById(chatPanel.id);
615+
chatPanel.openIfExists(widget.model.name);
616+
} else {
617+
// The chat is in the main area.
618+
app.shell.activateById(widget.id);
619+
}
620+
widget.model.focusInput();
600621
}
601-
app.shell.activateById(widget.id);
602-
widget.model.focusInput();
603622
}
604623
});
605624
}

ui-tests/tests/commands.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

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

89
const FILENAME = 'my-chat.chat';
910

@@ -128,3 +129,52 @@ test.describe('#launcher', () => {
128129
);
129130
});
130131
});
132+
133+
test.describe('#focusInput', () => {
134+
test.beforeEach(async ({ page }) => {
135+
// Create a chat file
136+
await page.filebrowser.contents.uploadContent('{}', 'text', FILENAME);
137+
});
138+
139+
test.afterEach(async ({ page }) => {
140+
if (await page.filebrowser.contents.fileExists(FILENAME)) {
141+
await page.filebrowser.contents.deleteFile(FILENAME);
142+
}
143+
});
144+
145+
test('should focus on the main area chat input', async ({ page }) => {
146+
const chatPanel = await openChat(page, FILENAME);
147+
const input = chatPanel
148+
.locator('.jp-chat-input-container')
149+
.getByRole('combobox');
150+
151+
// hide the chat
152+
await page.activity.activateTab('Launcher');
153+
154+
// focus input
155+
await page.keyboard.press('Control+Shift+1');
156+
157+
// expect the chat to be visible and the input to be focussed
158+
await expect(chatPanel).toBeVisible();
159+
await expect(input).toBeFocused();
160+
});
161+
162+
test('should focus on the side panel chat input', async ({ page }) => {
163+
const chatPanel = await openChatToSide(page, FILENAME);
164+
const input = chatPanel
165+
.locator('.jp-chat-input-container')
166+
.getByRole('combobox');
167+
168+
// hide the chat
169+
const chatIcon = page.getByTitle('Jupyter Chat');
170+
await chatIcon.click();
171+
await expect(chatPanel).not.toBeVisible();
172+
173+
// focus input
174+
await page.keyboard.press('Control+Shift+1');
175+
176+
// expect the chat to be visible and the input to be focussed
177+
await expect(chatPanel).toBeVisible();
178+
await expect(input).toBeFocused();
179+
});
180+
});

ui-tests/tests/general.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright (c) Jupyter Development Team.
3+
* Distributed under the terms of the Modified BSD License.
4+
*/
5+
6+
import { expect, test } from '@jupyterlab/galata';
7+
import { openChat, openChatToSide, openSidePanel } from './test-utils';
8+
9+
const CHAT1 = 'test1.chat';
10+
const CHAT2 = 'test2.chat';
11+
12+
test.describe('#restorer', () => {
13+
test.beforeEach(async ({ page }) => {
14+
// Create chat filed
15+
await page.filebrowser.contents.uploadContent('{}', 'text', CHAT1);
16+
await page.filebrowser.contents.uploadContent('{}', 'text', CHAT2);
17+
});
18+
19+
test.afterEach(async ({ page }) => {
20+
[CHAT1, CHAT2].forEach(async file => {
21+
if (await page.filebrowser.contents.fileExists(file)) {
22+
await page.filebrowser.contents.deleteFile(file);
23+
}
24+
});
25+
});
26+
27+
test('should restore the previous session', async ({ page }) => {
28+
const chat1 = await openChat(page, CHAT1);
29+
const chat2 = await openChatToSide(page, CHAT2);
30+
await page.reload({ waitForIsReady: false });
31+
32+
await expect(chat1).toBeVisible();
33+
// open the side panel if it is not
34+
await openSidePanel(page);
35+
await expect(chat2).toBeVisible();
36+
});
37+
});

ui-tests/tests/side-panel.spec.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,18 @@
33
* Distributed under the terms of the Modified BSD License.
44
*/
55

6-
import {
7-
IJupyterLabPageFixture,
8-
expect,
9-
galata,
10-
test
11-
} from '@jupyterlab/galata';
6+
import { expect, galata, test } from '@jupyterlab/galata';
127
import { Locator } from '@playwright/test';
138

14-
import { openChat, openChatToSide, openSettings } from './test-utils';
9+
import {
10+
openChat,
11+
openChatToSide,
12+
openSettings,
13+
openSidePanel
14+
} from './test-utils';
1515

1616
const FILENAME = 'my-chat.chat';
1717

18-
const openSidePanel = async (
19-
page: IJupyterLabPageFixture
20-
): Promise<Locator> => {
21-
const panel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel');
22-
23-
if (!(await panel?.isVisible())) {
24-
const chatIcon = page.getByTitle('Jupyter Chat');
25-
await chatIcon.click();
26-
await expect(panel).toBeVisible();
27-
}
28-
return panel.first();
29-
};
30-
3118
test.describe('#sidepanel', () => {
3219
test.describe('#initialization', () => {
3320
test('should contain the chat panel icon', async ({ page }) => {

ui-tests/tests/test-utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,16 @@ export const openSettings = async (
157157
);
158158
return (await page.activity.getPanelLocator('Settings')) as Locator;
159159
};
160+
161+
export const openSidePanel = async (
162+
page: IJupyterLabPageFixture
163+
): Promise<Locator> => {
164+
const panel = page.locator('.jp-SidePanel.jp-lab-chat-sidepanel');
165+
166+
if (!(await panel?.isVisible())) {
167+
const chatIcon = page.locator('.jp-SideBar').getByTitle('Jupyter Chat');
168+
await chatIcon.click();
169+
page.waitForCondition(async () => await panel.isVisible());
170+
}
171+
return panel.first();
172+
};

0 commit comments

Comments
 (0)