@jupyter/chat 0.22.0 → 0.23.0-alpha.0

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.
Files changed (61) hide show
  1. package/lib/__tests__/multichat-panel.spec.js +82 -1
  2. package/lib/components/attachments.d.ts +0 -1
  3. package/lib/components/avatar.d.ts +0 -1
  4. package/lib/components/chat.d.ts +0 -1
  5. package/lib/components/chat.js +8 -9
  6. package/lib/components/code-blocks/code-toolbar.d.ts +0 -1
  7. package/lib/components/code-blocks/copy-button.d.ts +0 -1
  8. package/lib/components/input/buttons/attach-button.d.ts +0 -1
  9. package/lib/components/input/buttons/cancel-button.d.ts +0 -1
  10. package/lib/components/input/buttons/save-edit-button.d.ts +0 -1
  11. package/lib/components/input/buttons/save-edit-button.js +2 -2
  12. package/lib/components/input/buttons/send-button.d.ts +0 -1
  13. package/lib/components/input/buttons/send-button.js +3 -10
  14. package/lib/components/input/buttons/stop-button.d.ts +0 -1
  15. package/lib/components/input/chat-input.d.ts +0 -1
  16. package/lib/components/input/chat-input.js +2 -3
  17. package/lib/components/input/submit-message.d.ts +22 -0
  18. package/lib/components/input/submit-message.js +13 -0
  19. package/lib/components/messages/chat-body-placeholder.d.ts +0 -1
  20. package/lib/components/messages/footer.d.ts +0 -1
  21. package/lib/components/messages/header.d.ts +3 -2
  22. package/lib/components/messages/header.js +2 -1
  23. package/lib/components/messages/message.d.ts +3 -6
  24. package/lib/components/messages/message.js +26 -14
  25. package/lib/components/messages/messages.d.ts +0 -1
  26. package/lib/components/messages/messages.js +11 -20
  27. package/lib/components/messages/preamble.d.ts +0 -1
  28. package/lib/components/messages/toolbar.d.ts +0 -1
  29. package/lib/components/messages/welcome.d.ts +0 -1
  30. package/lib/components/mui-extras/tooltipped-icon-button.d.ts +0 -1
  31. package/lib/components/writing-indicator.d.ts +0 -1
  32. package/lib/context.d.ts +0 -1
  33. package/lib/message.d.ts +6 -0
  34. package/lib/message.js +23 -0
  35. package/lib/model.d.ts +8 -1
  36. package/lib/model.js +3 -0
  37. package/lib/registers/chat-commands.d.ts +0 -1
  38. package/lib/registers/footers.d.ts +0 -1
  39. package/lib/registers/preambles.d.ts +0 -1
  40. package/lib/tokens.d.ts +0 -1
  41. package/lib/types.d.ts +5 -0
  42. package/lib/utils.js +2 -2
  43. package/lib/widgets/chat-selector-popup.d.ts +0 -1
  44. package/lib/widgets/multichat-panel.d.ts +21 -5
  45. package/lib/widgets/multichat-panel.js +50 -10
  46. package/package.json +22 -22
  47. package/src/__tests__/multichat-panel.spec.ts +113 -1
  48. package/src/components/chat.tsx +10 -10
  49. package/src/components/input/buttons/save-edit-button.tsx +2 -2
  50. package/src/components/input/buttons/send-button.tsx +3 -13
  51. package/src/components/input/chat-input.tsx +2 -3
  52. package/src/components/input/submit-message.ts +38 -0
  53. package/src/components/messages/header.tsx +5 -1
  54. package/src/components/messages/message.tsx +129 -122
  55. package/src/components/messages/messages.tsx +14 -26
  56. package/src/message.ts +29 -0
  57. package/src/model.ts +10 -2
  58. package/src/types.ts +5 -0
  59. package/src/utils.ts +2 -2
  60. package/src/widgets/multichat-panel.tsx +62 -11
  61. package/style/chat.css +8 -4
package/lib/message.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Copyright (c) Jupyter Development Team.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
+ import { PromiseDelegate } from '@lumino/coreutils';
5
6
  import { Signal } from '@lumino/signaling';
6
7
  /**
7
8
  * The message object.
@@ -14,6 +15,7 @@ export class Message {
14
15
  */
15
16
  constructor(content) {
16
17
  this._changed = new Signal(this);
18
+ this._rendered = new PromiseDelegate();
17
19
  this._content = content;
18
20
  }
19
21
  /**
@@ -70,11 +72,32 @@ export class Message {
70
72
  get changed() {
71
73
  return this._changed;
72
74
  }
75
+ /**
76
+ * A promise delegate that resolve when the message is rendered.
77
+ */
78
+ get renderedDelegate() {
79
+ return this._rendered;
80
+ }
73
81
  /**
74
82
  * Update one or several fields of the message.
75
83
  */
76
84
  update(updated) {
85
+ // Check if the message will be rendered again.
86
+ const triggerRerender = new Set([
87
+ 'body',
88
+ 'deleted',
89
+ 'mentions',
90
+ 'mime_model'
91
+ ]);
92
+ const updatedAttributes = Object.keys(updated);
93
+ const shouldRerender = updatedAttributes.some(attribute => triggerRerender.has(attribute) &&
94
+ updated[attribute] !== this._content[attribute]);
95
+ // Update the message content.
77
96
  this._content = { ...this._content, ...updated };
97
+ // Update the promise delegate if the message will be rendered again.
98
+ if (shouldRerender) {
99
+ this._rendered = new PromiseDelegate();
100
+ }
78
101
  this._changed.emit();
79
102
  }
80
103
  }
package/lib/model.d.ts CHANGED
@@ -124,6 +124,10 @@ export interface IChatModel extends IDisposable {
124
124
  * Whether the chat handler is disposed.
125
125
  */
126
126
  isDisposed: boolean;
127
+ /**
128
+ * A signal emitting when disposing of the model.
129
+ */
130
+ readonly disposed: ISignal<IChatModel, void>;
127
131
  /**
128
132
  * Function to call when a message is received.
129
133
  *
@@ -186,7 +190,10 @@ export declare abstract class AbstractChatModel implements IChatModel {
186
190
  */
187
191
  get name(): string;
188
192
  set name(value: string);
189
- get disposed(): ISignal<AbstractChatModel, void>;
193
+ /**
194
+ * A signal emitting when disposing of the model.
195
+ */
196
+ get disposed(): ISignal<IChatModel, void>;
190
197
  /**
191
198
  * The chat messages list.
192
199
  */
package/lib/model.js CHANGED
@@ -92,6 +92,9 @@ export class AbstractChatModel {
92
92
  set name(value) {
93
93
  this._name = value;
94
94
  }
95
+ /**
96
+ * A signal emitting when disposing of the model.
97
+ */
95
98
  get disposed() {
96
99
  return this._disposed;
97
100
  }
@@ -1,4 +1,3 @@
1
- /// <reference types="react" />
2
1
  import { LabIcon } from '@jupyterlab/ui-components';
3
2
  import { Token } from '@lumino/coreutils';
4
3
  import { IInputModel } from '../input-model';
@@ -1,4 +1,3 @@
1
- /// <reference types="react" />
2
1
  import { Token } from '@lumino/coreutils';
3
2
  import { IChatModel } from '../model';
4
3
  import { IMessageContent } from '../types';
@@ -1,4 +1,3 @@
1
- /// <reference types="react" />
2
1
  import { Token } from '@lumino/coreutils';
3
2
  import { IChatModel } from '../model';
4
3
  import { IMessageContent } from '../types';
package/lib/tokens.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- /// <reference types="react" />
2
1
  import { IWidgetTracker, MainAreaWidget } from '@jupyterlab/apputils';
3
2
  import { Token } from '@lumino/coreutils';
4
3
  import { Widget } from '@lumino/widgets';
package/lib/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { PromiseDelegate } from '@lumino/coreutils';
1
2
  import { ISignal } from '@lumino/signaling';
2
3
  import { IRenderMime } from '@jupyterlab/rendermime';
3
4
  /**
@@ -146,6 +147,10 @@ export interface IMessage extends IMessageContent {
146
147
  * A signal emitting when the message has been updated.
147
148
  */
148
149
  changed: ISignal<IMessage, void>;
150
+ /**
151
+ * A promise delegate that resolve when the message is rendered.
152
+ */
153
+ renderedDelegate: PromiseDelegate<void>;
149
154
  }
150
155
  /**
151
156
  * The chat history interface.
package/lib/utils.js CHANGED
@@ -48,7 +48,7 @@ export function replaceMentionToSpan(content, user) {
48
48
  }
49
49
  const mention = '@' + user.mention_name;
50
50
  const regex = new RegExp(mention, 'g');
51
- const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
51
+ const mentionEl = `<span class="${MENTION_CLASS}"> ${mention} </span>`;
52
52
  return content.replace(regex, mentionEl);
53
53
  }
54
54
  /**
@@ -62,7 +62,7 @@ export function replaceSpanToMention(content, user) {
62
62
  return content;
63
63
  }
64
64
  const mention = '@' + user.mention_name;
65
- const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
65
+ const mentionEl = `<span class="${MENTION_CLASS}"> ${mention} </span>`;
66
66
  const regex = new RegExp(mentionEl, 'g');
67
67
  return content.replace(regex, mention);
68
68
  }
@@ -1,4 +1,3 @@
1
- /// <reference types="react" />
2
1
  import { ITranslator } from '@jupyterlab/translation';
3
2
  import { ReactWidget } from '@jupyterlab/ui-components';
4
3
  import { Message } from '@lumino/messaging';
@@ -1,11 +1,20 @@
1
1
  import { TranslationBundle } from '@jupyterlab/translation';
2
- import { PanelWithToolbar } from '@jupyterlab/ui-components';
2
+ import { PanelWithToolbar, ReactiveToolbar } from '@jupyterlab/ui-components';
3
3
  import { Message } from '@lumino/messaging';
4
4
  import { ISignal } from '@lumino/signaling';
5
+ import { Panel, Widget } from '@lumino/widgets';
5
6
  import { ChatWidget } from './chat-widget';
6
7
  import { Chat, IInputToolbarRegistryFactory } from '../components';
7
8
  import { IChatModel } from '../model';
8
9
  import { IChatPlaceholderFactory } from '../tokens';
10
+ /**
11
+ * A panel widget with a reactive toolbar.
12
+ */
13
+ declare class ReactivePanelWithToolbar extends Panel {
14
+ constructor();
15
+ get toolbar(): ReactiveToolbar;
16
+ private _toolbar;
17
+ }
9
18
  /**
10
19
  * Generic sidepanel widget including multiple chats and the add chat button.
11
20
  */
@@ -41,9 +50,10 @@ export declare class MultiChatPanel extends PanelWithToolbar {
41
50
  */
42
51
  getLoadedModelNames(): string[];
43
52
  /**
44
- * Dispose a model, removing it from loaded models.
53
+ * Removing a model from loaded list.
54
+ * Dispose of the widget and optionally dispose of the model.
45
55
  */
46
- disposeLoadedModel(name: string): void;
56
+ unsetLoadedModel(name: string, dispose?: boolean): void;
47
57
  /**
48
58
  * Emit a signal when the panel visibility changed.
49
59
  */
@@ -178,8 +188,10 @@ export declare namespace MultiChatPanel {
178
188
  /**
179
189
  * A widget containing the chat and its toolbar.
180
190
  */
181
- declare class SidePanelWidget extends PanelWithToolbar {
191
+ declare class SidePanelWidget extends ReactivePanelWithToolbar {
182
192
  constructor(options: SidePanelWidget.IOptions);
193
+ protected onAfterAttach(msg: Message): void;
194
+ protected onResize(msg: Widget.ResizeMessage): void;
183
195
  /**
184
196
  * The chat widget embedded in the sidepanel widget.
185
197
  */
@@ -212,6 +224,10 @@ declare class SidePanelWidget extends PanelWithToolbar {
212
224
  * Enable/disable unread icon.
213
225
  */
214
226
  private _unreadChanged;
227
+ /**
228
+ * Trigger reactive toolbar overflow computation from rendered toolbar size.
229
+ */
230
+ private _updateReactiveToolbar;
215
231
  private _chatWidget;
216
232
  private _markAsRead;
217
233
  private _displayName;
@@ -233,7 +249,7 @@ declare namespace SidePanelWidget {
233
249
  /**
234
250
  * The callback when closing the chat.
235
251
  */
236
- onClose: (name: string) => void;
252
+ onClose: (name: string, disposeModel?: boolean) => void;
237
253
  /**
238
254
  * The displayed name of the chat.
239
255
  */
@@ -8,11 +8,12 @@
8
8
  */
9
9
  import { InputDialog } from '@jupyterlab/apputils';
10
10
  import { nullTranslator } from '@jupyterlab/translation';
11
- import { addIcon, closeIcon, launchIcon, PanelWithToolbar, ReactWidget, Spinner, ToolbarButton } from '@jupyterlab/ui-components';
11
+ import { addIcon, closeIcon, launchIcon, PanelWithToolbar, ReactiveToolbar, ReactWidget, Spinner, Toolbar, ToolbarButton } from '@jupyterlab/ui-components';
12
12
  import { ArrayExt } from '@lumino/algorithm';
13
+ import { MessageLoop } from '@lumino/messaging';
13
14
  import { Debouncer } from '@lumino/polling';
14
15
  import { Signal } from '@lumino/signaling';
15
- import { Widget } from '@lumino/widgets';
16
+ import { Panel, Widget } from '@lumino/widgets';
16
17
  import React, { useEffect, useRef, useState } from 'react';
17
18
  import { ChatSelectorPopup } from './chat-selector-popup';
18
19
  import { ChatWidget } from './chat-widget';
@@ -23,7 +24,21 @@ const SIDEPANEL_CLASS = 'jp-chat-sidepanel';
23
24
  const ADD_BUTTON_CLASS = 'jp-chat-add';
24
25
  const OPEN_SELECT_CLASS = 'jp-chat-open';
25
26
  const SIDEPANEL_WIDGET_CLASS = 'jp-chat-sidepanel-widget';
27
+ // TODO: Drop this workaround class (and related CSS) once we depend on
28
+ // @jupyterlab/ui-components >= 4.6.0 (refs - jupyterlab/jupyterlab#18824).
26
29
  const TOOLBAR_CLASS = 'jp-chat-sidepanel-widget-toolbar';
30
+ /**
31
+ * A panel widget with a reactive toolbar.
32
+ */
33
+ class ReactivePanelWithToolbar extends Panel {
34
+ constructor() {
35
+ super();
36
+ this._toolbar = new ReactiveToolbar({ noFocusOnClick: true });
37
+ }
38
+ get toolbar() {
39
+ return this._toolbar;
40
+ }
41
+ }
27
42
  /**
28
43
  * Generic sidepanel widget including multiple chats and the add chat button.
29
44
  */
@@ -133,7 +148,7 @@ export class MultiChatPanel extends PanelWithToolbar {
133
148
  chatNames: [],
134
149
  onSelect: this._onSelectChat,
135
150
  onClose: (name) => {
136
- this.disposeLoadedModel(name);
151
+ this.unsetLoadedModel(name);
137
152
  },
138
153
  translator
139
154
  });
@@ -197,9 +212,10 @@ export class MultiChatPanel extends PanelWithToolbar {
197
212
  return Array.from(this._loadedModels.keys());
198
213
  }
199
214
  /**
200
- * Dispose a model, removing it from loaded models.
215
+ * Removing a model from loaded list.
216
+ * Dispose of the widget and optionally dispose of the model.
201
217
  */
202
- disposeLoadedModel(name) {
218
+ unsetLoadedModel(name, dispose = true) {
203
219
  var _a, _b;
204
220
  const model = this._loadedModels.get(name);
205
221
  if (model) {
@@ -214,7 +230,9 @@ export class MultiChatPanel extends PanelWithToolbar {
214
230
  }
215
231
  this._addPlaceholder();
216
232
  }
217
- model.dispose();
233
+ if (dispose) {
234
+ model.dispose();
235
+ }
218
236
  this._loadedModels.delete(name);
219
237
  (_b = this._chatSelectorPopup) === null || _b === void 0 ? void 0 : _b.setLoadedModels(this.getLoadedModelNames());
220
238
  }
@@ -282,8 +300,8 @@ export class MultiChatPanel extends PanelWithToolbar {
282
300
  displayName: name,
283
301
  openInMain: this._openInMain,
284
302
  renameChat: this._renameChat,
285
- onClose: (name) => {
286
- this.disposeLoadedModel(name);
303
+ onClose: (name, disposeModel = true) => {
304
+ this.unsetLoadedModel(name, disposeModel);
287
305
  },
288
306
  trans: this._trans
289
307
  });
@@ -352,7 +370,7 @@ export class MultiChatPanel extends PanelWithToolbar {
352
370
  /**
353
371
  * A widget containing the chat and its toolbar.
354
372
  */
355
- class SidePanelWidget extends PanelWithToolbar {
373
+ class SidePanelWidget extends ReactivePanelWithToolbar {
356
374
  constructor(options) {
357
375
  var _a, _b, _c, _d;
358
376
  super();
@@ -367,8 +385,11 @@ class SidePanelWidget extends PanelWithToolbar {
367
385
  this._displayName = (_a = options.displayName) !== null && _a !== void 0 ? _a : options.widget.model.name;
368
386
  const trans = options.trans;
369
387
  this.addClass(SIDEPANEL_WIDGET_CLASS);
388
+ // Keep side-panel toolbar baseline sizing from ui-components.
389
+ this.toolbar.addClass('jp-SidePanel-toolbar');
370
390
  this.toolbar.addClass(TOOLBAR_CLASS);
371
391
  this._updateTitle();
392
+ this.toolbar.addItem('titleSpacer', Toolbar.createSpacerItem());
372
393
  this.addWidget(this.toolbar);
373
394
  // Add spinner while loading
374
395
  const spinner = new Spinner();
@@ -435,7 +456,7 @@ class SidePanelWidget extends PanelWithToolbar {
435
456
  var _a;
436
457
  const name = this.model.name;
437
458
  if (await ((_a = options.openInMain) === null || _a === void 0 ? void 0 : _a.call(options, name))) {
438
- options.onClose(this._displayName);
459
+ options.onClose(this._displayName, false);
439
460
  }
440
461
  }
441
462
  });
@@ -454,6 +475,14 @@ class SidePanelWidget extends PanelWithToolbar {
454
475
  (_b = this.model.unreadChanged) === null || _b === void 0 ? void 0 : _b.connect(this._unreadChanged);
455
476
  this._markAsRead.enabled = ((_d = (_c = this.model) === null || _c === void 0 ? void 0 : _c.unreadMessages.length) !== null && _d !== void 0 ? _d : 0) > 0;
456
477
  }
478
+ onAfterAttach(msg) {
479
+ super.onAfterAttach(msg);
480
+ requestAnimationFrame(() => this._updateReactiveToolbar());
481
+ }
482
+ onResize(msg) {
483
+ super.onResize(msg);
484
+ this._updateReactiveToolbar();
485
+ }
457
486
  /**
458
487
  * The chat widget embedded in the sidepanel widget.
459
488
  */
@@ -516,6 +545,17 @@ class SidePanelWidget extends PanelWithToolbar {
516
545
  this._titleWidget = new Widget({ node: titleElement });
517
546
  this.toolbar.insertItem(0, 'title', this._titleWidget);
518
547
  }
548
+ /**
549
+ * Trigger reactive toolbar overflow computation from rendered toolbar size.
550
+ */
551
+ _updateReactiveToolbar() {
552
+ const toolbarWidth = this.toolbar.node.offsetWidth;
553
+ if (toolbarWidth <= 0) {
554
+ return;
555
+ }
556
+ const toolbarHeight = this.toolbar.node.offsetHeight;
557
+ MessageLoop.sendMessage(this.toolbar, new Widget.ResizeMessage(toolbarWidth, toolbarHeight));
558
+ }
519
559
  }
520
560
  /**
521
561
  * A search input component for selecting a chat.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.22.0",
3
+ "version": "0.23.0-alpha.0",
4
4
  "description": "A package that provides UI components that can be used to create a chat in a Jupyterlab extension.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -46,25 +46,25 @@
46
46
  "@emotion/react": "^11.10.5",
47
47
  "@emotion/styled": "^11.10.5",
48
48
  "@jupyter/react-components": "^0.15.2",
49
- "@jupyterlab/application": "^4.2.0",
50
- "@jupyterlab/apputils": "^4.3.0",
51
- "@jupyterlab/codeeditor": "^4.2.0",
52
- "@jupyterlab/codemirror": "^4.2.0",
53
- "@jupyterlab/docmanager": "^4.2.0",
54
- "@jupyterlab/docregistry": "^4.2.0",
55
- "@jupyterlab/filebrowser": "^4.2.0",
56
- "@jupyterlab/fileeditor": "^4.2.0",
57
- "@jupyterlab/notebook": "^4.2.0",
58
- "@jupyterlab/rendermime": "^4.2.0",
59
- "@jupyterlab/translation": "^4.2.0",
60
- "@jupyterlab/ui-components": "^4.2.0",
61
- "@lumino/algorithm": "^2.0.0",
62
- "@lumino/commands": "^2.0.0",
63
- "@lumino/coreutils": "^2.0.0",
64
- "@lumino/disposable": "^2.0.0",
65
- "@lumino/polling": "^2.0.0",
66
- "@lumino/signaling": "^2.0.0",
67
- "@lumino/widgets": "^2.0.0",
49
+ "@jupyterlab/application": "^4.6.0",
50
+ "@jupyterlab/apputils": "^4.7.0",
51
+ "@jupyterlab/codeeditor": "^4.6.0",
52
+ "@jupyterlab/codemirror": "^4.6.0",
53
+ "@jupyterlab/docmanager": "^4.6.0",
54
+ "@jupyterlab/docregistry": "^4.6.0",
55
+ "@jupyterlab/filebrowser": "^4.6.0",
56
+ "@jupyterlab/fileeditor": "^4.6.0",
57
+ "@jupyterlab/notebook": "^4.6.0",
58
+ "@jupyterlab/rendermime": "^4.6.0",
59
+ "@jupyterlab/translation": "^4.6.0",
60
+ "@jupyterlab/ui-components": "^4.6.0",
61
+ "@lumino/algorithm": "^2.0.4",
62
+ "@lumino/commands": "^2.3.3",
63
+ "@lumino/coreutils": "^2.2.2",
64
+ "@lumino/disposable": "^2.1.5",
65
+ "@lumino/polling": "^2.1.5",
66
+ "@lumino/signaling": "^2.1.5",
67
+ "@lumino/widgets": "^2.8.0",
68
68
  "@mui/icons-material": "^7.3.2",
69
69
  "@mui/material": "^7.3.2",
70
70
  "clsx": "^2.1.0",
@@ -72,7 +72,7 @@
72
72
  "react-dom": "^18.2.0"
73
73
  },
74
74
  "devDependencies": {
75
- "@jupyterlab/testing": "^4.2.0",
75
+ "@jupyterlab/testing": "^4.6.0",
76
76
  "@types/jest": "^29.2.0",
77
77
  "@types/json-schema": "^7.0.11",
78
78
  "@types/react": "^18.2.0",
@@ -84,7 +84,7 @@
84
84
  "rimraf": "^5.0.1",
85
85
  "source-map-loader": "^1.0.2",
86
86
  "style-loader": "^3.3.1",
87
- "typescript": "~5.0.2"
87
+ "typescript": "~5.9.3"
88
88
  },
89
89
  "sideEffects": [
90
90
  "style/*.css",
@@ -3,6 +3,7 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
+ import { ToolbarButton } from '@jupyterlab/ui-components';
6
7
  import { RenderMimeRegistry } from '@jupyterlab/rendermime';
7
8
  import { Widget } from '@lumino/widgets';
8
9
 
@@ -17,6 +18,101 @@ describe('MultiChatPanel', () => {
17
18
  rmRegistry = new RenderMimeRegistry();
18
19
  });
19
20
 
21
+ describe('unsetLoadedModel', () => {
22
+ it('should dispose the model by default', async () => {
23
+ const panel = new MultiChatPanel({ rmRegistry });
24
+ const { MockChatModel } = await import('./mocks');
25
+ const model = new MockChatModel();
26
+ panel.open({ model, displayName: 'test-chat' });
27
+
28
+ panel.unsetLoadedModel('test-chat');
29
+
30
+ expect(model.isDisposed).toBe(true);
31
+ panel.dispose();
32
+ });
33
+
34
+ it('should not dispose the model when dispose=false', async () => {
35
+ const panel = new MultiChatPanel({ rmRegistry });
36
+ const { MockChatModel } = await import('./mocks');
37
+ const model = new MockChatModel();
38
+ panel.open({ model, displayName: 'test-chat' });
39
+
40
+ panel.unsetLoadedModel('test-chat', false);
41
+
42
+ expect(model.isDisposed).toBe(false);
43
+ model.dispose();
44
+ panel.dispose();
45
+ });
46
+
47
+ it('should dispose the model when clicking the close button', async () => {
48
+ const panel = new MultiChatPanel({ rmRegistry });
49
+ const { MockChatModel } = await import('./mocks');
50
+ const model = new MockChatModel();
51
+ panel.open({ model, displayName: 'test-chat' });
52
+
53
+ const toolbar = panel.current!.toolbar;
54
+ const names = Array.from(toolbar.names());
55
+ const closeIdx = names.indexOf('close');
56
+ expect(closeIdx).toBeGreaterThan(-1);
57
+
58
+ const closeButton = (toolbar.layout as any).widgets[
59
+ closeIdx
60
+ ] as ToolbarButton;
61
+
62
+ closeButton.onClick();
63
+
64
+ expect(model.isDisposed).toBe(true);
65
+ panel.dispose();
66
+ });
67
+
68
+ it('should remove the model from loaded models regardless of dispose flag', async () => {
69
+ const panel = new MultiChatPanel({ rmRegistry });
70
+ const { MockChatModel } = await import('./mocks');
71
+ const model = new MockChatModel();
72
+ panel.open({ model, displayName: 'test-chat' });
73
+
74
+ panel.unsetLoadedModel('test-chat', false);
75
+
76
+ expect(panel.getLoadedModel('test-chat')).toBeUndefined();
77
+ model.dispose();
78
+ panel.dispose();
79
+ });
80
+ });
81
+
82
+ describe('openInMain', () => {
83
+ it('should not dispose the model when moving to main area', async () => {
84
+ const { MockChatModel } = await import('./mocks');
85
+ const model = new MockChatModel();
86
+ const openInMain = jest.fn().mockResolvedValue(true);
87
+
88
+ const panel = new MultiChatPanel({ rmRegistry, openInMain });
89
+ panel.open({ model, displayName: 'test-chat' });
90
+
91
+ // Find the 'moveMain' toolbar button by name and invoke its click handler directly.
92
+ // ToolbarButton stores the onClick handler independently of DOM/React rendering,
93
+ // so this works without attaching the widget to the document.
94
+ const toolbar = panel.current!.toolbar;
95
+ const names = Array.from(toolbar.names());
96
+ const moveMainIdx = names.indexOf('moveMain');
97
+ expect(moveMainIdx).toBeGreaterThan(-1);
98
+
99
+ const moveButton = (toolbar.layout as any).widgets[
100
+ moveMainIdx
101
+ ] as ToolbarButton;
102
+
103
+ moveButton.onClick();
104
+ // Wait for the async onClick handler: openInMain resolves, then onClose is called.
105
+ await Promise.resolve();
106
+
107
+ expect(openInMain).toHaveBeenCalledWith(model.name);
108
+ expect(model.isDisposed).toBe(false);
109
+ expect(panel.getLoadedModel('test-chat')).toBeUndefined();
110
+
111
+ model.dispose();
112
+ panel.dispose();
113
+ });
114
+ });
115
+
20
116
  describe('placeholderFactory', () => {
21
117
  it('should use defaultPlaceholder when no factory is provided', () => {
22
118
  const panel = new MultiChatPanel({ rmRegistry });
@@ -126,10 +222,26 @@ describe('MultiChatPanel', () => {
126
222
  const { MockChatModel } = await import('./mocks');
127
223
  const model = new MockChatModel();
128
224
  panel.open({ model, displayName: 'test-chat' });
129
- panel.disposeLoadedModel('test-chat');
225
+ panel.unsetLoadedModel('test-chat');
130
226
 
131
227
  expect(factory.create).toHaveBeenCalledTimes(2);
132
228
  panel.dispose();
133
229
  });
134
230
  });
231
+
232
+ describe('side panel toolbar', () => {
233
+ it('should include the responsive toolbar opener for overflow actions', async () => {
234
+ const panel = new MultiChatPanel({ rmRegistry });
235
+ const { MockChatModel } = await import('./mocks');
236
+
237
+ panel.open({ model: new MockChatModel(), displayName: 'test-chat' });
238
+
239
+ const opener = panel.current?.toolbar.node.querySelector(
240
+ '.jp-Toolbar-responsive-opener'
241
+ );
242
+ expect(opener).not.toBeNull();
243
+
244
+ panel.dispose();
245
+ });
246
+ });
135
247
  });
@@ -10,7 +10,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
10
10
  import SettingsIcon from '@mui/icons-material/Settings';
11
11
  import { IconButton } from '@mui/material';
12
12
  import { Box } from '@mui/system';
13
- import React, { useEffect, useState } from 'react';
13
+ import React, { useEffect, useMemo, useState } from 'react';
14
14
 
15
15
  import {
16
16
  ChatInput,
@@ -34,11 +34,6 @@ import { IChatBodyPlaceholderFactory } from '../tokens';
34
34
  export function ChatBody(props: Chat.IChatProps): JSX.Element {
35
35
  const { model } = props;
36
36
  const [writers, setWriters] = useState<IChatModel.IWriter[]>([]);
37
- let { inputToolbarRegistry } = props;
38
- if (!inputToolbarRegistry) {
39
- inputToolbarRegistry = InputToolbarRegistry.defaultToolbarRegistry();
40
- }
41
-
42
37
  /**
43
38
  * Handle the changes in the writers list.
44
39
  */
@@ -65,10 +60,15 @@ export function ChatBody(props: Chat.IChatProps): JSX.Element {
65
60
 
66
61
  const horizontalPadding = 4;
67
62
 
68
- const contextValue: Chat.IChatProps = {
69
- ...props,
70
- inputToolbarRegistry
71
- };
63
+ const contextValue = useMemo(
64
+ () => ({
65
+ ...props,
66
+ inputToolbarRegistry:
67
+ props.inputToolbarRegistry ??
68
+ InputToolbarRegistry.defaultToolbarRegistry()
69
+ }),
70
+ [props]
71
+ );
72
72
 
73
73
  return (
74
74
  <ChatReactContext.Provider value={contextValue}>
@@ -7,6 +7,7 @@ import CheckIcon from '@mui/icons-material/Check';
7
7
  import React, { useEffect, useState } from 'react';
8
8
 
9
9
  import { InputToolbarRegistry } from '../toolbar-registry';
10
+ import { submitInputMessage } from '../submit-message';
10
11
  import { TooltippedIconButton } from '../../mui-extras';
11
12
  import { useTranslator } from '../../../context';
12
13
 
@@ -47,8 +48,7 @@ export function SaveEditButton(
47
48
  }, [model]);
48
49
 
49
50
  async function save() {
50
- await chatCommandRegistry?.onSubmit(model);
51
- model.send(model.value);
51
+ await submitInputMessage({ model, chatCommandRegistry });
52
52
  }
53
53
 
54
54
  return (
@@ -9,6 +9,7 @@ import { Box, Menu, MenuItem, Typography } from '@mui/material';
9
9
  import React, { useCallback, useEffect, useState } from 'react';
10
10
 
11
11
  import { InputToolbarRegistry } from '../toolbar-registry';
12
+ import { submitInputMessage } from '../submit-message';
12
13
  import { TooltippedIconButton } from '../../mui-extras';
13
14
  import { useTranslator } from '../../../context';
14
15
  import { includeSelectionIcon } from '../../../icons';
@@ -111,19 +112,10 @@ export function SendButton(
111
112
  }, [activeCellManager, selectionWatcher, showSendWithSelection]);
112
113
 
113
114
  async function send() {
114
- // Run all command providers
115
- await chatCommandRegistry?.onSubmit(model);
116
-
117
- const body = model.value;
118
-
119
- model.value = '';
120
- model.send(body);
121
- model.focus();
115
+ await submitInputMessage({ model, chatCommandRegistry });
122
116
  }
123
117
 
124
118
  async function sendWithSelection() {
125
- await chatCommandRegistry?.onSubmit(model);
126
-
127
119
  let source = '';
128
120
  let language: string | undefined;
129
121
 
@@ -141,10 +133,8 @@ export function SendButton(
141
133
  body += `\n\n\`\`\`${language ?? ''}\n${source}\n\`\`\`\n`;
142
134
  }
143
135
 
144
- model.value = '';
145
136
  closeMenu();
146
- model.send(body);
147
- model.focus();
137
+ await submitInputMessage({ model, chatCommandRegistry, body });
148
138
  }
149
139
 
150
140
  return (