@jupyter/chat 0.23.0-alpha.2 → 0.23.0-alpha.4

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/utils.js CHANGED
@@ -2,10 +2,12 @@
2
2
  * Copyright (c) Jupyter Development Team.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
+ import { CommandToolbarButton } from '@jupyterlab/apputils';
5
6
  import { CodeMirrorEditor } from '@jupyterlab/codemirror';
6
7
  import { DocumentWidget } from '@jupyterlab/docregistry';
7
8
  import { FileEditor } from '@jupyterlab/fileeditor';
8
9
  import { Notebook } from '@jupyterlab/notebook';
10
+ import { ObservableList } from '@jupyterlab/observables';
9
11
  const MENTION_CLASS = 'jp-chat-mention';
10
12
  /**
11
13
  * Gets the editor instance used by a document widget. Returns `null` if unable.
@@ -66,3 +68,43 @@ export function replaceSpanToMention(content, user) {
66
68
  const regex = new RegExp(mentionEl, 'g');
67
69
  return content.replace(regex, mention);
68
70
  }
71
+ /**
72
+ * Wraps a toolbar factory so that every CommandToolbarButton it creates
73
+ * automatically receives the panel's area as an arg.
74
+ * This lets commands branch on `args.area` without each plugin having to
75
+ * register a per-item toolbarRegistry.addFactory call.
76
+ */
77
+ export function injectAreaArg(baseFactory, commands) {
78
+ return (panel) => {
79
+ const base = baseFactory(panel);
80
+ const inject = (item) => {
81
+ const { widget } = item;
82
+ if (!(widget instanceof CommandToolbarButton)) {
83
+ return item;
84
+ }
85
+ return {
86
+ name: item.name,
87
+ widget: new CommandToolbarButton({
88
+ commands,
89
+ id: widget.commandId,
90
+ args: { area: panel.area }
91
+ })
92
+ };
93
+ };
94
+ const wrapped = new ObservableList({
95
+ values: Array.from({ length: base.length }, (_, i) => inject(base.get(i)))
96
+ });
97
+ base.changed.connect((_, change) => {
98
+ if (change.type === 'add') {
99
+ wrapped.insertAll(change.newIndex, change.newValues.map(inject));
100
+ }
101
+ else if (change.type === 'remove') {
102
+ wrapped.removeRange(change.oldIndex, change.oldIndex + change.oldValues.length);
103
+ }
104
+ else if (change.type === 'set') {
105
+ change.newValues.forEach((item, i) => wrapped.set(change.newIndex + i, inject(item)));
106
+ }
107
+ });
108
+ return wrapped;
109
+ };
110
+ }
@@ -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) {
@@ -133,6 +133,7 @@ export class MultiChatPanel extends PanelWithToolbar {
133
133
  this.open(addChatArgs);
134
134
  },
135
135
  icon: addIcon,
136
+ label: this._trans.__('New chat'),
136
137
  tooltip: this._trans.__('Create a new chat')
137
138
  });
138
139
  addChat.addClass(ADD_BUTTON_CLASS);
@@ -168,7 +169,7 @@ export class MultiChatPanel extends PanelWithToolbar {
168
169
  : undefined;
169
170
  }
170
171
  /**
171
- * A signal emitting when a chat widget is opened in the panel.
172
+ * A signal emitting when a chat panel is opened in the sidepanel.
172
173
  */
173
174
  get chatOpened() {
174
175
  return this._chatOpened;
@@ -298,8 +299,8 @@ export class MultiChatPanel extends PanelWithToolbar {
298
299
  const widget = new SidePanelWidget({
299
300
  widget: chatWidget,
300
301
  displayName: name,
301
- openInMain: this._openInMain,
302
302
  renameChat: this._renameChat,
303
+ toolbarFactory: this._chatToolbarFactory,
303
304
  onClose: (name, disposeModel = true) => {
304
305
  this.unsetLoadedModel(name, disposeModel);
305
306
  },
@@ -314,7 +315,7 @@ export class MultiChatPanel extends PanelWithToolbar {
314
315
  if (this._chatSelectorPopup) {
315
316
  this._chatSelectorPopup.setCurrentChat(name);
316
317
  }
317
- this._chatOpened.emit(chatWidget);
318
+ this._chatOpened.emit(widget);
318
319
  return chatWidget;
319
320
  }
320
321
  /**
@@ -372,14 +373,8 @@ export class MultiChatPanel extends PanelWithToolbar {
372
373
  */
373
374
  class SidePanelWidget extends ReactivePanelWithToolbar {
374
375
  constructor(options) {
375
- var _a, _b, _c, _d;
376
+ var _a;
376
377
  super();
377
- /**
378
- * Enable/disable unread icon.
379
- */
380
- this._unreadChanged = (_, unread) => {
381
- this._markAsRead.enabled = unread.length > 0;
382
- };
383
378
  this._nameChanged = new Signal(this);
384
379
  this._chatWidget = options.widget;
385
380
  this._displayName = (_a = options.displayName) !== null && _a !== void 0 ? _a : options.widget.model.name;
@@ -399,18 +394,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
399
394
  });
400
395
  // Add the chat widget
401
396
  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
397
  if (options.renameChat) {
415
398
  const renameButton = new ToolbarButton({
416
399
  iconClass: 'jp-EditIcon',
@@ -447,21 +430,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
447
430
  });
448
431
  this.toolbar.addItem('rename', renameButton);
449
432
  }
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
433
  const closeButton = new ToolbarButton({
466
434
  icon: closeIcon,
467
435
  iconLabel: trans.__('Close the chat'),
@@ -471,9 +439,25 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
471
439
  }
472
440
  });
473
441
  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;
442
+ if (options.toolbarFactory) {
443
+ const items = options.toolbarFactory(this);
444
+ for (let i = 0; i < items.length; i++) {
445
+ const { name, widget } = items.get(i);
446
+ this.toolbar.insertBefore('close', name, widget);
447
+ }
448
+ items.changed.connect((_, change) => {
449
+ if (change.type === 'add') {
450
+ for (const { name, widget } of change.newValues) {
451
+ this.toolbar.insertBefore('close', name, widget);
452
+ }
453
+ }
454
+ else if (change.type === 'remove') {
455
+ for (const { widget } of change.oldValues) {
456
+ widget.dispose();
457
+ }
458
+ }
459
+ });
460
+ }
477
461
  }
478
462
  onAfterAttach(msg) {
479
463
  super.onAfterAttach(msg);
@@ -483,6 +467,12 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
483
467
  super.onResize(msg);
484
468
  this._updateReactiveToolbar();
485
469
  }
470
+ /**
471
+ * The area of the widget.
472
+ */
473
+ get area() {
474
+ return 'sidebar';
475
+ }
486
476
  /**
487
477
  * The chat widget embedded in the sidepanel widget.
488
478
  */
@@ -520,11 +510,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
520
510
  * Dispose of the resources held by the widget.
521
511
  */
522
512
  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
513
  super.dispose();
529
514
  }
530
515
  /**
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.4",
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,6 +46,7 @@
46
46
  "@emotion/react": "^11.10.5",
47
47
  "@emotion/styled": "^11.10.5",
48
48
  "@jupyter/react-components": "^0.15.2",
49
+ "@jupyter/ydoc": "^3.0.0 || ^4.0.0",
49
50
  "@jupyterlab/application": "^4.6.0",
50
51
  "@jupyterlab/apputils": "^4.7.0",
51
52
  "@jupyterlab/codeeditor": "^4.6.0",
@@ -55,6 +56,7 @@
55
56
  "@jupyterlab/filebrowser": "^4.6.0",
56
57
  "@jupyterlab/fileeditor": "^4.6.0",
57
58
  "@jupyterlab/notebook": "^4.6.0",
59
+ "@jupyterlab/observables": "^5.6.0",
58
60
  "@jupyterlab/rendermime": "^4.6.0",
59
61
  "@jupyterlab/translation": "^4.6.0",
60
62
  "@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
+ }
@@ -7,6 +7,8 @@
7
7
  * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests
8
8
  */
9
9
 
10
+ import type { IAwareness } from '@jupyter/ydoc';
11
+
10
12
  import { AbstractChatModel, IChatContext, IChatModel } from '../model';
11
13
  import { IMessage, IMessageContent, INewMessage } from '../types';
12
14
  import { MockChatModel, MockChatContext } from './mocks';
@@ -149,4 +151,20 @@ describe('test chat model', () => {
149
151
  expect(model.config.sendWithShiftEnter).toBeTruthy();
150
152
  });
151
153
  });
154
+
155
+ describe('awareness', () => {
156
+ it('should surface the model awareness on the context', () => {
157
+ class AwareChatModel extends MockChatModel {
158
+ readonly awareness = {} as IAwareness;
159
+ }
160
+ const model = new AwareChatModel();
161
+ const context = new MockChatContext({ model });
162
+ expect(context.awareness).toBe(model.awareness);
163
+ });
164
+
165
+ it('should be undefined when the model has no awareness', () => {
166
+ const context = new MockChatContext({ model: new MockChatModel() });
167
+ expect(context.awareness).toBeUndefined();
168
+ });
169
+ });
152
170
  });