@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.
- package/lib/__tests__/input-model.spec.d.ts +9 -0
- package/lib/__tests__/input-model.spec.js +102 -0
- package/lib/__tests__/multichat-panel.spec.js +78 -27
- package/lib/components/messages/message.js +15 -8
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/input-model.d.ts +50 -2
- package/lib/input-model.js +57 -1
- package/lib/model.js +1 -1
- package/lib/tokens.d.ts +21 -5
- package/lib/types.d.ts +1 -4
- package/lib/utils.d.ts +11 -0
- package/lib/utils.js +42 -0
- package/lib/widgets/multichat-panel.d.ts +20 -20
- package/lib/widgets/multichat-panel.js +32 -48
- package/package.json +2 -1
- package/src/__tests__/input-model.spec.ts +129 -0
- package/src/__tests__/multichat-panel.spec.ts +93 -37
- package/src/components/messages/message.tsx +21 -11
- package/src/index.ts +1 -0
- package/src/input-model.ts +106 -4
- package/src/model.ts +1 -1
- package/src/tokens.ts +21 -5
- package/src/types.ts +6 -4
- package/src/utils.ts +59 -0
- package/src/widgets/multichat-panel.tsx +53 -66
|
@@ -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
|
|
34
|
+
* A signal emitting when a chat panel is opened in the sidepanel.
|
|
32
35
|
*/
|
|
33
|
-
get chatOpened(): ISignal<MultiChatPanel,
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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.
|
|
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 {
|
|
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',
|
|
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 {
|
|
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: (
|
|
100
|
-
updateMessage(message.id,
|
|
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,
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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]
|