@jupyter/chat 0.23.0-alpha.2 → 0.23.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.
@@ -1,3 +1,5 @@
1
+ import { ToolbarRegistry } from '@jupyterlab/apputils';
2
+ import { IObservableList } from '@jupyterlab/observables';
1
3
  import { TranslationBundle } from '@jupyterlab/translation';
2
4
  import { PanelWithToolbar, ReactiveToolbar } from '@jupyterlab/ui-components';
3
5
  import { Message } from '@lumino/messaging';
@@ -6,7 +8,8 @@ import { Panel, Widget } from '@lumino/widgets';
6
8
  import { ChatWidget } from './chat-widget';
7
9
  import { Chat, IInputToolbarRegistryFactory } from '../components';
8
10
  import { IChatModel } from '../model';
9
- import { IChatPlaceholderFactory } from '../tokens';
11
+ import { IChatPanel, IChatPlaceholderFactory } from '../tokens';
12
+ import { ChatArea } from '../types';
10
13
  /**
11
14
  * A panel widget with a reactive toolbar.
12
15
  */
@@ -28,9 +31,9 @@ export declare class MultiChatPanel extends PanelWithToolbar {
28
31
  */
29
32
  get current(): SidePanelWidget | undefined;
30
33
  /**
31
- * A signal emitting when a chat widget is opened in the panel.
34
+ * A signal emitting when a chat panel is opened in the sidepanel.
32
35
  */
33
- get chatOpened(): ISignal<MultiChatPanel, ChatWidget>;
36
+ get chatOpened(): ISignal<MultiChatPanel, IChatPanel>;
34
37
  /**
35
38
  * A signal emitting when the panel visibility changed.
36
39
  */
@@ -106,10 +109,10 @@ export declare class MultiChatPanel extends PanelWithToolbar {
106
109
  private _chatNamesChanged;
107
110
  private _chatOptions;
108
111
  private _inputToolbarFactory?;
112
+ private _chatToolbarFactory?;
109
113
  private _updateChatListDebouncer;
110
114
  private _createModel?;
111
115
  private _getChatNames?;
112
- private _openInMain?;
113
116
  private _renameChat?;
114
117
  private _placeholderFactory?;
115
118
  private _openChatWidget?;
@@ -132,6 +135,10 @@ export declare namespace MultiChatPanel {
132
135
  * The input toolbar factory;
133
136
  */
134
137
  inputToolbarFactory?: IInputToolbarRegistryFactory;
138
+ /**
139
+ * An optional toolbar factory for each opened chat.
140
+ */
141
+ chatToolbarFactory?: (panel: IChatPanel) => IObservableList<ToolbarRegistry.IToolbarItem>;
135
142
  /**
136
143
  * An optional callback to create a chat model.
137
144
  *
@@ -147,12 +154,6 @@ export declare namespace MultiChatPanel {
147
154
  getChatNames?: () => Promise<{
148
155
  [name: string]: string;
149
156
  }>;
150
- /**
151
- * An optional callback to open the chat in the main area.
152
- *
153
- * @param name - the name of the chat to move.
154
- */
155
- openInMain?: (name: string) => Promise<boolean>;
156
157
  /**
157
158
  * An optional callback to rename a chat.
158
159
  *
@@ -188,10 +189,14 @@ export declare namespace MultiChatPanel {
188
189
  /**
189
190
  * A widget containing the chat and its toolbar.
190
191
  */
191
- declare class SidePanelWidget extends ReactivePanelWithToolbar {
192
+ declare class SidePanelWidget extends ReactivePanelWithToolbar implements IChatPanel {
192
193
  constructor(options: SidePanelWidget.IOptions);
193
194
  protected onAfterAttach(msg: Message): void;
194
195
  protected onResize(msg: Widget.ResizeMessage): void;
196
+ /**
197
+ * The area of the widget.
198
+ */
199
+ get area(): ChatArea;
195
200
  /**
196
201
  * The chat widget embedded in the sidepanel widget.
197
202
  */
@@ -220,16 +225,11 @@ declare class SidePanelWidget extends ReactivePanelWithToolbar {
220
225
  * Update the title based on the chat name.
221
226
  */
222
227
  private _updateTitle;
223
- /**
224
- * Enable/disable unread icon.
225
- */
226
- private _unreadChanged;
227
228
  /**
228
229
  * Trigger reactive toolbar overflow computation from rendered toolbar size.
229
230
  */
230
231
  private _updateReactiveToolbar;
231
232
  private _chatWidget;
232
- private _markAsRead;
233
233
  private _displayName;
234
234
  private _titleWidget;
235
235
  private _nameChanged;
@@ -254,14 +254,14 @@ declare namespace SidePanelWidget {
254
254
  * The displayed name of the chat.
255
255
  */
256
256
  displayName?: string;
257
- /**
258
- * The callback to open the chat in main area.
259
- */
260
- openInMain?: (name: string) => Promise<boolean>;
261
257
  /**
262
258
  * The callback to rename the chat.
263
259
  */
264
260
  renameChat?: boolean | ((oldName: string) => Promise<string | null>);
261
+ /**
262
+ * An optional toolbar factory.
263
+ */
264
+ toolbarFactory?: (panel: IChatPanel) => IObservableList<ToolbarRegistry.IToolbarItem>;
265
265
  /**
266
266
  * The translation bundle.
267
267
  */
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { InputDialog } from '@jupyterlab/apputils';
10
10
  import { nullTranslator } from '@jupyterlab/translation';
11
- import { addIcon, closeIcon, launchIcon, PanelWithToolbar, ReactiveToolbar, ReactWidget, Spinner, Toolbar, ToolbarButton } from '@jupyterlab/ui-components';
11
+ import { addIcon, closeIcon, PanelWithToolbar, ReactiveToolbar, ReactWidget, Spinner, Toolbar, ToolbarButton } from '@jupyterlab/ui-components';
12
12
  import { ArrayExt } from '@lumino/algorithm';
13
13
  import { MessageLoop } from '@lumino/messaging';
14
14
  import { Debouncer } from '@lumino/polling';
@@ -19,7 +19,7 @@ import { ChatSelectorPopup } from './chat-selector-popup';
19
19
  import { ChatWidget } from './chat-widget';
20
20
  import { defaultPlaceholder } from './placeholder';
21
21
  import { TRANSLATION_DOMAIN } from '../context';
22
- import { chatIcon, readIcon } from '../icons';
22
+ import { chatIcon } from '../icons';
23
23
  const SIDEPANEL_CLASS = 'jp-chat-sidepanel';
24
24
  const ADD_BUTTON_CLASS = 'jp-chat-add';
25
25
  const OPEN_SELECT_CLASS = 'jp-chat-open';
@@ -120,9 +120,9 @@ export class MultiChatPanel extends PanelWithToolbar {
120
120
  this.addClass(SIDEPANEL_CLASS);
121
121
  this._chatOptions = options;
122
122
  this._inputToolbarFactory = options.inputToolbarFactory;
123
+ this._chatToolbarFactory = options.chatToolbarFactory;
123
124
  this._getChatNames = options.getChatNames;
124
125
  this._createModel = options.createModel;
125
- this._openInMain = options.openInMain;
126
126
  this._renameChat = options.renameChat;
127
127
  this._placeholderFactory = options.placeholderFactory;
128
128
  if (this._createModel) {
@@ -168,7 +168,7 @@ export class MultiChatPanel extends PanelWithToolbar {
168
168
  : undefined;
169
169
  }
170
170
  /**
171
- * A signal emitting when a chat widget is opened in the panel.
171
+ * A signal emitting when a chat panel is opened in the sidepanel.
172
172
  */
173
173
  get chatOpened() {
174
174
  return this._chatOpened;
@@ -298,8 +298,8 @@ export class MultiChatPanel extends PanelWithToolbar {
298
298
  const widget = new SidePanelWidget({
299
299
  widget: chatWidget,
300
300
  displayName: name,
301
- openInMain: this._openInMain,
302
301
  renameChat: this._renameChat,
302
+ toolbarFactory: this._chatToolbarFactory,
303
303
  onClose: (name, disposeModel = true) => {
304
304
  this.unsetLoadedModel(name, disposeModel);
305
305
  },
@@ -314,7 +314,7 @@ export class MultiChatPanel extends PanelWithToolbar {
314
314
  if (this._chatSelectorPopup) {
315
315
  this._chatSelectorPopup.setCurrentChat(name);
316
316
  }
317
- this._chatOpened.emit(chatWidget);
317
+ this._chatOpened.emit(widget);
318
318
  return chatWidget;
319
319
  }
320
320
  /**
@@ -372,14 +372,8 @@ export class MultiChatPanel extends PanelWithToolbar {
372
372
  */
373
373
  class SidePanelWidget extends ReactivePanelWithToolbar {
374
374
  constructor(options) {
375
- var _a, _b, _c, _d;
375
+ var _a;
376
376
  super();
377
- /**
378
- * Enable/disable unread icon.
379
- */
380
- this._unreadChanged = (_, unread) => {
381
- this._markAsRead.enabled = unread.length > 0;
382
- };
383
377
  this._nameChanged = new Signal(this);
384
378
  this._chatWidget = options.widget;
385
379
  this._displayName = (_a = options.displayName) !== null && _a !== void 0 ? _a : options.widget.model.name;
@@ -399,18 +393,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
399
393
  });
400
394
  // Add the chat widget
401
395
  this.addWidget(this._chatWidget);
402
- // Add toolbar buttons
403
- this._markAsRead = new ToolbarButton({
404
- icon: readIcon,
405
- iconLabel: trans.__('Mark chat as read'),
406
- className: 'jp-mod-styled',
407
- onClick: () => {
408
- if (this.model) {
409
- this.model.unreadMessages = [];
410
- }
411
- }
412
- });
413
- this.toolbar.addItem('markRead', this._markAsRead);
414
396
  if (options.renameChat) {
415
397
  const renameButton = new ToolbarButton({
416
398
  iconClass: 'jp-EditIcon',
@@ -447,21 +429,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
447
429
  });
448
430
  this.toolbar.addItem('rename', renameButton);
449
431
  }
450
- if (options.openInMain) {
451
- const moveToMain = new ToolbarButton({
452
- icon: launchIcon,
453
- iconLabel: trans.__('Move the chat to the main area'),
454
- className: 'jp-mod-styled',
455
- onClick: async () => {
456
- var _a;
457
- const name = this.model.name;
458
- if (await ((_a = options.openInMain) === null || _a === void 0 ? void 0 : _a.call(options, name))) {
459
- options.onClose(this._displayName, false);
460
- }
461
- }
462
- });
463
- this.toolbar.addItem('moveMain', moveToMain);
464
- }
465
432
  const closeButton = new ToolbarButton({
466
433
  icon: closeIcon,
467
434
  iconLabel: trans.__('Close the chat'),
@@ -471,9 +438,25 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
471
438
  }
472
439
  });
473
440
  this.toolbar.addItem('close', closeButton);
474
- // Update mark as read button state
475
- (_b = this.model.unreadChanged) === null || _b === void 0 ? void 0 : _b.connect(this._unreadChanged);
476
- this._markAsRead.enabled = ((_d = (_c = this.model) === null || _c === void 0 ? void 0 : _c.unreadMessages.length) !== null && _d !== void 0 ? _d : 0) > 0;
441
+ if (options.toolbarFactory) {
442
+ const items = options.toolbarFactory(this);
443
+ for (let i = 0; i < items.length; i++) {
444
+ const { name, widget } = items.get(i);
445
+ this.toolbar.insertBefore('close', name, widget);
446
+ }
447
+ items.changed.connect((_, change) => {
448
+ if (change.type === 'add') {
449
+ for (const { name, widget } of change.newValues) {
450
+ this.toolbar.insertBefore('close', name, widget);
451
+ }
452
+ }
453
+ else if (change.type === 'remove') {
454
+ for (const { widget } of change.oldValues) {
455
+ widget.dispose();
456
+ }
457
+ }
458
+ });
459
+ }
477
460
  }
478
461
  onAfterAttach(msg) {
479
462
  super.onAfterAttach(msg);
@@ -483,6 +466,12 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
483
466
  super.onResize(msg);
484
467
  this._updateReactiveToolbar();
485
468
  }
469
+ /**
470
+ * The area of the widget.
471
+ */
472
+ get area() {
473
+ return 'sidebar';
474
+ }
486
475
  /**
487
476
  * The chat widget embedded in the sidepanel widget.
488
477
  */
@@ -520,11 +509,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
520
509
  * Dispose of the resources held by the widget.
521
510
  */
522
511
  dispose() {
523
- var _a;
524
- const model = this.model;
525
- if (model) {
526
- (_a = model.unreadChanged) === null || _a === void 0 ? void 0 : _a.disconnect(this._unreadChanged);
527
- }
528
512
  super.dispose();
529
513
  }
530
514
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.23.0-alpha.2",
3
+ "version": "0.23.0-alpha.3",
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",
@@ -55,6 +55,7 @@
55
55
  "@jupyterlab/filebrowser": "^4.6.0",
56
56
  "@jupyterlab/fileeditor": "^4.6.0",
57
57
  "@jupyterlab/notebook": "^4.6.0",
58
+ "@jupyterlab/observables": "^5.6.0",
58
59
  "@jupyterlab/rendermime": "^4.6.0",
59
60
  "@jupyterlab/translation": "^4.6.0",
60
61
  "@jupyterlab/ui-components": "^4.6.0",
@@ -0,0 +1,129 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { InputModel } from '../input-model';
7
+ import { INewMessage } from '../types';
8
+
9
+ describe('test input model', () => {
10
+ describe('metadata', () => {
11
+ it('should start with empty metadata', () => {
12
+ const model = new InputModel({ onSend: jest.fn() });
13
+ expect(model.getMetadata()).toEqual({});
14
+ });
15
+
16
+ it('should seed metadata from options', () => {
17
+ const model = new InputModel({
18
+ onSend: jest.fn(),
19
+ metadata: { persona: 'kiro' }
20
+ });
21
+ expect(model.getMetadata()).toEqual({ persona: 'kiro' });
22
+ });
23
+
24
+ it('should merge patches with updateMetadata', () => {
25
+ const model = new InputModel({ onSend: jest.fn() });
26
+ model.updateMetadata({ persona: 'kiro' });
27
+ model.updateMetadata({ model: { id: 'claude-opus-48' } });
28
+ expect(model.getMetadata()).toEqual({
29
+ persona: 'kiro',
30
+ model: { id: 'claude-opus-48' }
31
+ });
32
+ });
33
+
34
+ it('should overwrite existing keys on update', () => {
35
+ const model = new InputModel({ onSend: jest.fn() });
36
+ model.updateMetadata({ persona: 'kiro' });
37
+ model.updateMetadata({ persona: 'jupyternaut' });
38
+ expect(model.getMetadata()).toEqual({ persona: 'jupyternaut' });
39
+ });
40
+
41
+ it('should shallow-merge: a top-level key replaces the whole value', () => {
42
+ const model = new InputModel({ onSend: jest.fn() });
43
+ model.updateMetadata({ model: { id: 'a' } });
44
+ // Passing `model` again replaces it wholesale (no recursive merge).
45
+ model.updateMetadata({ model: { id: 'b' } });
46
+ expect(model.getMetadata()).toEqual({ model: { id: 'b' } });
47
+ });
48
+
49
+ it('should not be mutated by later changes to a patch', () => {
50
+ const model = new InputModel({ onSend: jest.fn() });
51
+ const patch = { model: { id: 'a' } };
52
+ model.updateMetadata(patch);
53
+ // Mutating the patch after the fact must not reach into stored metadata.
54
+ patch.model.id = 'tampered';
55
+ expect(model.getMetadata()).toEqual({ model: { id: 'a' } });
56
+ });
57
+
58
+ it('should clear metadata', () => {
59
+ const model = new InputModel({ onSend: jest.fn() });
60
+ model.updateMetadata({ persona: 'kiro' });
61
+ model.clearMetadata();
62
+ expect(model.getMetadata()).toEqual({});
63
+ });
64
+
65
+ it('should emit metadataChanged on update and clear', () => {
66
+ const model = new InputModel({ onSend: jest.fn() });
67
+ const emitted: any[] = [];
68
+ model.metadataChanged?.connect((_, metadata) => {
69
+ emitted.push({ ...metadata });
70
+ });
71
+ model.updateMetadata({ persona: 'kiro' });
72
+ model.clearMetadata();
73
+ expect(emitted).toEqual([{ persona: 'kiro' }, {}]);
74
+ });
75
+ });
76
+
77
+ describe('send', () => {
78
+ it('should attach metadata to the message when non-empty', () => {
79
+ const onSend = jest.fn();
80
+ const model = new InputModel({ onSend });
81
+ model.updateMetadata({ persona: 'kiro' });
82
+ model.send('hello');
83
+
84
+ const message: INewMessage = onSend.mock.calls[0][0];
85
+ expect(message.body).toBe('hello');
86
+ expect(message.metadata).toEqual({ persona: 'kiro' });
87
+ });
88
+
89
+ it('should omit metadata from the message when empty', () => {
90
+ const onSend = jest.fn();
91
+ const model = new InputModel({ onSend });
92
+ model.send('hello');
93
+
94
+ const message: INewMessage = onSend.mock.calls[0][0];
95
+ expect(message.metadata).toBeUndefined();
96
+ });
97
+
98
+ it('should send a copy of the metadata', () => {
99
+ const onSend = jest.fn();
100
+ const model = new InputModel({ onSend });
101
+ model.updateMetadata({ persona: 'kiro' });
102
+ model.send('hello');
103
+
104
+ const message: INewMessage = onSend.mock.calls[0][0];
105
+ model.updateMetadata({ persona: 'jupyternaut' });
106
+ expect(message.metadata).toEqual({ persona: 'kiro' });
107
+ });
108
+
109
+ it('should keep metadata after sending (sticky selection)', () => {
110
+ // Unlike attachments/mentions, metadata carries the picker's
111
+ // persona/model/settings selection, which is sticky across messages.
112
+ const model = new InputModel({ onSend: jest.fn() });
113
+ model.updateMetadata({ persona: 'kiro' });
114
+ model.send('hello');
115
+ expect(model.getMetadata()).toEqual({ persona: 'kiro' });
116
+ });
117
+ });
118
+ });
119
+
120
+ // `IMessageMetadata` is intentionally empty in the source; consumers augment it
121
+ // with their own fields via module augmentation. We do the same here purely so
122
+ // the tests can exercise `updateMetadata` with representative fields — this
123
+ // stays in the test file and no consumer-specific fields leak into the source.
124
+ declare module '../types' {
125
+ interface IMessageMetadata {
126
+ persona?: string;
127
+ model?: { id: string };
128
+ }
129
+ }
@@ -3,13 +3,16 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
- import { ToolbarButton } from '@jupyterlab/ui-components';
6
+ import { ToolbarRegistry } from '@jupyterlab/apputils';
7
7
  import { RenderMimeRegistry } from '@jupyterlab/rendermime';
8
+ import { ObservableList } from '@jupyterlab/observables';
9
+ import { ToolbarButton, PanelWithToolbar } from '@jupyterlab/ui-components';
8
10
  import { Widget } from '@lumino/widgets';
9
11
 
10
12
  import { IChatPlaceholderFactory } from '../tokens';
11
13
  import { MultiChatPanel } from '../widgets/multichat-panel';
12
14
  import { defaultPlaceholder, Placeholder } from '../widgets/placeholder';
15
+ import { MockChatModel } from './mocks';
13
16
 
14
17
  describe('MultiChatPanel', () => {
15
18
  let rmRegistry: RenderMimeRegistry;
@@ -79,40 +82,6 @@ describe('MultiChatPanel', () => {
79
82
  });
80
83
  });
81
84
 
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
-
116
85
  describe('placeholderFactory', () => {
117
86
  it('should use defaultPlaceholder when no factory is provided', () => {
118
87
  const panel = new MultiChatPanel({ rmRegistry });
@@ -207,7 +176,7 @@ describe('MultiChatPanel', () => {
207
176
  panel.dispose();
208
177
  });
209
178
 
210
- it('should call factory.create again when the placeholder is re-added after closing a chat', async () => {
179
+ it('should call factory.create again when the placeholder is re-added after closing a chat', () => {
211
180
  const factory: IChatPlaceholderFactory = {
212
181
  create: jest.fn().mockReturnValue(new Widget())
213
182
  };
@@ -219,7 +188,6 @@ describe('MultiChatPanel', () => {
219
188
  });
220
189
 
221
190
  // Simulate opening and closing a chat to trigger _addPlaceholder again.
222
- const { MockChatModel } = await import('./mocks');
223
191
  const model = new MockChatModel();
224
192
  panel.open({ model, displayName: 'test-chat' });
225
193
  panel.unsetLoadedModel('test-chat');
@@ -240,7 +208,95 @@ describe('MultiChatPanel', () => {
240
208
  '.jp-Toolbar-responsive-opener'
241
209
  );
242
210
  expect(opener).not.toBeNull();
211
+ panel.dispose();
212
+ });
213
+ });
214
+
215
+ describe('chatToolbarFactory', () => {
216
+ const makeFactory = (items: ToolbarRegistry.IToolbarItem[]) =>
217
+ jest.fn().mockReturnValue(new ObservableList({ values: items }));
218
+
219
+ it('should call factory with the panel when a chat is opened', () => {
220
+ const factory = makeFactory([{ name: 'myButton', widget: new Widget() }]);
221
+ const panel = new MultiChatPanel({
222
+ rmRegistry,
223
+ chatToolbarFactory: factory
224
+ });
225
+ const model = new MockChatModel();
226
+ const chatWidget = panel.open({ model, displayName: 'test' });
227
+ expect(factory).toHaveBeenCalledTimes(1);
228
+ expect(factory.mock.calls[0][0].widget).toBe(chatWidget);
229
+ panel.dispose();
230
+ });
243
231
 
232
+ it('should add the created widget to the chat toolbar', () => {
233
+ const panel = new MultiChatPanel({
234
+ rmRegistry,
235
+ chatToolbarFactory: makeFactory([
236
+ { name: 'myButton', widget: new Widget() }
237
+ ])
238
+ });
239
+ const model = new MockChatModel();
240
+ panel.open({ model, displayName: 'test' });
241
+ const toolbar = (panel.current as unknown as PanelWithToolbar).toolbar;
242
+ expect(Array.from(toolbar.names())).toContain('myButton');
243
+ panel.dispose();
244
+ });
245
+
246
+ it('should insert the item before "close"', () => {
247
+ const panel = new MultiChatPanel({
248
+ rmRegistry,
249
+ chatToolbarFactory: makeFactory([
250
+ { name: 'myButton', widget: new Widget() }
251
+ ])
252
+ });
253
+ const model = new MockChatModel();
254
+ panel.open({ model, displayName: 'test' });
255
+ const toolbar = (panel.current as unknown as PanelWithToolbar).toolbar;
256
+ const names = Array.from(toolbar.names());
257
+ expect(names.indexOf('myButton')).toBeLessThan(names.indexOf('close'));
258
+ panel.dispose();
259
+ });
260
+
261
+ it('should add all items when factory returns multiple items', () => {
262
+ const panel = new MultiChatPanel({
263
+ rmRegistry,
264
+ chatToolbarFactory: makeFactory([
265
+ { name: 'item1', widget: new Widget() },
266
+ { name: 'item2', widget: new Widget() }
267
+ ])
268
+ });
269
+ const model = new MockChatModel();
270
+ panel.open({ model, displayName: 'test' });
271
+ const toolbar = (panel.current as unknown as PanelWithToolbar).toolbar;
272
+ const names = Array.from(toolbar.names());
273
+ expect(names).toContain('item1');
274
+ expect(names).toContain('item2');
275
+ panel.dispose();
276
+ });
277
+
278
+ it('should call factory again for each newly opened chat', () => {
279
+ const factory = jest.fn().mockImplementation(
280
+ () =>
281
+ new ObservableList<ToolbarRegistry.IToolbarItem>({
282
+ values: [{ name: 'myButton', widget: new Widget() }]
283
+ })
284
+ );
285
+ const panel = new MultiChatPanel({
286
+ rmRegistry,
287
+ chatToolbarFactory: factory
288
+ });
289
+ const model1 = new MockChatModel();
290
+ panel.open({ model: model1, displayName: 'chat1' });
291
+ panel.unsetLoadedModel('chat1');
292
+
293
+ const model2 = new MockChatModel();
294
+ panel.open({ model: model2, displayName: 'chat2' });
295
+
296
+ expect(factory).toHaveBeenCalledTimes(2);
297
+ expect(factory.mock.results[0].value).not.toBe(
298
+ factory.mock.results[1].value
299
+ );
244
300
  panel.dispose();
245
301
  });
246
302
  });
@@ -10,8 +10,8 @@ import { MessageRenderer } from './message-renderer';
10
10
  import { AttachmentPreviewList } from '../attachments';
11
11
  import { ChatInput } from '../input';
12
12
  import { useChatContext } from '../../context';
13
- import { IInputModel, InputModel } from '../../input-model';
14
- import { IMessageContent, IMessage } from '../../types';
13
+ import { InputModel } from '../../input-model';
14
+ import { IMessageContent, IMessage, INewMessage } from '../../types';
15
15
  import { replaceSpanToMention } from '../../utils';
16
16
 
17
17
  export const MESSAGE_CONTAINER_CLASS = 'jp-chat-message-container';
@@ -96,8 +96,8 @@ export function ChatMessageBase(props: ChatMessageProps): JSX.Element {
96
96
  });
97
97
  const inputModel = new InputModel({
98
98
  chatContext: model.createChatContext(),
99
- onSend: (input: string, model?: IInputModel) =>
100
- updateMessage(message.id, input, model),
99
+ onSend: (newMessage: INewMessage) =>
100
+ updateMessage(message.id, newMessage),
101
101
  onCancel: () => cancelEdition(),
102
102
  value: body,
103
103
  activeCellManager: model.activeCellManager,
@@ -121,17 +121,27 @@ export function ChatMessageBase(props: ChatMessageProps): JSX.Element {
121
121
 
122
122
  // Update the content of the message.
123
123
  const updateMessage = useCallback(
124
- (id: string, input: string, inputModel?: IInputModel): void => {
125
- if (!canEdit || !inputModel) {
124
+ (id: string, newMessage: INewMessage): void => {
125
+ const inputModel = model.getEditionModel(id);
126
+ if (!canEdit || !inputModel || !model.updateMessage) {
127
+ setEdit(false);
126
128
  return;
127
129
  }
130
+
128
131
  // Update the message
129
132
  const updatedMessage = { ...message };
130
- updatedMessage.body = input;
131
- updatedMessage.attachments = inputModel.attachments;
132
- updatedMessage.mentions = inputModel.mentions;
133
- model.updateMessage!(id, updatedMessage);
134
- model.getEditionModel(message.id)?.dispose();
133
+ if (newMessage.body) {
134
+ updatedMessage.body = newMessage.body;
135
+ }
136
+ if (newMessage.attachments) {
137
+ updatedMessage.attachments = newMessage.attachments;
138
+ }
139
+ if (newMessage.mentions) {
140
+ updatedMessage.mentions = newMessage.mentions;
141
+ }
142
+
143
+ model.updateMessage(id, updatedMessage);
144
+ inputModel.dispose();
135
145
  setEdit(false);
136
146
  },
137
147
  [model, message, canEdit]
package/src/index.ts CHANGED
@@ -13,4 +13,5 @@ export * from './registers';
13
13
  export * from './selection-watcher';
14
14
  export * from './tokens';
15
15
  export * from './types';
16
+ export * from './utils';
16
17
  export * from './widgets';