jupyterlab-chat 0.24.1 → 0.24.2

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.
@@ -14,6 +14,14 @@ const TEST_USER = {
14
14
  initials: 'TU',
15
15
  color: '#aabbcc'
16
16
  };
17
+ const TEST_BOT = {
18
+ username: 'test-bot',
19
+ name: 'Test Bot',
20
+ display_name: 'Bot',
21
+ initials: 'BU',
22
+ color: '#aabbcc',
23
+ bot: true
24
+ };
17
25
  function makeWidgetConfig() {
18
26
  const owner = {};
19
27
  owner.config = {};
@@ -30,13 +38,48 @@ describe('LabChatModel', () => {
30
38
  user: TEST_USER,
31
39
  sharedModel
32
40
  });
33
- // Resolve the ready promise so messagesInserted can proceed.
34
- model.id = UUID.uuid4();
41
+ model.markDocumentSynced();
35
42
  });
36
43
  afterEach(() => {
37
44
  Signal.clearData(model);
38
45
  sharedModel.dispose();
39
46
  });
47
+ describe('message update from model', () => {
48
+ it('should set edited when user message is updated', async () => {
49
+ const id = model.sendMessage({
50
+ body: 'original',
51
+ sender: TEST_USER
52
+ });
53
+ await flushPromises();
54
+ model.updateMessage(id, {
55
+ type: 'msg',
56
+ id,
57
+ body: 'updated',
58
+ time: 1000,
59
+ sender: TEST_USER
60
+ });
61
+ await flushPromises();
62
+ expect(model.messages[0].body).toBe('updated');
63
+ expect(model.messages[0].edited).toBe(true);
64
+ });
65
+ it('should not set edited when bot message is updated', async () => {
66
+ const id = model.sendMessage({
67
+ body: 'original',
68
+ sender: TEST_BOT
69
+ });
70
+ await flushPromises();
71
+ model.updateMessage(id, {
72
+ type: 'msg',
73
+ id,
74
+ body: 'updated',
75
+ time: 1000,
76
+ sender: TEST_BOT
77
+ });
78
+ await flushPromises();
79
+ expect(model.messages[0].body).toBe('updated');
80
+ expect(model.messages[0].edited).toBeUndefined();
81
+ });
82
+ });
40
83
  describe('message attribute updates from shared model', () => {
41
84
  it('should reflect a body change', async () => {
42
85
  const id = UUID.uuid4();
@@ -80,26 +123,80 @@ describe('LabChatModel', () => {
80
123
  await flushPromises();
81
124
  expect(model.messages[0].mime_model).toEqual(mimeModel);
82
125
  });
83
- it('should not set edited when only mime_model changes', async () => {
84
- const id = UUID.uuid4();
85
- sharedModel.addMessage({
126
+ });
127
+ describe('ready resolves after messages are loaded', () => {
128
+ it('messages present in sharedModel before sync are buffered, not yet in model', () => {
129
+ const sm = YChat.create();
130
+ const m = new LabChatModel({
131
+ widgetConfig: makeWidgetConfig(),
132
+ user: TEST_USER,
133
+ sharedModel: sm
134
+ });
135
+ sm.addMessage({
86
136
  type: 'msg',
87
- id,
88
- body: '',
137
+ id: UUID.uuid4(),
138
+ body: 'pre-sync',
89
139
  time: 1000,
90
140
  sender: TEST_USER.username
91
141
  });
92
- await flushPromises();
93
- sharedModel.updateMessage(0, {
142
+ // Not yet synced: message must be buffered, not inserted.
143
+ expect(m.messages).toHaveLength(0);
144
+ sm.dispose();
145
+ });
146
+ it('messages are in model.messages when ready resolves', async () => {
147
+ const sm = YChat.create();
148
+ const m = new LabChatModel({
149
+ widgetConfig: makeWidgetConfig(),
150
+ user: TEST_USER,
151
+ sharedModel: sm
152
+ });
153
+ sm.addMessage({
94
154
  type: 'msg',
95
- id,
96
- body: '',
155
+ id: UUID.uuid4(),
156
+ body: 'pre-sync',
97
157
  time: 1000,
98
- sender: TEST_USER.username,
99
- mime_model: { data: { 'text/plain': 'streamed output' } }
158
+ sender: TEST_USER.username
100
159
  });
101
- await flushPromises();
102
- expect(model.messages[0].edited).toBeUndefined();
160
+ m.markDocumentSynced();
161
+ await m.ready;
162
+ expect(m.messages).toHaveLength(1);
163
+ expect(m.messages[0].body).toBe('pre-sync');
164
+ sm.dispose();
165
+ });
166
+ it('model.messages is already populated inside the ready .then() callback', async () => {
167
+ const sm = YChat.create();
168
+ const m = new LabChatModel({
169
+ widgetConfig: makeWidgetConfig(),
170
+ user: TEST_USER,
171
+ sharedModel: sm
172
+ });
173
+ sm.addMessage({
174
+ type: 'msg',
175
+ id: UUID.uuid4(),
176
+ body: 'pre-sync',
177
+ time: 1000,
178
+ sender: TEST_USER.username
179
+ });
180
+ let messagesAtReady = -1;
181
+ const check = m.ready.then(() => {
182
+ messagesAtReady = m.messages.length;
183
+ });
184
+ m.markDocumentSynced();
185
+ await check;
186
+ expect(messagesAtReady).toBe(1);
187
+ sm.dispose();
188
+ });
189
+ it('messages added after sync are inserted directly without buffering', async () => {
190
+ await model.ready;
191
+ sharedModel.addMessage({
192
+ type: 'msg',
193
+ id: UUID.uuid4(),
194
+ body: 'post-sync',
195
+ time: 1000,
196
+ sender: TEST_USER.username
197
+ });
198
+ expect(model.messages).toHaveLength(1);
199
+ expect(model.messages[0].body).toBe('post-sync');
103
200
  });
104
201
  });
105
202
  describe('rerender tracking', () => {
package/lib/model.d.ts CHANGED
@@ -63,7 +63,7 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
63
63
  set dirty(value: boolean);
64
64
  get readOnly(): boolean;
65
65
  set readOnly(value: boolean);
66
- set id(value: string | undefined);
66
+ protected setReady(): void;
67
67
  /**
68
68
  * Notify the model that its shared document has been synchronized with the
69
69
  * server, and give it an ID if the document does not carry one yet.
@@ -85,7 +85,8 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
85
85
  toJSON(): PartialJSONObject;
86
86
  fromJSON(data: PartialJSONObject): void;
87
87
  createChatContext(): IChatContext;
88
- messagesInserted(index: number, messages: IMessageContent[]): Promise<void>;
88
+ messagesInserted(index: number, messages: IMessageContent[]): void;
89
+ private _flushPreReadyMessages;
89
90
  sendMessage(message: INewMessage): string | null;
90
91
  /**
91
92
  * Override the clear messages method.
@@ -116,6 +117,8 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
116
117
  readonly defaultKernelName: string;
117
118
  readonly defaultKernelLanguage: string;
118
119
  private _sharedModel;
120
+ private _documentSynced;
121
+ private _preReadyMessages;
119
122
  private _dirty;
120
123
  private _readOnly;
121
124
  private _contentChanged;
package/lib/model.js CHANGED
@@ -234,11 +234,14 @@ export class LabChatModel extends AbstractChatModel {
234
234
  if (changes.stateChange && !this._sharedModel.id) {
235
235
  if (changes.stateChange.some(change => change.name === 'dirty' && !change.newValue)) {
236
236
  this._sharedModel.id = UUID.uuid4();
237
+ this.setReady();
237
238
  }
238
239
  }
239
240
  };
240
241
  this.defaultKernelName = '';
241
242
  this.defaultKernelLanguage = '';
243
+ this._documentSynced = false;
244
+ this._preReadyMessages = [];
242
245
  this._dirty = false;
243
246
  this._readOnly = false;
244
247
  this._contentChanged = new Signal(this);
@@ -294,11 +297,10 @@ export class LabChatModel extends AbstractChatModel {
294
297
  set readOnly(value) {
295
298
  this._readOnly = value;
296
299
  }
297
- set id(value) {
298
- super.id = value;
299
- if (value) {
300
- this.setReady();
301
- }
300
+ setReady() {
301
+ this._documentSynced = true;
302
+ this._flushPreReadyMessages();
303
+ super.setReady();
302
304
  }
303
305
  /**
304
306
  * Notify the model that its shared document has been synchronized with the
@@ -316,10 +318,13 @@ export class LabChatModel extends AbstractChatModel {
316
318
  */
317
319
  markDocumentSynced() {
318
320
  if (!this._sharedModel.id) {
319
- // Assigning the shared ID emits a metadata change, which sets the model
320
- // ID - and therefore resolves `ready` - through `_onchange`.
321
+ // Brand-new document: writing the id fires _onchange synchronously, which
322
+ // sets this.id via metadataChanges before setReady() is called.
321
323
  this._sharedModel.id = UUID.uuid4();
322
324
  }
325
+ // For an existing document, _onchange already fired for the id metadata
326
+ // during the initial Yjs sync and set this.id before this method was called.
327
+ this.setReady();
323
328
  }
324
329
  dispose() {
325
330
  if (this.isDisposed) {
@@ -346,12 +351,23 @@ export class LabChatModel extends AbstractChatModel {
346
351
  createChatContext() {
347
352
  return new LabChatContext({ model: this });
348
353
  }
349
- async messagesInserted(index, messages) {
350
- // Ensure the chat has an ID before inserting the messages, to properly catch the
351
- // unread messages (the last read message is saved using the chat ID).
352
- return this.ready.then(() => {
354
+ messagesInserted(index, messages) {
355
+ // Buffer messages that arrive before the document is synced. They are
356
+ // flushed synchronously in markDocumentSynced() so that `ready` resolves
357
+ // only after all initial messages and metadata are already in the model.
358
+ // This ensures that the chat has an ID before inserting the messages, to properly
359
+ // catch the unread messages (the last read message is saved using the chat ID).
360
+ if (!this._documentSynced) {
361
+ this._preReadyMessages.push({ index, messages });
362
+ return;
363
+ }
364
+ super.messagesInserted(index, messages);
365
+ }
366
+ _flushPreReadyMessages() {
367
+ for (const { index, messages } of this._preReadyMessages) {
353
368
  super.messagesInserted(index, messages);
354
- });
369
+ }
370
+ this._preReadyMessages = [];
355
371
  }
356
372
  sendMessage(message) {
357
373
  var _a, _b, _c, _d, _e;
@@ -413,7 +429,7 @@ export class LabChatModel extends AbstractChatModel {
413
429
  let message = this.sharedModel.getMessage(index);
414
430
  if (message) {
415
431
  message.body = updatedMessage.body;
416
- message.edited = true;
432
+ message.edited = updatedMessage.sender.bot ? updatedMessage.edited : true;
417
433
  }
418
434
  else {
419
435
  const sender = updatedMessage.sender.username;
@@ -423,7 +439,7 @@ export class LabChatModel extends AbstractChatModel {
423
439
  body: updatedMessage.body,
424
440
  time: updatedMessage.time || Date.now() / 1000,
425
441
  sender: sender,
426
- edited: true
442
+ edited: updatedMessage.sender.bot ? false : true
427
443
  };
428
444
  }
429
445
  // Update the mime model.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab-chat",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "The library to build a chat based on shared document",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -42,7 +42,7 @@
42
42
  "watch:src": "tsc -w --sourceMap"
43
43
  },
44
44
  "dependencies": {
45
- "@jupyter/chat": "^0.24.1",
45
+ "@jupyter/chat": "^0.24.2",
46
46
  "@jupyter/collaborative-drive": "^4.4.0 || ^5.0.0",
47
47
  "@jupyter/ydoc": "^3.0.0 || ^4.0.0",
48
48
  "@jupyterlab/application": "^4.5.0",
@@ -20,6 +20,15 @@ const TEST_USER = {
20
20
  color: '#aabbcc'
21
21
  };
22
22
 
23
+ const TEST_BOT = {
24
+ username: 'test-bot',
25
+ name: 'Test Bot',
26
+ display_name: 'Bot',
27
+ initials: 'BU',
28
+ color: '#aabbcc',
29
+ bot: true
30
+ };
31
+
23
32
  function makeWidgetConfig(): IWidgetConfig {
24
33
  const owner = {} as IWidgetConfig;
25
34
  owner.config = {};
@@ -38,8 +47,7 @@ describe('LabChatModel', () => {
38
47
  user: TEST_USER,
39
48
  sharedModel
40
49
  });
41
- // Resolve the ready promise so messagesInserted can proceed.
42
- model.id = UUID.uuid4();
50
+ model.markDocumentSynced();
43
51
  });
44
52
 
45
53
  afterEach(() => {
@@ -47,6 +55,48 @@ describe('LabChatModel', () => {
47
55
  sharedModel.dispose();
48
56
  });
49
57
 
58
+ describe('message update from model', () => {
59
+ it('should set edited when user message is updated', async () => {
60
+ const id = model.sendMessage({
61
+ body: 'original',
62
+ sender: TEST_USER
63
+ }) as string;
64
+ await flushPromises();
65
+
66
+ model.updateMessage(id, {
67
+ type: 'msg',
68
+ id,
69
+ body: 'updated',
70
+ time: 1000,
71
+ sender: TEST_USER
72
+ });
73
+ await flushPromises();
74
+
75
+ expect(model.messages[0].body).toBe('updated');
76
+ expect(model.messages[0].edited).toBe(true);
77
+ });
78
+
79
+ it('should not set edited when bot message is updated', async () => {
80
+ const id = model.sendMessage({
81
+ body: 'original',
82
+ sender: TEST_BOT
83
+ }) as string;
84
+ await flushPromises();
85
+
86
+ model.updateMessage(id, {
87
+ type: 'msg',
88
+ id,
89
+ body: 'updated',
90
+ time: 1000,
91
+ sender: TEST_BOT
92
+ });
93
+ await flushPromises();
94
+
95
+ expect(model.messages[0].body).toBe('updated');
96
+ expect(model.messages[0].edited).toBeUndefined();
97
+ });
98
+ });
99
+
50
100
  describe('message attribute updates from shared model', () => {
51
101
  it('should reflect a body change', async () => {
52
102
  const id = UUID.uuid4();
@@ -95,29 +145,91 @@ describe('LabChatModel', () => {
95
145
 
96
146
  expect(model.messages[0].mime_model).toEqual(mimeModel);
97
147
  });
148
+ });
98
149
 
99
- it('should not set edited when only mime_model changes', async () => {
100
- const id = UUID.uuid4();
101
- sharedModel.addMessage({
150
+ describe('ready resolves after messages are loaded', () => {
151
+ it('messages present in sharedModel before sync are buffered, not yet in model', () => {
152
+ const sm = YChat.create();
153
+ const m = new LabChatModel({
154
+ widgetConfig: makeWidgetConfig(),
155
+ user: TEST_USER,
156
+ sharedModel: sm
157
+ });
158
+ sm.addMessage({
102
159
  type: 'msg',
103
- id,
104
- body: '',
160
+ id: UUID.uuid4(),
161
+ body: 'pre-sync',
105
162
  time: 1000,
106
163
  sender: TEST_USER.username
107
164
  });
108
- await flushPromises();
165
+ // Not yet synced: message must be buffered, not inserted.
166
+ expect(m.messages).toHaveLength(0);
167
+ sm.dispose();
168
+ });
109
169
 
110
- sharedModel.updateMessage(0, {
170
+ it('messages are in model.messages when ready resolves', async () => {
171
+ const sm = YChat.create();
172
+ const m = new LabChatModel({
173
+ widgetConfig: makeWidgetConfig(),
174
+ user: TEST_USER,
175
+ sharedModel: sm
176
+ });
177
+ sm.addMessage({
111
178
  type: 'msg',
112
- id,
113
- body: '',
179
+ id: UUID.uuid4(),
180
+ body: 'pre-sync',
114
181
  time: 1000,
115
- sender: TEST_USER.username,
116
- mime_model: { data: { 'text/plain': 'streamed output' } }
182
+ sender: TEST_USER.username
117
183
  });
118
- await flushPromises();
119
184
 
120
- expect(model.messages[0].edited).toBeUndefined();
185
+ m.markDocumentSynced();
186
+ await m.ready;
187
+
188
+ expect(m.messages).toHaveLength(1);
189
+ expect(m.messages[0].body).toBe('pre-sync');
190
+ sm.dispose();
191
+ });
192
+
193
+ it('model.messages is already populated inside the ready .then() callback', async () => {
194
+ const sm = YChat.create();
195
+ const m = new LabChatModel({
196
+ widgetConfig: makeWidgetConfig(),
197
+ user: TEST_USER,
198
+ sharedModel: sm
199
+ });
200
+ sm.addMessage({
201
+ type: 'msg',
202
+ id: UUID.uuid4(),
203
+ body: 'pre-sync',
204
+ time: 1000,
205
+ sender: TEST_USER.username
206
+ });
207
+
208
+ let messagesAtReady = -1;
209
+ const check = m.ready.then(() => {
210
+ messagesAtReady = m.messages.length;
211
+ });
212
+
213
+ m.markDocumentSynced();
214
+ await check;
215
+
216
+ expect(messagesAtReady).toBe(1);
217
+ sm.dispose();
218
+ });
219
+
220
+ it('messages added after sync are inserted directly without buffering', async () => {
221
+ await model.ready;
222
+
223
+ sharedModel.addMessage({
224
+ type: 'msg',
225
+ id: UUID.uuid4(),
226
+ body: 'post-sync',
227
+ time: 1000,
228
+ sender: TEST_USER.username
229
+ });
230
+
231
+ expect(model.messages).toHaveLength(1);
232
+ expect(model.messages[0].body).toBe('post-sync');
121
233
  });
122
234
  });
123
235
 
package/src/model.ts CHANGED
@@ -162,11 +162,10 @@ export class LabChatModel
162
162
  this._readOnly = value;
163
163
  }
164
164
 
165
- set id(value: string | undefined) {
166
- super.id = value;
167
- if (value) {
168
- this.setReady();
169
- }
165
+ protected setReady(): void {
166
+ this._documentSynced = true;
167
+ this._flushPreReadyMessages();
168
+ super.setReady();
170
169
  }
171
170
 
172
171
  /**
@@ -185,10 +184,13 @@ export class LabChatModel
185
184
  */
186
185
  markDocumentSynced(): void {
187
186
  if (!this._sharedModel.id) {
188
- // Assigning the shared ID emits a metadata change, which sets the model
189
- // ID - and therefore resolves `ready` - through `_onchange`.
187
+ // Brand-new document: writing the id fires _onchange synchronously, which
188
+ // sets this.id via metadataChanges before setReady() is called.
190
189
  this._sharedModel.id = UUID.uuid4();
191
190
  }
191
+ // For an existing document, _onchange already fired for the id metadata
192
+ // during the initial Yjs sync and set this.id before this method was called.
193
+ this.setReady();
192
194
  }
193
195
 
194
196
  dispose(): void {
@@ -222,15 +224,24 @@ export class LabChatModel
222
224
  return new LabChatContext({ model: this });
223
225
  }
224
226
 
225
- async messagesInserted(
226
- index: number,
227
- messages: IMessageContent[]
228
- ): Promise<void> {
229
- // Ensure the chat has an ID before inserting the messages, to properly catch the
230
- // unread messages (the last read message is saved using the chat ID).
231
- return this.ready.then(() => {
227
+ messagesInserted(index: number, messages: IMessageContent[]): void {
228
+ // Buffer messages that arrive before the document is synced. They are
229
+ // flushed synchronously in markDocumentSynced() so that `ready` resolves
230
+ // only after all initial messages and metadata are already in the model.
231
+ // This ensures that the chat has an ID before inserting the messages, to properly
232
+ // catch the unread messages (the last read message is saved using the chat ID).
233
+ if (!this._documentSynced) {
234
+ this._preReadyMessages.push({ index, messages });
235
+ return;
236
+ }
237
+ super.messagesInserted(index, messages);
238
+ }
239
+
240
+ private _flushPreReadyMessages(): void {
241
+ for (const { index, messages } of this._preReadyMessages) {
232
242
  super.messagesInserted(index, messages);
233
- });
243
+ }
244
+ this._preReadyMessages = [];
234
245
  }
235
246
 
236
247
  sendMessage(message: INewMessage): string | null {
@@ -308,7 +319,7 @@ export class LabChatModel
308
319
  let message = this.sharedModel.getMessage(index);
309
320
  if (message) {
310
321
  message.body = updatedMessage.body;
311
- message.edited = true;
322
+ message.edited = updatedMessage.sender.bot ? updatedMessage.edited : true;
312
323
  } else {
313
324
  const sender = updatedMessage.sender.username;
314
325
 
@@ -318,7 +329,7 @@ export class LabChatModel
318
329
  body: updatedMessage.body,
319
330
  time: updatedMessage.time || Date.now() / 1000,
320
331
  sender: sender,
321
- edited: true
332
+ edited: updatedMessage.sender.bot ? false : true
322
333
  };
323
334
  }
324
335
 
@@ -609,6 +620,7 @@ export class LabChatModel
609
620
  )
610
621
  ) {
611
622
  this._sharedModel.id = UUID.uuid4();
623
+ this.setReady();
612
624
  }
613
625
  }
614
626
  };
@@ -618,6 +630,11 @@ export class LabChatModel
618
630
 
619
631
  private _sharedModel: YChat;
620
632
 
633
+ private _documentSynced = false;
634
+ private _preReadyMessages: Array<{
635
+ index: number;
636
+ messages: IMessageContent[];
637
+ }> = [];
621
638
  private _dirty = false;
622
639
  private _readOnly = false;
623
640
  private _contentChanged = new Signal<this, void>(this);