@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.
@@ -0,0 +1,9 @@
1
+ declare module '../types' {
2
+ interface IMessageMetadata {
3
+ persona?: string;
4
+ model?: {
5
+ id: string;
6
+ };
7
+ }
8
+ }
9
+ export {};
@@ -0,0 +1,102 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { InputModel } from '../input-model';
6
+ describe('test input model', () => {
7
+ describe('metadata', () => {
8
+ it('should start with empty metadata', () => {
9
+ const model = new InputModel({ onSend: jest.fn() });
10
+ expect(model.getMetadata()).toEqual({});
11
+ });
12
+ it('should seed metadata from options', () => {
13
+ const model = new InputModel({
14
+ onSend: jest.fn(),
15
+ metadata: { persona: 'kiro' }
16
+ });
17
+ expect(model.getMetadata()).toEqual({ persona: 'kiro' });
18
+ });
19
+ it('should merge patches with updateMetadata', () => {
20
+ const model = new InputModel({ onSend: jest.fn() });
21
+ model.updateMetadata({ persona: 'kiro' });
22
+ model.updateMetadata({ model: { id: 'claude-opus-48' } });
23
+ expect(model.getMetadata()).toEqual({
24
+ persona: 'kiro',
25
+ model: { id: 'claude-opus-48' }
26
+ });
27
+ });
28
+ it('should overwrite existing keys on update', () => {
29
+ const model = new InputModel({ onSend: jest.fn() });
30
+ model.updateMetadata({ persona: 'kiro' });
31
+ model.updateMetadata({ persona: 'jupyternaut' });
32
+ expect(model.getMetadata()).toEqual({ persona: 'jupyternaut' });
33
+ });
34
+ it('should shallow-merge: a top-level key replaces the whole value', () => {
35
+ const model = new InputModel({ onSend: jest.fn() });
36
+ model.updateMetadata({ model: { id: 'a' } });
37
+ // Passing `model` again replaces it wholesale (no recursive merge).
38
+ model.updateMetadata({ model: { id: 'b' } });
39
+ expect(model.getMetadata()).toEqual({ model: { id: 'b' } });
40
+ });
41
+ it('should not be mutated by later changes to a patch', () => {
42
+ const model = new InputModel({ onSend: jest.fn() });
43
+ const patch = { model: { id: 'a' } };
44
+ model.updateMetadata(patch);
45
+ // Mutating the patch after the fact must not reach into stored metadata.
46
+ patch.model.id = 'tampered';
47
+ expect(model.getMetadata()).toEqual({ model: { id: 'a' } });
48
+ });
49
+ it('should clear metadata', () => {
50
+ const model = new InputModel({ onSend: jest.fn() });
51
+ model.updateMetadata({ persona: 'kiro' });
52
+ model.clearMetadata();
53
+ expect(model.getMetadata()).toEqual({});
54
+ });
55
+ it('should emit metadataChanged on update and clear', () => {
56
+ var _a;
57
+ const model = new InputModel({ onSend: jest.fn() });
58
+ const emitted = [];
59
+ (_a = model.metadataChanged) === null || _a === void 0 ? void 0 : _a.connect((_, metadata) => {
60
+ emitted.push({ ...metadata });
61
+ });
62
+ model.updateMetadata({ persona: 'kiro' });
63
+ model.clearMetadata();
64
+ expect(emitted).toEqual([{ persona: 'kiro' }, {}]);
65
+ });
66
+ });
67
+ describe('send', () => {
68
+ it('should attach metadata to the message when non-empty', () => {
69
+ const onSend = jest.fn();
70
+ const model = new InputModel({ onSend });
71
+ model.updateMetadata({ persona: 'kiro' });
72
+ model.send('hello');
73
+ const message = onSend.mock.calls[0][0];
74
+ expect(message.body).toBe('hello');
75
+ expect(message.metadata).toEqual({ persona: 'kiro' });
76
+ });
77
+ it('should omit metadata from the message when empty', () => {
78
+ const onSend = jest.fn();
79
+ const model = new InputModel({ onSend });
80
+ model.send('hello');
81
+ const message = onSend.mock.calls[0][0];
82
+ expect(message.metadata).toBeUndefined();
83
+ });
84
+ it('should send a copy of the metadata', () => {
85
+ const onSend = jest.fn();
86
+ const model = new InputModel({ onSend });
87
+ model.updateMetadata({ persona: 'kiro' });
88
+ model.send('hello');
89
+ const message = onSend.mock.calls[0][0];
90
+ model.updateMetadata({ persona: 'jupyternaut' });
91
+ expect(message.metadata).toEqual({ persona: 'kiro' });
92
+ });
93
+ it('should keep metadata after sending (sticky selection)', () => {
94
+ // Unlike attachments/mentions, metadata carries the picker's
95
+ // persona/model/settings selection, which is sticky across messages.
96
+ const model = new InputModel({ onSend: jest.fn() });
97
+ model.updateMetadata({ persona: 'kiro' });
98
+ model.send('hello');
99
+ expect(model.getMetadata()).toEqual({ persona: 'kiro' });
100
+ });
101
+ });
102
+ });
@@ -3,9 +3,11 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  import { RenderMimeRegistry } from '@jupyterlab/rendermime';
6
+ import { ObservableList } from '@jupyterlab/observables';
6
7
  import { Widget } from '@lumino/widgets';
7
8
  import { MultiChatPanel } from '../widgets/multichat-panel';
8
9
  import { defaultPlaceholder } from '../widgets/placeholder';
10
+ import { MockChatModel } from './mocks';
9
11
  describe('MultiChatPanel', () => {
10
12
  let rmRegistry;
11
13
  beforeEach(() => {
@@ -56,31 +58,6 @@ describe('MultiChatPanel', () => {
56
58
  panel.dispose();
57
59
  });
58
60
  });
59
- describe('openInMain', () => {
60
- it('should not dispose the model when moving to main area', async () => {
61
- const { MockChatModel } = await import('./mocks');
62
- const model = new MockChatModel();
63
- const openInMain = jest.fn().mockResolvedValue(true);
64
- const panel = new MultiChatPanel({ rmRegistry, openInMain });
65
- panel.open({ model, displayName: 'test-chat' });
66
- // Find the 'moveMain' toolbar button by name and invoke its click handler directly.
67
- // ToolbarButton stores the onClick handler independently of DOM/React rendering,
68
- // so this works without attaching the widget to the document.
69
- const toolbar = panel.current.toolbar;
70
- const names = Array.from(toolbar.names());
71
- const moveMainIdx = names.indexOf('moveMain');
72
- expect(moveMainIdx).toBeGreaterThan(-1);
73
- const moveButton = toolbar.layout.widgets[moveMainIdx];
74
- moveButton.onClick();
75
- // Wait for the async onClick handler: openInMain resolves, then onClose is called.
76
- await Promise.resolve();
77
- expect(openInMain).toHaveBeenCalledWith(model.name);
78
- expect(model.isDisposed).toBe(false);
79
- expect(panel.getLoadedModel('test-chat')).toBeUndefined();
80
- model.dispose();
81
- panel.dispose();
82
- });
83
- });
84
61
  describe('placeholderFactory', () => {
85
62
  it('should use defaultPlaceholder when no factory is provided', () => {
86
63
  const panel = new MultiChatPanel({ rmRegistry });
@@ -164,7 +141,7 @@ describe('MultiChatPanel', () => {
164
141
  expect(props.onCreate).toBeDefined();
165
142
  panel.dispose();
166
143
  });
167
- it('should call factory.create again when the placeholder is re-added after closing a chat', async () => {
144
+ it('should call factory.create again when the placeholder is re-added after closing a chat', () => {
168
145
  const factory = {
169
146
  create: jest.fn().mockReturnValue(new Widget())
170
147
  };
@@ -175,7 +152,6 @@ describe('MultiChatPanel', () => {
175
152
  createModel
176
153
  });
177
154
  // Simulate opening and closing a chat to trigger _addPlaceholder again.
178
- const { MockChatModel } = await import('./mocks');
179
155
  const model = new MockChatModel();
180
156
  panel.open({ model, displayName: 'test-chat' });
181
157
  panel.unsetLoadedModel('test-chat');
@@ -194,4 +170,79 @@ describe('MultiChatPanel', () => {
194
170
  panel.dispose();
195
171
  });
196
172
  });
173
+ describe('chatToolbarFactory', () => {
174
+ const makeFactory = (items) => jest.fn().mockReturnValue(new ObservableList({ values: items }));
175
+ it('should call factory with the panel when a chat is opened', () => {
176
+ const factory = makeFactory([{ name: 'myButton', widget: new Widget() }]);
177
+ const panel = new MultiChatPanel({
178
+ rmRegistry,
179
+ chatToolbarFactory: factory
180
+ });
181
+ const model = new MockChatModel();
182
+ const chatWidget = panel.open({ model, displayName: 'test' });
183
+ expect(factory).toHaveBeenCalledTimes(1);
184
+ expect(factory.mock.calls[0][0].widget).toBe(chatWidget);
185
+ panel.dispose();
186
+ });
187
+ it('should add the created widget to the chat toolbar', () => {
188
+ const panel = new MultiChatPanel({
189
+ rmRegistry,
190
+ chatToolbarFactory: makeFactory([
191
+ { name: 'myButton', widget: new Widget() }
192
+ ])
193
+ });
194
+ const model = new MockChatModel();
195
+ panel.open({ model, displayName: 'test' });
196
+ const toolbar = panel.current.toolbar;
197
+ expect(Array.from(toolbar.names())).toContain('myButton');
198
+ panel.dispose();
199
+ });
200
+ it('should insert the item before "close"', () => {
201
+ const panel = new MultiChatPanel({
202
+ rmRegistry,
203
+ chatToolbarFactory: makeFactory([
204
+ { name: 'myButton', widget: new Widget() }
205
+ ])
206
+ });
207
+ const model = new MockChatModel();
208
+ panel.open({ model, displayName: 'test' });
209
+ const toolbar = panel.current.toolbar;
210
+ const names = Array.from(toolbar.names());
211
+ expect(names.indexOf('myButton')).toBeLessThan(names.indexOf('close'));
212
+ panel.dispose();
213
+ });
214
+ it('should add all items when factory returns multiple items', () => {
215
+ const panel = new MultiChatPanel({
216
+ rmRegistry,
217
+ chatToolbarFactory: makeFactory([
218
+ { name: 'item1', widget: new Widget() },
219
+ { name: 'item2', widget: new Widget() }
220
+ ])
221
+ });
222
+ const model = new MockChatModel();
223
+ panel.open({ model, displayName: 'test' });
224
+ const toolbar = panel.current.toolbar;
225
+ const names = Array.from(toolbar.names());
226
+ expect(names).toContain('item1');
227
+ expect(names).toContain('item2');
228
+ panel.dispose();
229
+ });
230
+ it('should call factory again for each newly opened chat', () => {
231
+ const factory = jest.fn().mockImplementation(() => new ObservableList({
232
+ values: [{ name: 'myButton', widget: new Widget() }]
233
+ }));
234
+ const panel = new MultiChatPanel({
235
+ rmRegistry,
236
+ chatToolbarFactory: factory
237
+ });
238
+ const model1 = new MockChatModel();
239
+ panel.open({ model: model1, displayName: 'chat1' });
240
+ panel.unsetLoadedModel('chat1');
241
+ const model2 = new MockChatModel();
242
+ panel.open({ model: model2, displayName: 'chat2' });
243
+ expect(factory).toHaveBeenCalledTimes(2);
244
+ expect(factory.mock.results[0].value).not.toBe(factory.mock.results[1].value);
245
+ panel.dispose();
246
+ });
247
+ });
197
248
  });
@@ -70,7 +70,7 @@ export function ChatMessageBase(props) {
70
70
  });
71
71
  const inputModel = new InputModel({
72
72
  chatContext: model.createChatContext(),
73
- onSend: (input, model) => updateMessage(message.id, input, model),
73
+ onSend: (newMessage) => updateMessage(message.id, newMessage),
74
74
  onCancel: () => cancelEdition(),
75
75
  value: body,
76
76
  activeCellManager: model.activeCellManager,
@@ -92,18 +92,25 @@ export function ChatMessageBase(props) {
92
92
  setEdit(false);
93
93
  }, [model, message]);
94
94
  // Update the content of the message.
95
- const updateMessage = useCallback((id, input, inputModel) => {
96
- var _a;
97
- if (!canEdit || !inputModel) {
95
+ const updateMessage = useCallback((id, newMessage) => {
96
+ const inputModel = model.getEditionModel(id);
97
+ if (!canEdit || !inputModel || !model.updateMessage) {
98
+ setEdit(false);
98
99
  return;
99
100
  }
100
101
  // Update the message
101
102
  const updatedMessage = { ...message };
102
- updatedMessage.body = input;
103
- updatedMessage.attachments = inputModel.attachments;
104
- updatedMessage.mentions = inputModel.mentions;
103
+ if (newMessage.body) {
104
+ updatedMessage.body = newMessage.body;
105
+ }
106
+ if (newMessage.attachments) {
107
+ updatedMessage.attachments = newMessage.attachments;
108
+ }
109
+ if (newMessage.mentions) {
110
+ updatedMessage.mentions = newMessage.mentions;
111
+ }
105
112
  model.updateMessage(id, updatedMessage);
106
- (_a = model.getEditionModel(message.id)) === null || _a === void 0 ? void 0 : _a.dispose();
113
+ inputModel.dispose();
107
114
  setEdit(false);
108
115
  }, [model, message, canEdit]);
109
116
  // Delete the message.
package/lib/index.d.ts CHANGED
@@ -8,4 +8,5 @@ export * from './registers';
8
8
  export * from './selection-watcher';
9
9
  export * from './tokens';
10
10
  export * from './types';
11
+ export * from './utils';
11
12
  export * from './widgets';
package/lib/index.js CHANGED
@@ -12,4 +12,5 @@ export * from './registers';
12
12
  export * from './selection-watcher';
13
13
  export * from './tokens';
14
14
  export * from './types';
15
+ export * from './utils';
15
16
  export * from './widgets';
@@ -4,7 +4,7 @@ import { ISignal } from '@lumino/signaling';
4
4
  import { IActiveCellManager } from './active-cell-manager';
5
5
  import { ISelectionWatcher } from './selection-watcher';
6
6
  import { IChatContext } from './model';
7
- import { IAttachment, IUser } from './types';
7
+ import { IAttachment, IMessageMetadata, INewMessage, IUser } from './types';
8
8
  /**
9
9
  * The chat input interface.
10
10
  */
@@ -118,6 +118,27 @@ export interface IInputModel extends IDisposable {
118
118
  * Clear mentions list.
119
119
  */
120
120
  clearMentions(): void;
121
+ /**
122
+ * Get the metadata to attach to the next message to send.
123
+ */
124
+ getMetadata(): IMessageMetadata;
125
+ /**
126
+ * Merge a patch into the metadata to attach to the next message to send.
127
+ *
128
+ * This is a *shallow* merge: each key in `patch` replaces the existing value
129
+ * at that key wholesale, rather than being merged recursively. To change a
130
+ * nested field, pass the whole new value for its top-level key. The patch is
131
+ * deep-copied, so mutating it afterwards won't affect the stored metadata.
132
+ */
133
+ updateMetadata(patch: IMessageMetadata): void;
134
+ /**
135
+ * Clear the metadata.
136
+ */
137
+ clearMetadata(): void;
138
+ /**
139
+ * A signal emitting when the metadata has changed.
140
+ */
141
+ readonly metadataChanged?: ISignal<IInputModel, IMessageMetadata>;
121
142
  /**
122
143
  * A signal emitting when disposing of the model.
123
144
  */
@@ -240,6 +261,27 @@ export declare class InputModel implements IInputModel {
240
261
  * Clear mentions list.
241
262
  */
242
263
  clearMentions: () => void;
264
+ /**
265
+ * Get the metadata to attach to the next message to send.
266
+ */
267
+ getMetadata(): IMessageMetadata;
268
+ /**
269
+ * Merge a patch into the metadata to attach to the next message to send.
270
+ *
271
+ * This is a *shallow* merge: each key in `patch` replaces the existing value
272
+ * at that key wholesale, rather than being merged recursively. To change a
273
+ * nested field, pass the whole new value for its top-level key. The patch is
274
+ * deep-copied, so mutating it afterwards won't affect the stored metadata.
275
+ */
276
+ updateMetadata: (patch: IMessageMetadata) => void;
277
+ /**
278
+ * Clear the metadata.
279
+ */
280
+ clearMetadata: () => void;
281
+ /**
282
+ * A signal emitting when the metadata has changed.
283
+ */
284
+ get metadataChanged(): ISignal<IInputModel, IMessageMetadata>;
243
285
  /**
244
286
  * Dispose the input model.
245
287
  */
@@ -260,6 +302,7 @@ export declare class InputModel implements IInputModel {
260
302
  private _currentWord;
261
303
  private _attachments;
262
304
  private _mentions;
305
+ private _metadata;
263
306
  private _activeCellManager;
264
307
  private _selectionWatcher;
265
308
  private _documentManager;
@@ -270,6 +313,7 @@ export declare class InputModel implements IInputModel {
270
313
  private _configChanged;
271
314
  private _focusInputSignal;
272
315
  private _attachmentsChanged;
316
+ private _metadataChanged;
273
317
  private _onDisposed;
274
318
  private _isDisposed;
275
319
  }
@@ -284,7 +328,7 @@ export declare namespace InputModel {
284
328
  * @param content - the content of the message.
285
329
  * @param model - the model of the input sending the message.
286
330
  */
287
- onSend: (content: string, model?: InputModel) => void;
331
+ onSend: (message: INewMessage) => void;
288
332
  /**
289
333
  * Function that should cancel the message edition.
290
334
  */
@@ -301,6 +345,10 @@ export declare namespace InputModel {
301
345
  * The initial mentions.
302
346
  */
303
347
  mentions?: IUser[];
348
+ /**
349
+ * The initial metadata.
350
+ */
351
+ metadata?: IMessageMetadata;
304
352
  /**
305
353
  * The current cursor index.
306
354
  * This refers to the index of the character in front of the cursor.
@@ -14,8 +14,27 @@ export class InputModel {
14
14
  * Function to send a message.
15
15
  */
16
16
  this.send = (input) => {
17
- this._onSend(input, this);
17
+ const message = {
18
+ body: input
19
+ };
20
+ // Add the attachments
21
+ if (this.attachments.length) {
22
+ message.attachments = [...this.attachments];
23
+ }
24
+ // Add the mentions
25
+ if (this.mentions.length) {
26
+ message.mentions = [...this.mentions];
27
+ }
28
+ // Add the metadata
29
+ if (Object.keys(this._metadata).length) {
30
+ message.metadata = { ...this._metadata };
31
+ }
32
+ // Send the message
33
+ this._onSend(message);
34
+ // Clear the input
18
35
  this.value = '';
36
+ this.clearAttachments();
37
+ this.clearMentions();
19
38
  };
20
39
  /**
21
40
  * Add attachment to send with next message.
@@ -75,6 +94,29 @@ export class InputModel {
75
94
  this.clearMentions = () => {
76
95
  this._mentions = [];
77
96
  };
97
+ /**
98
+ * Merge a patch into the metadata to attach to the next message to send.
99
+ *
100
+ * This is a *shallow* merge: each key in `patch` replaces the existing value
101
+ * at that key wholesale, rather than being merged recursively. To change a
102
+ * nested field, pass the whole new value for its top-level key. The patch is
103
+ * deep-copied, so mutating it afterwards won't affect the stored metadata.
104
+ */
105
+ this.updateMetadata = (patch) => {
106
+ // Deep-copy the patch so a caller mutating a nested field afterwards can't
107
+ // reach into the stored metadata. `this._metadata` only ever holds values
108
+ // that were themselves deep-copied on the way in, so a shallow spread of it
109
+ // is safe.
110
+ this._metadata = { ...this._metadata, ...structuredClone(patch) };
111
+ this._metadataChanged.emit(this._metadata);
112
+ };
113
+ /**
114
+ * Clear the metadata.
115
+ */
116
+ this.clearMetadata = () => {
117
+ this._metadata = {};
118
+ this._metadataChanged.emit(this._metadata);
119
+ };
78
120
  this._cursorIndex = null;
79
121
  this._currentWord = null;
80
122
  this._valueChanged = new Signal(this);
@@ -83,6 +125,7 @@ export class InputModel {
83
125
  this._configChanged = new Signal(this);
84
126
  this._focusInputSignal = new Signal(this);
85
127
  this._attachmentsChanged = new Signal(this);
128
+ this._metadataChanged = new Signal(this);
86
129
  this._onDisposed = new Signal(this);
87
130
  this._isDisposed = false;
88
131
  this._id = (_a = options.id) !== null && _a !== void 0 ? _a : `input-${UUID.uuid4()}`;
@@ -91,6 +134,7 @@ export class InputModel {
91
134
  this._value = options.value || '';
92
135
  this._attachments = options.attachments || [];
93
136
  this._mentions = options.mentions || [];
137
+ this._metadata = options.metadata || {};
94
138
  this.cursorIndex = options.cursorIndex || this.value.length;
95
139
  this._activeCellManager = (_b = options.activeCellManager) !== null && _b !== void 0 ? _b : null;
96
140
  this._selectionWatcher = (_c = options.selectionWatcher) !== null && _c !== void 0 ? _c : null;
@@ -262,6 +306,18 @@ export class InputModel {
262
306
  this._mentions.splice(index, 1);
263
307
  }
264
308
  }
309
+ /**
310
+ * Get the metadata to attach to the next message to send.
311
+ */
312
+ getMetadata() {
313
+ return this._metadata;
314
+ }
315
+ /**
316
+ * A signal emitting when the metadata has changed.
317
+ */
318
+ get metadataChanged() {
319
+ return this._metadataChanged;
320
+ }
265
321
  /**
266
322
  * Dispose the input model.
267
323
  */
package/lib/model.js CHANGED
@@ -57,7 +57,7 @@ export class AbstractChatModel {
57
57
  config: {
58
58
  sendWithShiftEnter: config.sendWithShiftEnter
59
59
  },
60
- onSend: (input) => this.sendMessage({ body: input })
60
+ onSend: this.sendMessage.bind(this)
61
61
  });
62
62
  this._commands = options.commands;
63
63
  this._activeCellManager = (_c = options.activeCellManager) !== null && _c !== void 0 ? _c : null;
package/lib/tokens.d.ts CHANGED
@@ -1,18 +1,34 @@
1
- import { IWidgetTracker, MainAreaWidget } from '@jupyterlab/apputils';
1
+ import { IWidgetTracker } from '@jupyterlab/apputils';
2
2
  import { Token } from '@lumino/coreutils';
3
3
  import { Widget } from '@lumino/widgets';
4
4
  import { ChatWidget, Placeholder } from './widgets';
5
5
  import { IChatModel } from './model';
6
+ import { ChatArea } from './types';
6
7
  /**
7
- * The main area chat widget type.
8
+ * The interface for any widget displaying a chat (main area or side panel).
8
9
  */
9
- export type MainAreaChat = MainAreaWidget<ChatWidget> & {
10
+ export interface IChatPanel extends Widget {
11
+ /**
12
+ * The chat widget embedded in the panel.
13
+ */
14
+ widget: ChatWidget;
15
+ /**
16
+ * The model of the chat widget.
17
+ */
10
18
  model: IChatModel;
11
- };
19
+ /**
20
+ * The area of the panel.
21
+ */
22
+ area: ChatArea;
23
+ /**
24
+ * The chat panel toolbar.
25
+ */
26
+ toolbar: Widget;
27
+ }
12
28
  /**
13
29
  * the chat tracker type.
14
30
  */
15
- export type IChatTracker = IWidgetTracker<ChatWidget | MainAreaChat>;
31
+ export type IChatTracker = IWidgetTracker<IChatPanel>;
16
32
  /**
17
33
  * A chat tracker token.
18
34
  */
package/lib/types.d.ts CHANGED
@@ -161,10 +161,7 @@ export interface IChatHistory {
161
161
  /**
162
162
  * The content of a new message.
163
163
  */
164
- export interface INewMessage {
165
- body: string;
166
- id?: string;
167
- }
164
+ export type INewMessage<T = IUser, U = IAttachment> = Partial<Pick<IMessageContent<T, U>, 'body' | 'attachments' | 'mentions' | 'metadata' | 'mime_model' | 'sender'>>;
168
165
  /**
169
166
  * The attachment type. Jupyter Chat allows for two types of attachments
170
167
  * currently:
package/lib/utils.d.ts CHANGED
@@ -1,6 +1,10 @@
1
+ import { ToolbarRegistry } from '@jupyterlab/apputils';
1
2
  import { CodeMirrorEditor } from '@jupyterlab/codemirror';
2
3
  import { Notebook } from '@jupyterlab/notebook';
4
+ import { IObservableList } from '@jupyterlab/observables';
5
+ import { CommandRegistry } from '@lumino/commands';
3
6
  import { Widget } from '@lumino/widgets';
7
+ import { IChatPanel } from './tokens';
4
8
  import { IUser } from './types';
5
9
  /**
6
10
  * Gets the editor instance used by a document widget. Returns `null` if unable.
@@ -24,3 +28,10 @@ export declare function replaceMentionToSpan(content: string, user: IUser): stri
24
28
  * @param user - the user mentioned.
25
29
  */
26
30
  export declare function replaceSpanToMention(content: string, user: IUser): string;
31
+ /**
32
+ * Wraps a toolbar factory so that every CommandToolbarButton it creates
33
+ * automatically receives the panel's area as an arg.
34
+ * This lets commands branch on `args.area` without each plugin having to
35
+ * register a per-item toolbarRegistry.addFactory call.
36
+ */
37
+ export declare function injectAreaArg(baseFactory: (panel: IChatPanel) => IObservableList<ToolbarRegistry.IToolbarItem>, commands: CommandRegistry): (panel: IChatPanel) => IObservableList<ToolbarRegistry.IToolbarItem>;
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
+ }