@jupyter/chat 0.21.1 → 0.22.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 (47) hide show
  1. package/lib/__tests__/chat-body-placeholder.spec.d.ts +1 -0
  2. package/lib/__tests__/chat-body-placeholder.spec.js +54 -0
  3. package/lib/__tests__/multichat-panel.spec.d.ts +1 -0
  4. package/lib/__tests__/multichat-panel.spec.js +116 -0
  5. package/lib/components/chat.d.ts +6 -0
  6. package/lib/components/input/buttons/send-button.d.ts +1 -1
  7. package/lib/components/input/buttons/send-button.js +108 -8
  8. package/lib/components/input/chat-input.js +4 -3
  9. package/lib/components/messages/chat-body-placeholder.d.ts +6 -0
  10. package/lib/components/messages/chat-body-placeholder.js +19 -0
  11. package/lib/components/messages/index.d.ts +1 -0
  12. package/lib/components/messages/index.js +1 -0
  13. package/lib/components/messages/messages.js +54 -2
  14. package/lib/components/scroll-container.d.ts +6 -13
  15. package/lib/components/scroll-container.js +11 -22
  16. package/lib/input-model.d.ts +4 -0
  17. package/lib/theme-provider.js +2 -2
  18. package/lib/tokens.d.ts +50 -1
  19. package/lib/tokens.js +12 -0
  20. package/lib/types.d.ts +4 -0
  21. package/lib/widgets/chat-widget.js +5 -1
  22. package/lib/widgets/index.d.ts +1 -0
  23. package/lib/widgets/index.js +1 -0
  24. package/lib/widgets/multichat-panel.d.ts +15 -0
  25. package/lib/widgets/multichat-panel.js +41 -9
  26. package/lib/widgets/placeholder.d.ts +48 -0
  27. package/lib/widgets/placeholder.js +48 -0
  28. package/package.json +3 -3
  29. package/src/__tests__/chat-body-placeholder.spec.ts +69 -0
  30. package/src/__tests__/multichat-panel.spec.ts +135 -0
  31. package/src/components/chat.tsx +6 -0
  32. package/src/components/input/buttons/send-button.tsx +158 -14
  33. package/src/components/input/chat-input.tsx +6 -5
  34. package/src/components/messages/chat-body-placeholder.tsx +24 -0
  35. package/src/components/messages/index.ts +1 -0
  36. package/src/components/messages/messages.tsx +59 -2
  37. package/src/components/scroll-container.tsx +27 -34
  38. package/src/input-model.ts +4 -0
  39. package/src/theme-provider.ts +2 -2
  40. package/src/tokens.ts +61 -1
  41. package/src/types.ts +4 -0
  42. package/src/widgets/chat-widget.tsx +14 -1
  43. package/src/widgets/index.ts +1 -0
  44. package/src/widgets/multichat-panel.tsx +56 -10
  45. package/src/widgets/placeholder.tsx +122 -0
  46. package/style/base.css +1 -0
  47. package/style/placeholder.css +29 -0
package/lib/tokens.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ /// <reference types="react" />
1
2
  import { IWidgetTracker, MainAreaWidget } from '@jupyterlab/apputils';
2
3
  import { Token } from '@lumino/coreutils';
3
- import { ChatWidget } from './widgets';
4
+ import { Widget } from '@lumino/widgets';
5
+ import { ChatWidget, Placeholder } from './widgets';
4
6
  import { IChatModel } from './model';
5
7
  /**
6
8
  * The main area chat widget type.
@@ -16,3 +18,50 @@ export type IChatTracker = IWidgetTracker<ChatWidget | MainAreaChat>;
16
18
  * A chat tracker token.
17
19
  */
18
20
  export declare const IChatTracker: Token<IChatTracker>;
21
+ /**
22
+ * The interface for the placeholder factory.
23
+ */
24
+ export interface IChatPlaceholderFactory {
25
+ /**
26
+ * Create a placeholder widget for the multi-chat panel.
27
+ *
28
+ * @param props - the props passed to the placeholder.
29
+ * @returns a widget to display as placeholder.
30
+ */
31
+ create(props: Placeholder.IProps): Widget;
32
+ }
33
+ /**
34
+ * The token for the placeholder factory.
35
+ * Not provided by default — extensions can provide it to replace the default
36
+ * placeholder shown when no chat is open in the multi-chat panel.
37
+ */
38
+ export declare const IChatPlaceholderFactory: Token<IChatPlaceholderFactory>;
39
+ /**
40
+ * The interface for the chat body placeholder factory.
41
+ */
42
+ export interface IChatBodyPlaceholderFactory {
43
+ /**
44
+ * Create a chat body placeholder component shown when the chat has no messages.
45
+ *
46
+ * @param props - the props passed to the component.
47
+ * @returns a React element to display as chat body placeholder.
48
+ */
49
+ create(props: IChatBodyPlaceholderFactory.IProps): JSX.Element | null;
50
+ }
51
+ export declare namespace IChatBodyPlaceholderFactory {
52
+ /**
53
+ * The props passed to the chat body placeholder factory.
54
+ */
55
+ interface IProps {
56
+ /**
57
+ * Callback to send a message.
58
+ */
59
+ onSend: (body: string) => void;
60
+ }
61
+ }
62
+ /**
63
+ * The token for the chat body placeholder factory.
64
+ * Not provided by default — extensions can provide it to display clickable
65
+ * chat body placeholder when a chat has no messages.
66
+ */
67
+ export declare const IChatBodyPlaceholderFactory: Token<IChatBodyPlaceholderFactory>;
package/lib/tokens.js CHANGED
@@ -7,3 +7,15 @@ import { Token } from '@lumino/coreutils';
7
7
  * A chat tracker token.
8
8
  */
9
9
  export const IChatTracker = new Token('@jupyter/chat:IChatTracker', 'The chat widget tracker');
10
+ /**
11
+ * The token for the placeholder factory.
12
+ * Not provided by default — extensions can provide it to replace the default
13
+ * placeholder shown when no chat is open in the multi-chat panel.
14
+ */
15
+ export const IChatPlaceholderFactory = new Token('@jupyter/chat:IChatPlaceholderFactory', 'The placeholder factory for the multi-chat panel');
16
+ /**
17
+ * The token for the chat body placeholder factory.
18
+ * Not provided by default — extensions can provide it to display clickable
19
+ * chat body placeholder when a chat has no messages.
20
+ */
21
+ export const IChatBodyPlaceholderFactory = new Token('@jupyter/chat:IChatBodyPlaceholderFactory', 'The chat body placeholder factory for empty chats');
package/lib/types.d.ts CHANGED
@@ -52,6 +52,10 @@ export interface IConfig {
52
52
  * Whether to display deleted messages.
53
53
  */
54
54
  showDeleted?: boolean;
55
+ /**
56
+ * Whether to display the 'send with selection' button.
57
+ */
58
+ sendWithSelection?: boolean;
55
59
  }
56
60
  /**
57
61
  * Mime model body type, a partial mime bundle model containing at least the data.
@@ -2,7 +2,7 @@
2
2
  * Copyright (c) Jupyter Development Team.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
- import { ReactWidget } from '@jupyterlab/apputils';
5
+ import { Notification, ReactWidget } from '@jupyterlab/apputils';
6
6
  import { DocumentWidget } from '@jupyterlab/docregistry';
7
7
  import { isCode, isMarkdown, isRaw } from '@jupyterlab/nbformat';
8
8
  import { nullTranslator } from '@jupyterlab/translation';
@@ -236,11 +236,13 @@ export class ChatWidget extends ReactWidget {
236
236
  // Get path from first cell as all cells come from same notebook as users can only select or drag cells from one notebook at a time
237
237
  if (!((_a = cells[0]) === null || _a === void 0 ? void 0 : _a.id)) {
238
238
  console.warn('No valid cells to process');
239
+ Notification.error('Failed to attach notebook cells: no valid cell data found.');
239
240
  return false;
240
241
  }
241
242
  const notebookPath = this._findNotebookPath(String(cells[0].id));
242
243
  if (!notebookPath) {
243
244
  console.warn(`Cannot find notebook for dragged cells from ${cells[0].id}`);
245
+ Notification.error('Failed to attach notebook cells: source notebook could not be located.');
244
246
  return false;
245
247
  }
246
248
  const validCells = [];
@@ -282,9 +284,11 @@ export class ChatWidget extends ReactWidget {
282
284
  (_b = inputModel === null || inputModel === void 0 ? void 0 : inputModel.addAttachment) === null || _b === void 0 ? void 0 : _b.call(inputModel, attachment);
283
285
  return !!inputModel;
284
286
  }
287
+ Notification.error('Failed to attach notebook cells: no supported cells were found.');
285
288
  }
286
289
  catch (error) {
287
290
  console.error('Failed to process cell drop: ', error);
291
+ Notification.error('Failed to attach notebook cells due to an unexpected error.');
288
292
  }
289
293
  return false;
290
294
  }
@@ -3,3 +3,4 @@ export * from './chat-selector-popup';
3
3
  export * from './chat-sidebar';
4
4
  export * from './chat-widget';
5
5
  export * from './multichat-panel';
6
+ export * from './placeholder';
@@ -7,3 +7,4 @@ export * from './chat-selector-popup';
7
7
  export * from './chat-sidebar';
8
8
  export * from './chat-widget';
9
9
  export * from './multichat-panel';
10
+ export * from './placeholder';
@@ -5,6 +5,7 @@ import { ISignal } from '@lumino/signaling';
5
5
  import { ChatWidget } from './chat-widget';
6
6
  import { Chat, IInputToolbarRegistryFactory } from '../components';
7
7
  import { IChatModel } from '../model';
8
+ import { IChatPlaceholderFactory } from '../tokens';
8
9
  /**
9
10
  * Generic sidepanel widget including multiple chats and the add chat button.
10
11
  */
@@ -48,6 +49,10 @@ export declare class MultiChatPanel extends PanelWithToolbar {
48
49
  */
49
50
  protected onAfterShow(msg: Message): void;
50
51
  protected onBeforeHide(msg: Message): void;
52
+ /**
53
+ * Add a placeholder in the panel.
54
+ */
55
+ private _addPlaceholder;
51
56
  /**
52
57
  * Open a specific chat by name, creating a new sidepanel widget.
53
58
  */
@@ -88,6 +93,7 @@ export declare class MultiChatPanel extends PanelWithToolbar {
88
93
  */
89
94
  private _onSelectChat;
90
95
  private _chatOpened;
96
+ private _chatNamesChanged;
91
97
  private _chatOptions;
92
98
  private _inputToolbarFactory?;
93
99
  private _updateChatListDebouncer;
@@ -95,6 +101,7 @@ export declare class MultiChatPanel extends PanelWithToolbar {
95
101
  private _getChatNames?;
96
102
  private _openInMain?;
97
103
  private _renameChat?;
104
+ private _placeholderFactory?;
98
105
  private _openChatWidget?;
99
106
  private _chatSelectorPopup?;
100
107
  private _loadedModels;
@@ -144,6 +151,14 @@ export declare namespace MultiChatPanel {
144
151
  * @returns - a boolean, whether the chat has been renamed or not.
145
152
  */
146
153
  renameChat?: boolean | ((oldName: string) => Promise<string | null>);
154
+ /**
155
+ * An optional factory to create a placeholder widget displayed when no chat
156
+ * is opened. Falls back to the default placeholder if not provided.
157
+ *
158
+ * @param props - the props passed to the placeholder.
159
+ * @returns a widget to display as placeholder.
160
+ */
161
+ placeholderFactory?: IChatPlaceholderFactory;
147
162
  }
148
163
  /**
149
164
  * The options for the add chat method.
@@ -14,8 +14,9 @@ import { Debouncer } from '@lumino/polling';
14
14
  import { Signal } from '@lumino/signaling';
15
15
  import { Widget } from '@lumino/widgets';
16
16
  import React, { useEffect, useRef, useState } from 'react';
17
- import { ChatWidget } from './chat-widget';
18
17
  import { ChatSelectorPopup } from './chat-selector-popup';
18
+ import { ChatWidget } from './chat-widget';
19
+ import { defaultPlaceholder } from './placeholder';
19
20
  import { TRANSLATION_DOMAIN } from '../context';
20
21
  import { chatIcon, readIcon } from '../icons';
21
22
  const SIDEPANEL_CLASS = 'jp-chat-sidepanel';
@@ -49,6 +50,7 @@ export class MultiChatPanel extends PanelWithToolbar {
49
50
  if (!ArrayExt.shallowEqual(Object.keys(chatNames !== null && chatNames !== void 0 ? chatNames : {}), Object.keys(this._chatNames))) {
50
51
  this._chatNames = chatNames !== null && chatNames !== void 0 ? chatNames : {};
51
52
  (_b = this._chatSelectorPopup) === null || _b === void 0 ? void 0 : _b.updateChats(Object.keys(this._chatNames));
53
+ this._chatNamesChanged.emit(this._chatNames);
52
54
  }
53
55
  }
54
56
  catch (e) {
@@ -65,7 +67,7 @@ export class MultiChatPanel extends PanelWithToolbar {
65
67
  this._loadedModels.set(change.new, model);
66
68
  this._loadedModels.delete(change.old);
67
69
  (_a = this._chatSelectorPopup) === null || _a === void 0 ? void 0 : _a.setLoadedModels(this.getLoadedModelNames());
68
- if (((_b = this._currentWidget) === null || _b === void 0 ? void 0 : _b.model.name) === model.name) {
70
+ if (((_b = this.current) === null || _b === void 0 ? void 0 : _b.model.name) === model.name) {
69
71
  (_c = this._chatSelectorPopup) === null || _c === void 0 ? void 0 : _c.setCurrentChat(change.new);
70
72
  }
71
73
  }
@@ -91,6 +93,7 @@ export class MultiChatPanel extends PanelWithToolbar {
91
93
  (_a = this._chatSelectorPopup) === null || _a === void 0 ? void 0 : _a.hide();
92
94
  };
93
95
  this._chatOpened = new Signal(this);
96
+ this._chatNamesChanged = new Signal(this);
94
97
  this._loadedModels = new Map();
95
98
  this._chatNames = {};
96
99
  this._visibilityChanged = new Signal(this);
@@ -106,6 +109,7 @@ export class MultiChatPanel extends PanelWithToolbar {
106
109
  this._createModel = options.createModel;
107
110
  this._openInMain = options.openInMain;
108
111
  this._renameChat = options.renameChat;
112
+ this._placeholderFactory = options.placeholderFactory;
109
113
  if (this._createModel) {
110
114
  // Add chat button calls the createChat callback
111
115
  const addChat = new ToolbarButton({
@@ -137,12 +141,16 @@ export class MultiChatPanel extends PanelWithToolbar {
137
141
  // Insert the toolbar as first child.
138
142
  this.insertWidget(0, this.toolbar);
139
143
  this._updateChatListDebouncer = new Debouncer(this._updateChatList, 200);
144
+ // Add the placeholder by default.
145
+ this._addPlaceholder();
140
146
  }
141
147
  /**
142
148
  * The currently displayed chat widget.
143
149
  */
144
150
  get current() {
145
- return this._currentWidget;
151
+ return this._currentWidget instanceof SidePanelWidget
152
+ ? this._currentWidget
153
+ : undefined;
146
154
  }
147
155
  /**
148
156
  * A signal emitting when a chat widget is opened in the panel.
@@ -196,14 +204,15 @@ export class MultiChatPanel extends PanelWithToolbar {
196
204
  const model = this._loadedModels.get(name);
197
205
  if (model) {
198
206
  // If this is the currently displayed chat, remove it.
199
- if (((_a = this._currentWidget) === null || _a === void 0 ? void 0 : _a.model) === model) {
200
- this._currentWidget.nameChanged.disconnect(this._modelNameChanged);
201
- this._currentWidget.dispose();
207
+ if (((_a = this.current) === null || _a === void 0 ? void 0 : _a.model) === model) {
208
+ this.current.nameChanged.disconnect(this._modelNameChanged);
209
+ this.current.dispose();
202
210
  this._currentWidget = undefined;
203
211
  // Clear current chat in selector
204
212
  if (this._chatSelectorPopup) {
205
213
  this._chatSelectorPopup.setCurrentChat(null);
206
214
  }
215
+ this._addPlaceholder();
207
216
  }
208
217
  model.dispose();
209
218
  this._loadedModels.delete(name);
@@ -219,17 +228,40 @@ export class MultiChatPanel extends PanelWithToolbar {
219
228
  onBeforeHide(msg) {
220
229
  this._visibilityChanged.emit(false);
221
230
  }
231
+ /**
232
+ * Add a placeholder in the panel.
233
+ */
234
+ _addPlaceholder() {
235
+ const props = {
236
+ chatNames: this._chatNames,
237
+ open: this._onSelectChat,
238
+ onCreate: this._createModel
239
+ ? async () => {
240
+ const args = await this._createModel();
241
+ this.open(args);
242
+ }
243
+ : undefined,
244
+ chatNamesChanged: this._chatNamesChanged,
245
+ trans: this._trans
246
+ };
247
+ const placeholder = this._placeholderFactory
248
+ ? this._placeholderFactory.create(props)
249
+ : new defaultPlaceholder(props);
250
+ this.addWidget(placeholder);
251
+ this._currentWidget = placeholder;
252
+ }
222
253
  /**
223
254
  * Open a specific chat by name, creating a new sidepanel widget.
224
255
  */
225
256
  _open(name) {
257
+ var _a, _b;
226
258
  const model = this._loadedModels.get(name);
227
259
  if (!model) {
228
260
  return;
229
261
  }
230
- // Dispose current chat widget if any
262
+ // Dispose of the current chat widget or placeholder.
231
263
  if (this._currentWidget) {
232
- this._currentWidget.nameChanged.disconnect(this._modelNameChanged);
264
+ (_a = this.current) === null || _a === void 0 ? void 0 : _a.nameChanged.disconnect(this._modelNameChanged);
233
265
  this._currentWidget.dispose();
234
266
  }
235
267
  // Create the toolbar registry.
@@ -259,7 +291,7 @@ export class MultiChatPanel extends PanelWithToolbar {
259
291
  this.addWidget(widget);
260
292
  this.update();
261
293
  this._currentWidget = widget;
262
- this._currentWidget.nameChanged.connect(this._modelNameChanged);
294
+ (_b = this.current) === null || _b === void 0 ? void 0 : _b.nameChanged.connect(this._modelNameChanged);
263
295
  // Update selector to show current chat
264
296
  if (this._chatSelectorPopup) {
265
297
  this._chatSelectorPopup.setCurrentChat(name);
@@ -0,0 +1,48 @@
1
+ import { IRenderMime } from '@jupyterlab/rendermime';
2
+ import { ReactWidget } from '@jupyterlab/ui-components';
3
+ import { ISignal } from '@lumino/signaling';
4
+ import React from 'react';
5
+ /**
6
+ * The default placeholder widget.
7
+ */
8
+ export declare class defaultPlaceholder extends ReactWidget {
9
+ constructor(options: Placeholder.IProps);
10
+ render(): React.JSX.Element;
11
+ private _props;
12
+ }
13
+ /**
14
+ * The placeholder namespace.
15
+ */
16
+ export declare namespace Placeholder {
17
+ /**
18
+ * The options of the placeholder widget.
19
+ */
20
+ interface IProps {
21
+ /**
22
+ * The initial chat names.
23
+ */
24
+ chatNames: {
25
+ [name: string]: string;
26
+ };
27
+ /**
28
+ * A callback to open an existing chat by name.
29
+ *
30
+ * @param name - the display name of the chat to open.
31
+ */
32
+ open: (name: string) => Promise<void>;
33
+ /**
34
+ * A callback to create and open a new chat.
35
+ */
36
+ onCreate?: () => Promise<void>;
37
+ /**
38
+ * An optional signal emitting when the chat list changes.
39
+ */
40
+ chatNamesChanged?: ISignal<any, {
41
+ [name: string]: string;
42
+ }>;
43
+ /**
44
+ * An optional translation bundle.
45
+ */
46
+ trans?: IRenderMime.TranslationBundle;
47
+ }
48
+ }
@@ -0,0 +1,48 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { addIcon, ReactWidget, ToolbarButtonComponent } from '@jupyterlab/ui-components';
6
+ import React, { useEffect, useState } from 'react';
7
+ import { useTranslator } from '../context';
8
+ /**
9
+ * The default placeholder widget.
10
+ */
11
+ export class defaultPlaceholder extends ReactWidget {
12
+ constructor(options) {
13
+ super();
14
+ this._props = options;
15
+ }
16
+ render() {
17
+ return React.createElement(PlaceholderComponent, { ...this._props });
18
+ }
19
+ }
20
+ /**
21
+ * The default placeholder component.
22
+ */
23
+ const PlaceholderComponent = (props) => {
24
+ var _a;
25
+ const trans = (_a = props.trans) !== null && _a !== void 0 ? _a : useTranslator();
26
+ const [chatNames, setChatNames] = useState(props.chatNames);
27
+ useEffect(() => {
28
+ if (!props.chatNamesChanged) {
29
+ return;
30
+ }
31
+ const onChanged = (_, names) => {
32
+ setChatNames(names);
33
+ };
34
+ props.chatNamesChanged.connect(onChanged);
35
+ return () => {
36
+ props.chatNamesChanged.disconnect(onChanged);
37
+ };
38
+ }, [props.chatNamesChanged]);
39
+ const names = Object.keys(chatNames).sort((a, b) => a.localeCompare(b));
40
+ return (React.createElement("div", { className: "jp-chat-placeholder" },
41
+ React.createElement("h3", null, trans.__('No chat opened')),
42
+ props.onCreate && (React.createElement("div", { className: "jp-chat-placeholder-hint" },
43
+ React.createElement(ToolbarButtonComponent, { label: trans.__('New chat'), icon: addIcon, onClick: props.onCreate, tooltip: trans.__('Create a new chat'), className: "jp-chat-add" }))),
44
+ names.length > 0 && (React.createElement("div", { className: "jp-chat-placeholder-list" },
45
+ React.createElement("p", null, trans.__('Open an existing chat:')),
46
+ names.map(name => (React.createElement("div", { key: name },
47
+ React.createElement("button", { className: "jp-chat-placeholder-chat-item", onClick: () => props.open(name) }, name))))))));
48
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.21.1",
3
+ "version": "0.22.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",
@@ -30,8 +30,8 @@
30
30
  "url": "https://github.com/jupyterlab/jupyter-chat.git"
31
31
  },
32
32
  "scripts": {
33
- "build": "jlpm build:lib",
34
- "build:prod": "jlpm clean && jlpm build:lib:prod",
33
+ "build": "echo 'Building @jupyter/chat' && jlpm build:lib",
34
+ "build:prod": "echo 'Building @jupyter/chat' && jlpm clean && jlpm build:lib:prod",
35
35
  "build:lib": "tsc --sourceMap",
36
36
  "build:lib:prod": "tsc",
37
37
  "clean": "jlpm clean:lib",
@@ -0,0 +1,69 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { RenderMimeRegistry } from '@jupyterlab/rendermime';
7
+
8
+ import { ChatWidget } from '../widgets/chat-widget';
9
+ import { IChatBodyPlaceholderFactory } from '../tokens';
10
+ import { IChatModel } from '../model';
11
+ import { MockChatModel } from './mocks';
12
+
13
+ describe('ChatBodyPlaceholder', () => {
14
+ let model: IChatModel;
15
+ let rmRegistry: RenderMimeRegistry;
16
+
17
+ beforeEach(() => {
18
+ model = new MockChatModel();
19
+ rmRegistry = new RenderMimeRegistry();
20
+ });
21
+
22
+ it('should create a widget without chatBodyPlaceholderFactory', () => {
23
+ const widget = new ChatWidget({ model, rmRegistry });
24
+ expect(widget).toBeInstanceOf(ChatWidget);
25
+ widget.dispose();
26
+ });
27
+
28
+ it('should create a widget with chatBodyPlaceholderFactory', () => {
29
+ const factory: IChatBodyPlaceholderFactory = {
30
+ create: jest.fn().mockReturnValue(null)
31
+ };
32
+ const widget = new ChatWidget({
33
+ model,
34
+ rmRegistry,
35
+ chatBodyPlaceholderFactory: factory
36
+ });
37
+ expect(widget).toBeInstanceOf(ChatWidget);
38
+ widget.dispose();
39
+ });
40
+
41
+ it('should call sendMessage when factory onSend is invoked', () => {
42
+ const factory: IChatBodyPlaceholderFactory = {
43
+ create: jest.fn().mockReturnValue(null)
44
+ };
45
+
46
+ const sendSpy = jest.spyOn(model, 'sendMessage');
47
+
48
+ // Simulate what ChatBodyPlaceholder component does:
49
+ factory.create({ onSend: (body: string) => model.sendMessage({ body }) });
50
+
51
+ // Invoke the onSend passed to factory
52
+ const props = (factory.create as jest.Mock).mock
53
+ .calls[0][0] as IChatBodyPlaceholderFactory.IProps;
54
+ props.onSend('Hello world');
55
+
56
+ expect(sendSpy).toHaveBeenCalledWith({ body: 'Hello world' });
57
+ });
58
+
59
+ it('should not fail when factory returns null', () => {
60
+ const factory: IChatBodyPlaceholderFactory = {
61
+ create: jest.fn().mockReturnValue(null)
62
+ };
63
+
64
+ const result = factory.create({
65
+ onSend: (body: string) => model.sendMessage({ body })
66
+ });
67
+ expect(result).toBeNull();
68
+ });
69
+ });
@@ -0,0 +1,135 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { RenderMimeRegistry } from '@jupyterlab/rendermime';
7
+ import { Widget } from '@lumino/widgets';
8
+
9
+ import { IChatPlaceholderFactory } from '../tokens';
10
+ import { MultiChatPanel } from '../widgets/multichat-panel';
11
+ import { defaultPlaceholder, Placeholder } from '../widgets/placeholder';
12
+
13
+ describe('MultiChatPanel', () => {
14
+ let rmRegistry: RenderMimeRegistry;
15
+
16
+ beforeEach(() => {
17
+ rmRegistry = new RenderMimeRegistry();
18
+ });
19
+
20
+ describe('placeholderFactory', () => {
21
+ it('should use defaultPlaceholder when no factory is provided', () => {
22
+ const panel = new MultiChatPanel({ rmRegistry });
23
+ const placeholder = Array.from(panel.widgets).find(
24
+ w => w instanceof defaultPlaceholder
25
+ );
26
+ expect(placeholder).toBeInstanceOf(defaultPlaceholder);
27
+ panel.dispose();
28
+ });
29
+
30
+ it('should call factory.create when a factory is provided', () => {
31
+ const factory: IChatPlaceholderFactory = {
32
+ create: jest.fn().mockReturnValue(new Widget())
33
+ };
34
+ const panel = new MultiChatPanel({
35
+ rmRegistry,
36
+ placeholderFactory: factory
37
+ });
38
+ expect(factory.create).toHaveBeenCalledTimes(1);
39
+ panel.dispose();
40
+ });
41
+
42
+ it('should add the widget returned by the factory to the panel', () => {
43
+ const customWidget = new Widget();
44
+ const factory: IChatPlaceholderFactory = {
45
+ create: jest.fn().mockReturnValue(customWidget)
46
+ };
47
+ const panel = new MultiChatPanel({
48
+ rmRegistry,
49
+ placeholderFactory: factory
50
+ });
51
+ expect(Array.from(panel.widgets)).toContain(customWidget);
52
+ panel.dispose();
53
+ });
54
+
55
+ it('should not use defaultPlaceholder when a factory is provided', () => {
56
+ const factory: IChatPlaceholderFactory = {
57
+ create: jest.fn().mockReturnValue(new Widget())
58
+ };
59
+ const panel = new MultiChatPanel({
60
+ rmRegistry,
61
+ placeholderFactory: factory
62
+ });
63
+ const placeholder = Array.from(panel.widgets).find(
64
+ w => w instanceof defaultPlaceholder
65
+ );
66
+ expect(placeholder).toBeUndefined();
67
+ panel.dispose();
68
+ });
69
+
70
+ it('should pass empty chatNames in props when no chats are loaded', () => {
71
+ const factory: IChatPlaceholderFactory = {
72
+ create: jest.fn().mockReturnValue(new Widget())
73
+ };
74
+ const panel = new MultiChatPanel({
75
+ rmRegistry,
76
+ placeholderFactory: factory
77
+ });
78
+ const props = (factory.create as jest.Mock).mock
79
+ .calls[0][0] as Placeholder.IProps;
80
+ expect(props.chatNames).toEqual({});
81
+ panel.dispose();
82
+ });
83
+
84
+ it('should pass undefined onCreate when createModel is not provided', () => {
85
+ const factory: IChatPlaceholderFactory = {
86
+ create: jest.fn().mockReturnValue(new Widget())
87
+ };
88
+ const panel = new MultiChatPanel({
89
+ rmRegistry,
90
+ placeholderFactory: factory
91
+ });
92
+ const props = (factory.create as jest.Mock).mock
93
+ .calls[0][0] as Placeholder.IProps;
94
+ expect(props.onCreate).toBeUndefined();
95
+ panel.dispose();
96
+ });
97
+
98
+ it('should pass onCreate when createModel is provided', () => {
99
+ const factory: IChatPlaceholderFactory = {
100
+ create: jest.fn().mockReturnValue(new Widget())
101
+ };
102
+ const createModel = jest.fn().mockResolvedValue({});
103
+ const panel = new MultiChatPanel({
104
+ rmRegistry,
105
+ placeholderFactory: factory,
106
+ createModel
107
+ });
108
+ const props = (factory.create as jest.Mock).mock
109
+ .calls[0][0] as Placeholder.IProps;
110
+ expect(props.onCreate).toBeDefined();
111
+ panel.dispose();
112
+ });
113
+
114
+ it('should call factory.create again when the placeholder is re-added after closing a chat', async () => {
115
+ const factory: IChatPlaceholderFactory = {
116
+ create: jest.fn().mockReturnValue(new Widget())
117
+ };
118
+ const createModel = jest.fn().mockResolvedValue({});
119
+ const panel = new MultiChatPanel({
120
+ rmRegistry,
121
+ placeholderFactory: factory,
122
+ createModel
123
+ });
124
+
125
+ // Simulate opening and closing a chat to trigger _addPlaceholder again.
126
+ const { MockChatModel } = await import('./mocks');
127
+ const model = new MockChatModel();
128
+ panel.open({ model, displayName: 'test-chat' });
129
+ panel.disposeLoadedModel('test-chat');
130
+
131
+ expect(factory.create).toHaveBeenCalledTimes(2);
132
+ panel.dispose();
133
+ });
134
+ });
135
+ });
@@ -29,6 +29,7 @@ import {
29
29
  IMessagePreambleRegistry
30
30
  } from '../registers';
31
31
  import { ChatArea } from '../types';
32
+ import { IChatBodyPlaceholderFactory } from '../tokens';
32
33
 
33
34
  export function ChatBody(props: Chat.IChatProps): JSX.Element {
34
35
  const { model } = props;
@@ -178,6 +179,11 @@ export namespace Chat {
178
179
  * The welcome message.
179
180
  */
180
181
  welcomeMessage?: string;
182
+ /**
183
+ * Optional factory to create chat body placeholder shown when the chat has
184
+ * no messages.
185
+ */
186
+ chatBodyPlaceholderFactory?: IChatBodyPlaceholderFactory;
181
187
  /**
182
188
  * The area where the chat is displayed.
183
189
  */