Skip to content

Commit 82595f8

Browse files
authored
Fix duplicated chats (#87)
* Add the file path to the chat section widget, and use the path as section name * Avoid opening duplicate chat in side panel * Add tests
1 parent 9646f3c commit 82595f8

File tree

4 files changed

+175
-29
lines changed

4 files changed

+175
-29
lines changed

packages/jupyterlab-collaborative-chat/src/widget.tsx

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export class ChatPanel extends SidePanel {
151151
* @param model - the model of the chat widget
152152
* @param name - the name of the chat.
153153
*/
154-
addChat(model: IChatModel, name: string): void {
154+
addChat(model: IChatModel, name: string, path: string): void {
155155
// Collapse all chats
156156
const content = this.content as AccordionPanel;
157157
for (let i = 0; i < this.widgets.length; i++) {
@@ -168,22 +168,45 @@ export class ChatPanel extends SidePanel {
168168
themeManager: this._themeManager,
169169
autocompletionRegistry: this._autocompletionRegistry
170170
});
171-
this.addWidget(new ChatSection({ name, widget, commands: this._commands }));
171+
this.addWidget(
172+
new ChatSection({ name, widget, commands: this._commands, path })
173+
);
172174
}
173175

176+
/**
177+
* Update the list of available chats in the root directory of the drive.
178+
*/
174179
updateChatNames = async (): Promise<void> => {
175180
const extension = chatFileType.extensions[0];
176181
this._drive
177182
.get('.')
178-
.then(model => {
179-
const chatsName = (model.content as any[])
183+
.then(contentModel => {
184+
const chatsNames: { [name: string]: string } = {};
185+
(contentModel.content as any[])
180186
.filter(f => f.type === 'file' && f.name.endsWith(extension))
181-
.map(f => PathExt.basename(f.name, extension));
182-
this._chatNamesChanged.emit(chatsName);
187+
.forEach(
188+
f => (chatsNames[PathExt.basename(f.name, extension)] = f.name)
189+
);
190+
191+
this._chatNamesChanged.emit(chatsNames);
183192
})
184193
.catch(e => console.error('Error getting the chat files from drive', e));
185194
};
186195

196+
/**
197+
* Open a chat if it exists in the side panel.
198+
*
199+
* @param path - the path of the chat.
200+
* @returns a boolean, whether the chat existed in the side panel or not.
201+
*/
202+
openIfExists(path: string): boolean {
203+
const index = this._getChatIndex(path);
204+
if (index > -1) {
205+
this._expandChat(index);
206+
}
207+
return index > -1;
208+
}
209+
187210
/**
188211
* A message handler invoked on an `'after-show'` message.
189212
*/
@@ -192,28 +215,41 @@ export class ChatPanel extends SidePanel {
192215
this._openChat.renderPromise?.then(() => this.updateChatNames());
193216
}
194217

218+
/**
219+
* Return the index of the chat in the list (-1 if not opened).
220+
*
221+
* @param name - the chat name.
222+
*/
223+
private _getChatIndex(path: string) {
224+
return this.widgets.findIndex(w => (w as ChatSection).path === path);
225+
}
226+
227+
/**
228+
* Expand the chat from its index.
229+
*/
230+
private _expandChat(index: number): void {
231+
if (!this.widgets[index].isVisible) {
232+
(this.content as AccordionPanel).expand(index);
233+
}
234+
}
235+
195236
/**
196237
* Handle `change` events for the HTMLSelect component.
197238
*/
198239
private _chatSelected = (
199240
event: React.ChangeEvent<HTMLSelectElement>
200241
): void => {
201-
const value = event.target.value;
202-
if (value === '-') {
242+
const select = event.target;
243+
const path = select.value;
244+
const name = select.options[select.selectedIndex].textContent;
245+
if (name === '-') {
203246
return;
204247
}
205248

206-
const index = this.widgets.findIndex(
207-
w => (w as ChatSection).name === value
208-
);
209-
if (index === -1) {
210-
this._commands.execute(CommandIDs.openChat, {
211-
filepath: `${value}${chatFileType.extensions[0]}`,
212-
inSidePanel: true
213-
});
214-
} else if (!this.widgets[index].isVisible) {
215-
(this.content as AccordionPanel).expand(index);
216-
}
249+
this._commands.execute(CommandIDs.openChat, {
250+
filepath: path,
251+
inSidePanel: true
252+
});
217253
event.target.selectedIndex = 0;
218254
};
219255

@@ -232,7 +268,9 @@ export class ChatPanel extends SidePanel {
232268
}
233269
}
234270

235-
private _chatNamesChanged = new Signal<this, string[]>(this);
271+
private _chatNamesChanged = new Signal<this, { [name: string]: string }>(
272+
this
273+
);
236274
private _commands: CommandRegistry;
237275
private _config: IConfig = {};
238276
private _drive: ICollaborativeDrive;
@@ -269,8 +307,9 @@ class ChatSection extends PanelWithToolbar {
269307
super(options);
270308
this.addClass(SECTION_CLASS);
271309
this._name = options.name;
310+
this._path = options.path;
272311
this.title.label = this._name;
273-
this.title.caption = this._name;
312+
this.title.caption = this._path;
274313
this.toolbar.addClass(TOOLBAR_CLASS);
275314

276315
this._markAsRead = new ToolbarButton({
@@ -316,6 +355,13 @@ class ChatSection extends PanelWithToolbar {
316355
options.widget.node.style.height = '100%';
317356
}
318357

358+
/**
359+
* The path of the chat.
360+
*/
361+
get path(): string {
362+
return this._path;
363+
}
364+
319365
/**
320366
* The name of the chat.
321367
*/
@@ -353,6 +399,7 @@ class ChatSection extends PanelWithToolbar {
353399

354400
private _name: string;
355401
private _markAsRead: ToolbarButton;
402+
private _path: string;
356403
}
357404

358405
/**
@@ -366,11 +413,12 @@ export namespace ChatSection {
366413
commands: CommandRegistry;
367414
name: string;
368415
widget: ChatWidget;
416+
path: string;
369417
}
370418
}
371419

372420
type ChatSelectProps = {
373-
chatNamesChanged: ISignal<ChatPanel, string[]>;
421+
chatNamesChanged: ISignal<ChatPanel, { [name: string]: string }>;
374422
handleChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
375423
};
376424

@@ -381,7 +429,9 @@ function ChatSelect({
381429
chatNamesChanged,
382430
handleChange
383431
}: ChatSelectProps): JSX.Element {
384-
const [chatNames, setChatNames] = useState<string[]>([]);
432+
// An object associating a chat name to its path. Both are purely indicative, the name
433+
// is the section title and the path is used as caption.
434+
const [chatNames, setChatNames] = useState<{ [name: string]: string }>({});
385435

386436
// Update the chat list.
387437
chatNamesChanged.connect((_, chatNames) => {
@@ -391,8 +441,8 @@ function ChatSelect({
391441
return (
392442
<HTMLSelect onChange={handleChange}>
393443
<option value="-">Open a chat</option>
394-
{chatNames.map(name => (
395-
<option value={name}>{name}</option>
444+
{Object.keys(chatNames).map(name => (
445+
<option value={chatNames[name]}>{name}</option>
396446
))}
397447
</HTMLSelect>
398448
);

python/jupyterlab-collaborative-chat/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,11 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
465465
if (inSidePanel && chatPanel) {
466466
// The chat is opened in the chat panel.
467467
app.shell.activateById(chatPanel.id);
468+
469+
if (chatPanel.openIfExists(filepath)) {
470+
return;
471+
}
472+
468473
const model = await drive.get(filepath);
469474

470475
/**
@@ -494,7 +499,11 @@ const chatCommands: JupyterFrontEndPlugin<void> = {
494499
*/
495500
chatPanel.addChat(
496501
chat,
497-
PathExt.basename(model.name, chatFileType.extensions[0])
502+
PathExt.join(
503+
PathExt.dirname(model.path),
504+
PathExt.basename(model.name, chatFileType.extensions[0])
505+
),
506+
model.path
498507
);
499508
} else {
500509
// The chat is opened in the main area

python/jupyterlab-collaborative-chat/ui-tests/tests/side-panel.spec.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ test.describe('#sidepanel', () => {
5858
const name = FILENAME.replace('.chat', '');
5959
let panel: Locator;
6060
let dialog: Locator;
61+
let addButton: Locator;
6162

6263
test.beforeEach(async ({ page }) => {
6364
panel = await openSidePanel(page);
64-
const addButton = panel.locator(
65+
addButton = panel.locator(
6566
'.jp-SidePanel-toolbar .jp-Toolbar-item.jp-collab-chat-add'
6667
);
6768
await addButton.click();
@@ -89,6 +90,7 @@ test.describe('#sidepanel', () => {
8990
'.jp-SidePanel-content .jp-AccordionPanel-title'
9091
);
9192
await expect(chatTitle).toHaveCount(1);
93+
await expect(chatTitle).toHaveClass(/lm-mod-expanded/);
9294
await expect(
9395
chatTitle.locator('.lm-AccordionPanel-titleLabel')
9496
).toHaveText(name);
@@ -117,6 +119,39 @@ test.describe('#sidepanel', () => {
117119
const content = panel.locator('.jp-SidePanel-content');
118120
await expect(content).toBeEmpty();
119121
});
122+
123+
test('should reveal the existing chat when creating again', async ({
124+
page
125+
}) => {
126+
await dialog.locator('input[type="text"]').pressSequentially(name);
127+
await dialog.getByRole('button').getByText('Ok').click();
128+
await page.waitForCondition(
129+
async () => await page.filebrowser.contents.fileExists(FILENAME)
130+
);
131+
132+
const chatTitle = panel.locator(
133+
'.jp-SidePanel-content .jp-AccordionPanel-title'
134+
);
135+
await expect(chatTitle).toHaveCount(1);
136+
await expect(
137+
chatTitle.locator('.lm-AccordionPanel-titleLabel')
138+
).toHaveText(name);
139+
140+
// Collapse the chat.
141+
await chatTitle.click();
142+
await expect(chatTitle).not.toHaveClass(/lm-mod-expanded/);
143+
144+
await addButton.click();
145+
146+
// try to recreate the same chat.
147+
dialog = page.locator('.jp-Dialog');
148+
await dialog.waitFor();
149+
await dialog.locator('input[type="text"]').pressSequentially(name);
150+
await dialog.getByRole('button').getByText('Ok').click();
151+
152+
// the chat should be expanded.
153+
await expect(chatTitle).toHaveClass(/lm-mod-expanded/);
154+
});
120155
});
121156

122157
test.describe('#openingClosing', () => {
@@ -207,6 +242,56 @@ test.describe('#sidepanel', () => {
207242
).toHaveText(FILENAME.split('.')[0]);
208243
});
209244

245+
test('should reveal the existing chat in side panel when moving again', async ({
246+
page
247+
}) => {
248+
let chatPanel = await openChat(page, FILENAME);
249+
const button = chatPanel.getByTitle('Move the chat to the side panel');
250+
await button.click();
251+
await expect(chatPanel).not.toBeAttached();
252+
253+
const sidePanel = page.locator('.jp-SidePanel.jp-collab-chat-sidepanel');
254+
await expect(sidePanel).toBeVisible();
255+
const chatTitle = sidePanel.locator(
256+
'.jp-SidePanel-content .jp-AccordionPanel-title'
257+
);
258+
await expect(chatTitle).toHaveCount(1);
259+
await expect(
260+
chatTitle.locator('.lm-AccordionPanel-titleLabel')
261+
).toHaveText(FILENAME.split('.')[0]);
262+
263+
await chatTitle.click();
264+
await expect(chatTitle).not.toHaveClass(/lm-mod-expanded/);
265+
266+
chatPanel = await openChat(page, FILENAME);
267+
await button.click();
268+
await expect(chatPanel).not.toBeAttached();
269+
await expect(chatTitle).toHaveClass(/lm-mod-expanded/);
270+
});
271+
272+
test('chat section should contain the file path', async ({ page }) => {
273+
// Create a nested chat file and open it.
274+
const dirName = 'my-dir';
275+
const nestedPath = [dirName, FILENAME].join('/');
276+
await page.filebrowser.contents.createDirectory(dirName);
277+
await page.filebrowser.contents.uploadContent('{}', 'text', nestedPath);
278+
279+
const chatPanel = await openChat(page, nestedPath);
280+
const button = chatPanel.getByTitle('Move the chat to the side panel');
281+
await button.click();
282+
await expect(chatPanel).not.toBeAttached();
283+
284+
// Move the chat to the side panel.
285+
const sidePanel = page.locator('.jp-SidePanel.jp-collab-chat-sidepanel');
286+
await expect(sidePanel).toBeVisible();
287+
const chatTitle = sidePanel.locator(
288+
'.jp-SidePanel-content .jp-AccordionPanel-title'
289+
);
290+
await expect(
291+
chatTitle.locator('.lm-AccordionPanel-titleLabel')
292+
).toHaveText(nestedPath.split('.')[0]);
293+
});
294+
210295
test('side panel should contain a button to move the chat', async ({
211296
page
212297
}) => {

python/jupyterlab-collaborative-chat/ui-tests/tests/test-utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ export const openChat = async (
3333
filepath
3434
});
3535
}, filename);
36+
const splitPath = filename.split('/');
37+
const tabName = splitPath[splitPath.length - 1];
3638
await page.waitForCondition(
37-
async () => await page.activity.isTabActive(filename)
39+
async () => await page.activity.isTabActive(tabName)
3840
);
39-
return (await page.activity.getPanelLocator(filename)) as Locator;
41+
return (await page.activity.getPanelLocator(tabName)) as Locator;
4042
};
4143

4244
export const openChatToSide = async (

0 commit comments

Comments
 (0)