@jupyter/chat 0.20.0-alpha.2 → 0.20.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,20 +12,21 @@ import { InputDialog } from '@jupyterlab/apputils';
12
12
  import {
13
13
  addIcon,
14
14
  closeIcon,
15
- HTMLSelect,
16
15
  launchIcon,
17
16
  PanelWithToolbar,
18
17
  ReactWidget,
19
- SidePanel,
20
18
  Spinner,
21
19
  ToolbarButton
22
20
  } from '@jupyterlab/ui-components';
21
+ import { ArrayExt } from '@lumino/algorithm';
22
+ import { Message } from '@lumino/messaging';
23
23
  import { Debouncer } from '@lumino/polling';
24
24
  import { ISignal, Signal } from '@lumino/signaling';
25
- import { AccordionPanel, Panel } from '@lumino/widgets';
26
- import React, { useState } from 'react';
25
+ import { Widget } from '@lumino/widgets';
26
+ import React, { useEffect, useRef, useState } from 'react';
27
27
 
28
28
  import { ChatWidget } from './chat-widget';
29
+ import { ChatSelectorPopup } from './chat-selector-popup';
29
30
  import {
30
31
  Chat,
31
32
  IInputToolbarRegistry,
@@ -37,13 +38,16 @@ import { IChatModel } from '../model';
37
38
  const SIDEPANEL_CLASS = 'jp-chat-sidepanel';
38
39
  const ADD_BUTTON_CLASS = 'jp-chat-add';
39
40
  const OPEN_SELECT_CLASS = 'jp-chat-open';
40
- const SECTION_CLASS = 'jp-chat-section';
41
- const TOOLBAR_CLASS = 'jp-chat-section-toolbar';
41
+ const SIDEPANEL_WIDGET_CLASS = 'jp-chat-sidepanel-widget';
42
+ const TOOLBAR_CLASS = 'jp-chat-sidepanel-widget-toolbar';
42
43
 
43
44
  /**
44
45
  * Generic sidepanel widget including multiple chats and the add chat button.
45
46
  */
46
- export class MultiChatPanel extends SidePanel {
47
+ export class MultiChatPanel extends PanelWithToolbar {
48
+ /**
49
+ * The constructor of the multichat panel.
50
+ */
47
51
  constructor(options: MultiChatPanel.IOptions) {
48
52
  super(options);
49
53
  this.id = 'jupyter-chat::multi-chat-panel';
@@ -65,68 +69,147 @@ export class MultiChatPanel extends SidePanel {
65
69
  const addChat = new ToolbarButton({
66
70
  onClick: async () => {
67
71
  const addChatArgs = await this._createModel!();
68
- this.addChat(addChatArgs);
72
+ this.open(addChatArgs);
69
73
  },
70
74
  icon: addIcon,
71
- label: 'Chat',
72
- tooltip: 'Add a new chat'
75
+ tooltip: 'Create a new chat'
73
76
  });
74
77
  addChat.addClass(ADD_BUTTON_CLASS);
75
78
  this.toolbar.addItem('createChat', addChat);
76
79
  }
77
80
 
78
81
  if (this._getChatNames && this._createModel) {
79
- // Chat select dropdown
82
+ // Chat selector with search input
80
83
  this._openChatWidget = ReactWidget.create(
81
- <ChatSelect
82
- chatNamesChanged={this._chatNamesChanged}
83
- handleChange={this._chatSelected.bind(this)}
84
+ <ChatSearchInput
85
+ selectChat={this._onSelectChat}
86
+ getPopup={() => this._chatSelectorPopup}
87
+ chatOpened={this._chatOpened}
84
88
  />
85
89
  );
86
90
  this._openChatWidget.addClass(OPEN_SELECT_CLASS);
87
91
  this.toolbar.addItem('openChat', this._openChatWidget);
88
- }
89
92
 
90
- const content = this.content as AccordionPanel;
91
- content.expansionToggled.connect(this._onExpansionToggled, this);
93
+ // Create the popup widget (attached to document body)
94
+ this._chatSelectorPopup = new ChatSelectorPopup({
95
+ chatNames: [],
96
+ onSelect: this._onSelectChat,
97
+ onClose: (name: string) => {
98
+ this.disposeLoadedModel(name);
99
+ }
100
+ });
101
+ }
92
102
 
103
+ // Insert the toolbar as first child.
104
+ this.insertWidget(0, this.toolbar);
93
105
  this._updateChatListDebouncer = new Debouncer(this._updateChatList, 200);
94
106
  }
95
107
 
96
108
  /**
97
- * The sections of the side panel.
109
+ * The currently displayed chat widget.
98
110
  */
99
- get sections(): ChatSection[] {
100
- return this.widgets as ChatSection[];
111
+ get current(): SidePanelWidget | undefined {
112
+ return this._currentWidget;
101
113
  }
102
114
 
103
115
  /**
104
- * A signal emitting when a section is added to the panel.
116
+ * A signal emitting when a chat widget is opened in the panel.
105
117
  */
106
- get sectionAdded(): ISignal<MultiChatPanel, ChatSection> {
107
- return this._sectionAdded;
118
+ get chatOpened(): ISignal<MultiChatPanel, ChatWidget> {
119
+ return this._chatOpened;
108
120
  }
109
121
 
110
122
  /**
111
- * Add a new widget to the chat panel.
112
- *
113
- * @param model - the model of the chat widget
114
- * @param displayName - the name of the chat.
123
+ * A signal emitting when the panel visibility changed.
115
124
  */
125
+ get visibilityChanged(): ISignal<MultiChatPanel, boolean> {
126
+ return this._visibilityChanged;
127
+ }
116
128
 
117
- addChat(args: MultiChatPanel.IAddChatArgs): ChatWidget | undefined {
118
- const { model, displayName } = args;
129
+ /**
130
+ * Add a chat to the panel by creating or showing its widget.
131
+ *
132
+ * @param args - the chat args including model and display name.
133
+ */
134
+ open(args: MultiChatPanel.IOpenChatArgs): ChatWidget | undefined {
135
+ const { model } = args;
119
136
  if (!model) {
120
137
  return;
121
138
  }
122
139
 
123
- if (this.openIfExists(model.name)) {
140
+ const displayName = args.displayName ?? model.name;
141
+
142
+ // Add model to loaded models
143
+ if (!this._loadedModels.has(displayName)) {
144
+ this._loadedModels.set(displayName, model);
145
+ this._chatSelectorPopup?.setLoadedModels(this.getLoadedModelNames());
146
+ }
147
+
148
+ // Open this chat (will create widget)
149
+ return this._open(displayName);
150
+ }
151
+
152
+ /**
153
+ * Get a loaded model by name, or undefined if not loaded.
154
+ */
155
+ getLoadedModel(name: string): IChatModel | undefined {
156
+ return this._loadedModels.get(name);
157
+ }
158
+
159
+ /**
160
+ * Get all loaded model names.
161
+ */
162
+ getLoadedModelNames(): string[] {
163
+ return Array.from(this._loadedModels.keys());
164
+ }
165
+
166
+ /**
167
+ * Dispose a model, removing it from loaded models.
168
+ */
169
+ disposeLoadedModel(name: string): void {
170
+ const model = this._loadedModels.get(name);
171
+ if (model) {
172
+ // If this is the currently displayed chat, remove it.
173
+ if (this._currentWidget?.model === model) {
174
+ this._currentWidget.nameChanged.disconnect(this._modelNameChanged);
175
+ this._currentWidget.dispose();
176
+ this._currentWidget = undefined;
177
+
178
+ // Clear current chat in selector
179
+ if (this._chatSelectorPopup) {
180
+ this._chatSelectorPopup.setCurrentChat(null);
181
+ }
182
+ }
183
+
184
+ model.dispose();
185
+ this._loadedModels.delete(name);
186
+ this._chatSelectorPopup?.setLoadedModels(this.getLoadedModelNames());
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Emit a signal when the panel visibility changed.
192
+ */
193
+ protected onAfterShow(msg: Message): void {
194
+ this._visibilityChanged.emit(true);
195
+ }
196
+ protected onBeforeHide(msg: Message): void {
197
+ this._visibilityChanged.emit(false);
198
+ }
199
+
200
+ /**
201
+ * Open a specific chat by name, creating a new sidepanel widget.
202
+ */
203
+ private _open(name: string): ChatWidget | undefined {
204
+ const model = this._loadedModels.get(name);
205
+ if (!model) {
124
206
  return;
125
207
  }
126
208
 
127
- const content = this.content as AccordionPanel;
128
- for (let i = 0; i < this.widgets.length; i++) {
129
- content.collapse(i);
209
+ // Dispose current chat widget if any
210
+ if (this._currentWidget) {
211
+ this._currentWidget.nameChanged.disconnect(this._modelNameChanged);
212
+ this._currentWidget.dispose();
130
213
  }
131
214
 
132
215
  // Create the toolbar registry.
@@ -135,34 +218,47 @@ export class MultiChatPanel extends SidePanel {
135
218
  inputToolbarRegistry = this._inputToolbarFactory.create();
136
219
  }
137
220
 
138
- // Create a new widget.
139
- const widget = new ChatWidget({
221
+ // Create a new widget for this model
222
+ const chatWidget = new ChatWidget({
140
223
  model,
141
224
  ...this._chatOptions,
142
225
  inputToolbarRegistry,
143
226
  area: 'sidebar'
144
227
  });
145
228
 
146
- const section = new ChatSection({
147
- widget,
229
+ // Create a chat with toolbar
230
+ const widget = new SidePanelWidget({
231
+ widget: chatWidget,
232
+ displayName: name,
148
233
  openInMain: this._openInMain,
149
234
  renameChat: this._renameChat,
150
- displayName
235
+ onClose: (name: string) => {
236
+ this.disposeLoadedModel(name);
237
+ }
151
238
  });
152
239
 
153
- this.addWidget(section);
154
- content.expand(this.widgets.length - 1);
240
+ // Add to content panel
241
+ this.addWidget(widget);
242
+ this.update();
243
+ this._currentWidget = widget;
155
244
 
156
- this._sectionAdded.emit(section);
157
- return widget;
245
+ this._currentWidget.nameChanged.connect(this._modelNameChanged);
246
+
247
+ // Update selector to show current chat
248
+ if (this._chatSelectorPopup) {
249
+ this._chatSelectorPopup.setCurrentChat(name);
250
+ }
251
+
252
+ this._chatOpened.emit(chatWidget);
253
+ return chatWidget;
158
254
  }
159
255
 
160
256
  /**
161
257
  * Invoke the update of the list of available chats.
162
258
  */
163
- updateChatList() {
259
+ updateChatList = (): void => {
164
260
  this._updateChatListDebouncer.invoke();
165
- }
261
+ };
166
262
 
167
263
  /**
168
264
  * Update the list of available chats.
@@ -170,24 +266,33 @@ export class MultiChatPanel extends SidePanel {
170
266
  private _updateChatList = async (): Promise<void> => {
171
267
  try {
172
268
  const chatNames = await this._getChatNames?.();
173
- this._chatNamesChanged.emit(chatNames ?? {});
269
+ if (
270
+ !ArrayExt.shallowEqual(
271
+ Object.keys(chatNames ?? {}),
272
+ Object.keys(this._chatNames)
273
+ )
274
+ ) {
275
+ this._chatNames = chatNames ?? {};
276
+ this._chatSelectorPopup?.updateChats(Object.keys(this._chatNames));
277
+ }
174
278
  } catch (e) {
175
279
  console.error('Error getting chat files', e);
176
280
  }
177
281
  };
178
282
 
179
283
  /**
180
- * Open a chat if it exists in the side panel.
284
+ * Open a chat if its model is already loaded.
181
285
  *
182
286
  * @param name - the name of the chat.
183
- * @returns a boolean, whether the chat existed in the side panel or not.
287
+ * @returns a boolean, whether the chat model was already loaded or not.
184
288
  */
185
- openIfExists(name: string): boolean {
186
- const index = this._getChatIndex(name);
187
- if (index > -1) {
188
- this._expandChat(index);
289
+ openIfLoaded(name: string): boolean {
290
+ const model = this._loadedModels.get(name);
291
+ if (model) {
292
+ this._open(name);
293
+ return true;
189
294
  }
190
- return index > -1;
295
+ return false;
191
296
  }
192
297
 
193
298
  /**
@@ -195,74 +300,95 @@ export class MultiChatPanel extends SidePanel {
195
300
  */
196
301
  protected onAfterAttach(): void {
197
302
  this._openChatWidget?.renderPromise?.then(() => this.updateChatList());
303
+
304
+ // Attach the popup to the document body
305
+ if (this._chatSelectorPopup && !this._chatSelectorPopup.isAttached) {
306
+ Widget.attach(this._chatSelectorPopup, document.body);
307
+ }
198
308
  }
199
309
 
200
310
  /**
201
- * Return the index of the chat in the list (-1 if not opened).
202
- *
203
- * @param name - the chat name.
311
+ * A message handler invoked on an `'before-detach'` message.
204
312
  */
205
- private _getChatIndex(name: string) {
206
- return this.sections.findIndex(section => section.model?.name === name);
313
+ protected onBeforeDetach(): void {
314
+ // Detach the popup
315
+ if (this._chatSelectorPopup && this._chatSelectorPopup.isAttached) {
316
+ Widget.detach(this._chatSelectorPopup);
317
+ }
207
318
  }
208
319
 
209
320
  /**
210
- * Expand the chat from its index.
321
+ * Dispose of the resources held by the widget.
211
322
  */
212
- private _expandChat(index: number): void {
213
- if (!this.widgets[index].isVisible) {
214
- (this.content as AccordionPanel).expand(index);
323
+ dispose(): void {
324
+ // Dispose all loaded models
325
+ for (const model of this._loadedModels.values()) {
326
+ model.dispose();
215
327
  }
328
+ this._loadedModels.clear();
329
+
330
+ if (this._chatSelectorPopup) {
331
+ this._chatSelectorPopup.dispose();
332
+ this._chatSelectorPopup = undefined;
333
+ }
334
+ super.dispose();
216
335
  }
217
336
 
218
337
  /**
219
- * Handle `change` events for the HTMLSelect component.
338
+ * Update loaded model when the current widget updates its name.
220
339
  */
221
- private async _chatSelected(
222
- event: React.ChangeEvent<HTMLSelectElement>
223
- ): Promise<void> {
224
- const selection = event.target.value;
225
- if (selection === '-') {
226
- return;
227
- }
228
- if (this._createModel) {
229
- const addChatArgs = await this._createModel(selection);
230
- this.addChat(addChatArgs);
340
+ private _modelNameChanged = (
341
+ _: SidePanelWidget,
342
+ change: { old: string; new: string }
343
+ ) => {
344
+ const model = this.getLoadedModel(change.old);
345
+ if (model) {
346
+ this._loadedModels.set(change.new, model);
347
+ this._loadedModels.delete(change.old);
348
+ this._chatSelectorPopup?.setLoadedModels(this.getLoadedModelNames());
349
+ if (this._currentWidget?.model.name === model.name) {
350
+ this._chatSelectorPopup?.setCurrentChat(change.new);
351
+ }
231
352
  }
232
- event.target.selectedIndex = 0;
233
- }
353
+ };
234
354
 
235
355
  /**
236
- * Triggered when a section is toggled. If the section is opened, all others
237
- * sections are closed.
356
+ * Handle chat selection from the popup.
238
357
  */
239
- private _onExpansionToggled(panel: AccordionPanel, index: number) {
240
- if (!this.widgets[index].isVisible) {
241
- return;
358
+ private _onSelectChat = async (name: string): Promise<void> => {
359
+ // Check if model is already loaded
360
+ let openChatArgs: MultiChatPanel.IOpenChatArgs = {
361
+ model: this.getLoadedModel(name),
362
+ displayName: name
363
+ };
364
+ // If not, create the model.
365
+ if (!openChatArgs.model && this._createModel) {
366
+ const chatID = this._chatNames[name];
367
+ openChatArgs = await this._createModel(chatID);
242
368
  }
243
- for (let i = 0; i < this.widgets.length; i++) {
244
- if (i !== index) {
245
- panel.collapse(i);
246
- }
369
+ if (openChatArgs.model) {
370
+ this.open(openChatArgs);
247
371
  }
248
- }
372
+ this._chatSelectorPopup?.hide();
373
+ };
249
374
 
250
- private _chatNamesChanged = new Signal<this, { [name: string]: string }>(
251
- this
252
- );
253
- private _sectionAdded = new Signal<MultiChatPanel, ChatSection>(this);
375
+ private _chatOpened = new Signal<MultiChatPanel, ChatWidget>(this);
254
376
  private _chatOptions: Omit<Chat.IOptions, 'model' | 'inputToolbarRegistry'>;
255
377
  private _inputToolbarFactory?: IInputToolbarRegistryFactory;
256
378
  private _updateChatListDebouncer: Debouncer;
257
379
 
258
380
  private _createModel?: (
259
381
  name?: string
260
- ) => Promise<MultiChatPanel.IAddChatArgs>;
382
+ ) => Promise<MultiChatPanel.IOpenChatArgs>;
261
383
  private _getChatNames?: () => Promise<{ [name: string]: string }>;
262
384
  private _openInMain?: (name: string) => Promise<boolean>;
263
- private _renameChat?: (oldName: string, newName: string) => Promise<boolean>;
264
-
385
+ private _renameChat?: boolean | ((oldName: string) => Promise<string | null>);
265
386
  private _openChatWidget?: ReactWidget;
387
+ private _chatSelectorPopup?: ChatSelectorPopup;
388
+ private _loadedModels: Map<string, IChatModel> = new Map();
389
+ private _currentWidget?: SidePanelWidget;
390
+ private _chatNames: { [name: string]: string } = {};
391
+ private _visibilityChanged = new Signal<MultiChatPanel, boolean>(this);
266
392
  }
267
393
 
268
394
  /**
@@ -273,7 +399,7 @@ export namespace MultiChatPanel {
273
399
  * Options of the constructor of the chat panel.
274
400
  */
275
401
  export interface IOptions
276
- extends SidePanel.IOptions,
402
+ extends PanelWithToolbar.IOptions,
277
403
  Omit<Chat.IOptions, 'model' | 'inputToolbarRegistry'> {
278
404
  /**
279
405
  * The input toolbar factory;
@@ -283,13 +409,13 @@ export namespace MultiChatPanel {
283
409
  * An optional callback to create a chat model.
284
410
  *
285
411
  * @param name - the name of the chat, optional.
286
- * @return an object that can be passed to add a chat section.
412
+ * @return an object that can be passed to open the chat.
287
413
  */
288
- createModel?: (name?: string) => Promise<IAddChatArgs>;
414
+ createModel?: (name?: string) => Promise<IOpenChatArgs>;
289
415
  /**
290
416
  * An optional callback to get the list of existing chats.
291
417
  *
292
- * @returns an object with display name as key and the "full" name as value.
418
+ * @returns an object mapping chat display names to identifiers.
293
419
  */
294
420
  getChatNames?: () => Promise<{ [name: string]: string }>;
295
421
  /**
@@ -305,42 +431,50 @@ export namespace MultiChatPanel {
305
431
  * @param newName - the new name of the chat.
306
432
  * @returns - a boolean, whether the chat has been renamed or not.
307
433
  */
308
- renameChat?: (oldName: string, newName: string) => Promise<boolean>;
434
+ renameChat?: boolean | ((oldName: string) => Promise<string | null>);
309
435
  }
310
436
  /**
311
437
  * The options for the add chat method.
312
438
  */
313
- export interface IAddChatArgs {
439
+ export interface IOpenChatArgs {
314
440
  /**
315
441
  * The model of the chat.
316
- * No-op id undefined.
442
+ * No-op if undefined.
317
443
  */
318
444
  model?: IChatModel;
319
445
  /**
320
- * The display name of the chat in the section title.
446
+ * The display name of the chat, shown in the toolbar.
321
447
  */
322
448
  displayName?: string;
323
449
  }
324
450
  }
325
451
 
326
452
  /**
327
- * The chat section containing a chat widget.
453
+ * A widget containing the chat and its toolbar.
328
454
  */
329
- export class ChatSection extends PanelWithToolbar {
330
- /**
331
- * Constructor of the chat section.
332
- */
333
- constructor(options: ChatSection.IOptions) {
334
- super(options);
455
+ class SidePanelWidget extends PanelWithToolbar {
456
+ constructor(options: SidePanelWidget.IOptions) {
457
+ super();
335
458
  this._chatWidget = options.widget;
336
- this.addWidget(this._chatWidget);
337
- this.addWidget(this._spinner);
338
- this.addClass(SECTION_CLASS);
459
+ this._displayName = options.displayName ?? options.widget.model.name;
460
+
461
+ this.addClass(SIDEPANEL_WIDGET_CLASS);
339
462
  this.toolbar.addClass(TOOLBAR_CLASS);
340
- this._displayName =
341
- options.displayName ?? options.widget.model.name ?? 'Chat';
342
463
  this._updateTitle();
343
464
 
465
+ this.addWidget(this.toolbar);
466
+
467
+ // Add spinner while loading
468
+ const spinner = new Spinner();
469
+ this.addWidget(spinner);
470
+ this._chatWidget.model.ready.then(() => {
471
+ spinner.dispose();
472
+ });
473
+
474
+ // Add the chat widget
475
+ this.addWidget(this._chatWidget);
476
+
477
+ // Add toolbar buttons
344
478
  this._markAsRead = new ToolbarButton({
345
479
  icon: readIcon,
346
480
  iconLabel: 'Mark chat as read',
@@ -359,22 +493,30 @@ export class ChatSection extends PanelWithToolbar {
359
493
  iconLabel: 'Rename chat',
360
494
  className: 'jp-mod-styled',
361
495
  onClick: async () => {
362
- const oldName = this.model.name ?? 'Chat';
363
- const result = await InputDialog.getText({
364
- title: 'Rename Chat',
365
- text: this.model.name,
366
- placeholder: 'new-name'
367
- });
368
- if (!result.button.accept) {
369
- return; // user cancelled
496
+ if (!options.renameChat) {
497
+ return;
370
498
  }
371
- const newName = result.value;
372
- if (this.model && newName && newName !== oldName) {
373
- if (await options.renameChat?.(oldName, newName)) {
499
+ let newName: string | null;
500
+ if (options.renameChat === true) {
501
+ // If rename chat is true, let's provide a input to select new name.
502
+ const result = await InputDialog.getText({
503
+ title: 'Rename Chat',
504
+ text: this.model.name,
505
+ placeholder: 'new-name'
506
+ });
507
+ if (!result.button.accept && result.value) {
508
+ return;
509
+ }
510
+ newName = result.value;
511
+ if (newName) {
374
512
  this.model.name = newName;
375
- this._displayName = newName;
376
- this._updateTitle();
377
513
  }
514
+ } else {
515
+ // Rename chat is a function, let's call it.
516
+ newName = await options.renameChat(this.model.name);
517
+ }
518
+ if (newName) {
519
+ this.name = newName;
378
520
  }
379
521
  }
380
522
  });
@@ -389,8 +531,7 @@ export class ChatSection extends PanelWithToolbar {
389
531
  onClick: async () => {
390
532
  const name = this.model.name;
391
533
  if (await options.openInMain?.(name)) {
392
- this.model.dispose();
393
- this.dispose();
534
+ options.onClose(this._displayName);
394
535
  }
395
536
  }
396
537
  });
@@ -402,49 +543,51 @@ export class ChatSection extends PanelWithToolbar {
402
543
  iconLabel: 'Close the chat',
403
544
  className: 'jp-mod-styled',
404
545
  onClick: () => {
405
- this.model.dispose();
406
- this.dispose();
546
+ options.onClose(this._displayName);
407
547
  }
408
548
  });
409
-
410
549
  this.toolbar.addItem('close', closeButton);
411
550
 
551
+ // Update mark as read button state
412
552
  this.model.unreadChanged?.connect(this._unreadChanged);
413
553
  this._markAsRead.enabled = (this.model?.unreadMessages.length ?? 0) > 0;
554
+ }
414
555
 
415
- options.widget.node.style.height = '100%';
556
+ /**
557
+ * The chat widget embedded in the sidepanel widget.
558
+ */
559
+ get widget(): ChatWidget {
560
+ return this._chatWidget;
561
+ }
416
562
 
417
- /**
418
- * Remove the spinner when the chat is ready.
419
- */
420
- this.model.ready.then(() => {
421
- this._spinner.dispose();
422
- });
563
+ /**
564
+ * The model of the widget.
565
+ */
566
+ get model(): IChatModel {
567
+ return this._chatWidget.model;
423
568
  }
424
569
 
425
570
  /**
426
- * The display name.
571
+ * The displayed name of the widget.
427
572
  */
428
- get displayName(): string {
573
+ get name(): string {
429
574
  return this._displayName;
430
575
  }
431
- set displayName(value: string) {
576
+ set name(value: string) {
577
+ const old = this._displayName;
578
+ if (old === value) {
579
+ return;
580
+ }
432
581
  this._displayName = value;
433
582
  this._updateTitle();
583
+ this._nameChanged.emit({ old, new: value });
434
584
  }
435
585
 
436
586
  /**
437
- * The chat widget of the section.
438
- */
439
- get widget(): ChatWidget {
440
- return this._chatWidget;
441
- }
442
-
443
- /**
444
- * The model of the widget.
587
+ * A signal emitting when the name has changed.
445
588
  */
446
- get model(): IChatModel {
447
- return this._chatWidget.model;
589
+ get nameChanged(): ISignal<SidePanelWidget, { old: string; new: string }> {
590
+ return this._nameChanged;
448
591
  }
449
592
 
450
593
  /**
@@ -459,21 +602,29 @@ export class ChatSection extends PanelWithToolbar {
459
602
  }
460
603
 
461
604
  /**
462
- * * Update the section’s title based on the chat name.
463
- * */
464
-
605
+ * Update the title based on the chat name.
606
+ */
465
607
  private _updateTitle(): void {
466
- this.title.label = this._displayName;
608
+ this.title.label = this.model.name;
467
609
  this.title.caption = this._displayName;
610
+
611
+ const titleElement = document.createElement('span');
612
+ titleElement.classList.add('jp-chat-sidepanel-widget-title');
613
+ titleElement.title = this._displayName;
614
+ titleElement.textContent = this._displayName;
615
+
616
+ // Dispose of the previous widget.
617
+ if (this._titleWidget) {
618
+ this._titleWidget.dispose();
619
+ }
620
+
621
+ // Insert the new title widget in toolbar.
622
+ this._titleWidget = new Widget({ node: titleElement });
623
+ this.toolbar.insertItem(0, 'title', this._titleWidget);
468
624
  }
469
625
 
470
626
  /**
471
- * Change the title when messages are unread.
472
- *
473
- * TODO: fix it upstream in @jupyterlab/ui-components.
474
- * Updating the title create a new Title widget, but does not attach again the
475
- * toolbar. The toolbar is attached only when the title widget is attached the first
476
- * time.
627
+ * Enable/disable unread icon.
477
628
  */
478
629
  private _unreadChanged = (_: IChatModel, unread: number[]) => {
479
630
  this._markAsRead.enabled = unread.length > 0;
@@ -481,82 +632,158 @@ export class ChatSection extends PanelWithToolbar {
481
632
 
482
633
  private _chatWidget: ChatWidget;
483
634
  private _markAsRead: ToolbarButton;
484
- private _spinner = new Spinner();
485
635
  private _displayName: string;
636
+ private _titleWidget: Widget | undefined;
637
+ private _nameChanged = new Signal<
638
+ SidePanelWidget,
639
+ { old: string; new: string }
640
+ >(this);
486
641
  }
487
642
 
488
643
  /**
489
- * The chat section namespace.
644
+ * The sidepanel widget namespace.
490
645
  */
491
- export namespace ChatSection {
646
+ namespace SidePanelWidget {
492
647
  /**
493
- * Options to build a chat section.
648
+ * The sidepanel widget constructor options.
494
649
  */
495
- export interface IOptions extends Panel.IOptions {
650
+ export interface IOptions {
496
651
  /**
497
- * The widget to display in the section.
652
+ * The chat widget.
498
653
  */
499
654
  widget: ChatWidget;
500
655
  /**
501
- * An optional callback to open the chat in the main area.
502
- *
503
- * @param name - the name of the chat to move.
656
+ * The callback when closing the chat.
504
657
  */
505
- openInMain?: (name: string) => Promise<boolean>;
658
+ onClose: (name: string) => void;
506
659
  /**
507
- * An optional callback to rename a chat.
508
- *
509
- * @param oldName - the old name of the chat.
510
- * @param newName - the new name of the chat.
511
- * @returns - a boolean, whether the chat has been renamed or not.
660
+ * The displayed name of the chat.
512
661
  */
513
- renameChat?: (oldName: string, newName: string) => Promise<boolean>;
662
+ displayName?: string;
514
663
  /**
515
- * The name to display in the section title.
664
+ * The callback to open the chat in main area.
516
665
  */
517
- displayName?: string;
666
+ openInMain?: (name: string) => Promise<boolean>;
667
+ /**
668
+ * The callback to rename the chat.
669
+ */
670
+ renameChat?: boolean | ((oldName: string) => Promise<string | null>);
518
671
  }
519
672
  }
520
673
 
521
- type ChatSelectProps = {
674
+ type ChatSearchInputProps = {
522
675
  /**
523
- * A signal emitting when the list of chat changed.
676
+ * The callback to call when a chat is selected.
524
677
  */
525
- chatNamesChanged: ISignal<MultiChatPanel, { [name: string]: string }>;
678
+ selectChat: (value: string) => void;
526
679
  /**
527
- * The callback to call when the selection changed in the select.
680
+ * Function to get the popup widget.
528
681
  */
529
- handleChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
682
+ getPopup: () => ChatSelectorPopup | undefined;
683
+ /**
684
+ * Signal emitting when a chat is opened.
685
+ */
686
+ chatOpened: ISignal<MultiChatPanel, ChatWidget>;
530
687
  };
531
688
 
532
689
  /**
533
- * A component to select a chat from the drive.
690
+ * A search input component for selecting a chat.
534
691
  */
535
- function ChatSelect({
536
- chatNamesChanged,
537
- handleChange
538
- }: ChatSelectProps): JSX.Element {
539
- // An object associating a chat name to its path. Both are purely indicative, the name
540
- // is the section title and the path is used as caption.
541
- const [chatNames, setChatNames] = useState<{ [name: string]: string }>({});
542
-
543
- // Update the chat list.
544
- chatNamesChanged.connect((_, chatNames) => {
545
- setChatNames(chatNames);
546
- });
692
+ function ChatSearchInput({
693
+ selectChat,
694
+ getPopup,
695
+ chatOpened
696
+ }: ChatSearchInputProps): JSX.Element {
697
+ const [query, setQuery] = useState<string>('');
698
+ const inputRef = useRef<HTMLInputElement>(null);
699
+
700
+ useEffect(() => {
701
+ const resetQuery = () => {
702
+ setQuery('');
703
+ };
704
+ chatOpened.connect(resetQuery);
705
+
706
+ return () => {
707
+ chatOpened.disconnect(resetQuery);
708
+ };
709
+ }, [chatOpened]);
710
+
711
+ const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
712
+ const value = event.target.value;
713
+ setQuery(value);
714
+ const popup = getPopup();
715
+ if (popup) {
716
+ popup.setQuery(value);
717
+ if (!popup.isVisible && value) {
718
+ popup.show();
719
+ }
720
+ }
721
+ };
722
+
723
+ const handleInputFocus = () => {
724
+ const popup = getPopup();
725
+ if (popup && inputRef.current) {
726
+ // Set anchor element before showing
727
+ popup.anchor = inputRef.current;
728
+ popup.setQuery(query);
729
+ popup.show();
730
+ }
731
+ };
732
+
733
+ const handleInputClick = () => {
734
+ const popup = getPopup();
735
+ if (popup && inputRef.current && !popup.isVisible) {
736
+ popup.anchor = inputRef.current;
737
+ popup.setQuery(query);
738
+ popup.show();
739
+ }
740
+ // Force focus on input.
741
+ inputRef.current?.focus();
742
+ };
743
+
744
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
745
+ const popup = getPopup();
746
+ if (!popup || !popup.isVisible) {
747
+ return;
748
+ }
749
+
750
+ let value: string | null;
751
+ switch (event.key) {
752
+ case 'ArrowDown':
753
+ event.preventDefault();
754
+ popup.selectNext();
755
+ break;
756
+ case 'ArrowUp':
757
+ event.preventDefault();
758
+ popup.selectPrevious();
759
+ break;
760
+ case 'Enter':
761
+ event.preventDefault();
762
+ value = popup.getSelectedValue();
763
+ if (value) {
764
+ selectChat(value);
765
+ popup.hide();
766
+ }
767
+ break;
768
+ case 'Escape':
769
+ event.preventDefault();
770
+ popup.hide();
771
+ setQuery('');
772
+ break;
773
+ }
774
+ };
547
775
 
548
776
  return (
549
- <HTMLSelect
550
- key={Object.keys(chatNames).join()}
551
- onChange={handleChange}
552
- value="-"
553
- >
554
- <option value="-" disabled hidden>
555
- Open a chat
556
- </option>
557
- {Object.keys(chatNames).map(name => (
558
- <option value={chatNames[name]}>{name}</option>
559
- ))}
560
- </HTMLSelect>
777
+ <input
778
+ ref={inputRef}
779
+ type="text"
780
+ placeholder="Select a chat"
781
+ value={query}
782
+ onChange={handleInputChange}
783
+ onFocus={handleInputFocus}
784
+ onClick={handleInputClick}
785
+ onKeyDown={handleKeyDown}
786
+ className="jp-chat-search-input"
787
+ />
561
788
  );
562
789
  }