jupyterlab-chat 0.24.0 → 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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,248 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { UUID } from '@lumino/coreutils';
6
+ import { Signal } from '@lumino/signaling';
7
+ import { LabChatModel } from '../model';
8
+ import { YChat } from '../ychat';
9
+ const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0));
10
+ const TEST_USER = {
11
+ username: 'test-user',
12
+ name: 'Test User',
13
+ display_name: 'Test',
14
+ initials: 'TU',
15
+ color: '#aabbcc'
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
+ };
25
+ function makeWidgetConfig() {
26
+ const owner = {};
27
+ owner.config = {};
28
+ owner.configChanged = new Signal(owner);
29
+ return owner;
30
+ }
31
+ describe('LabChatModel', () => {
32
+ let sharedModel;
33
+ let model;
34
+ beforeEach(() => {
35
+ sharedModel = YChat.create();
36
+ model = new LabChatModel({
37
+ widgetConfig: makeWidgetConfig(),
38
+ user: TEST_USER,
39
+ sharedModel
40
+ });
41
+ model.markDocumentSynced();
42
+ });
43
+ afterEach(() => {
44
+ Signal.clearData(model);
45
+ sharedModel.dispose();
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
+ });
83
+ describe('message attribute updates from shared model', () => {
84
+ it('should reflect a body change', async () => {
85
+ const id = UUID.uuid4();
86
+ sharedModel.addMessage({
87
+ type: 'msg',
88
+ id,
89
+ body: 'original',
90
+ time: 1000,
91
+ sender: TEST_USER.username
92
+ });
93
+ await flushPromises();
94
+ sharedModel.updateMessage(0, {
95
+ type: 'msg',
96
+ id,
97
+ body: 'updated',
98
+ time: 1000,
99
+ sender: TEST_USER.username
100
+ });
101
+ await flushPromises();
102
+ expect(model.messages[0].body).toBe('updated');
103
+ });
104
+ it('should reflect a mime_model change', async () => {
105
+ const id = UUID.uuid4();
106
+ sharedModel.addMessage({
107
+ type: 'msg',
108
+ id,
109
+ body: '',
110
+ time: 1000,
111
+ sender: TEST_USER.username
112
+ });
113
+ await flushPromises();
114
+ const mimeModel = { data: { 'text/plain': 'streamed output' } };
115
+ sharedModel.updateMessage(0, {
116
+ type: 'msg',
117
+ id,
118
+ body: '',
119
+ time: 1000,
120
+ sender: TEST_USER.username,
121
+ mime_model: mimeModel
122
+ });
123
+ await flushPromises();
124
+ expect(model.messages[0].mime_model).toEqual(mimeModel);
125
+ });
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({
136
+ type: 'msg',
137
+ id: UUID.uuid4(),
138
+ body: 'pre-sync',
139
+ time: 1000,
140
+ sender: TEST_USER.username
141
+ });
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({
154
+ type: 'msg',
155
+ id: UUID.uuid4(),
156
+ body: 'pre-sync',
157
+ time: 1000,
158
+ sender: TEST_USER.username
159
+ });
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');
200
+ });
201
+ });
202
+ describe('rerender tracking', () => {
203
+ it('should replace renderedDelegate when mime_model changes', async () => {
204
+ const id = UUID.uuid4();
205
+ sharedModel.addMessage({
206
+ type: 'msg',
207
+ id,
208
+ body: '',
209
+ time: 1000,
210
+ sender: TEST_USER.username
211
+ });
212
+ await flushPromises();
213
+ const delegateBefore = model.messages[0].renderedDelegate;
214
+ sharedModel.updateMessage(0, {
215
+ type: 'msg',
216
+ id,
217
+ body: '',
218
+ time: 1000,
219
+ sender: TEST_USER.username,
220
+ mime_model: { data: { 'text/plain': 'streamed output' } }
221
+ });
222
+ await flushPromises();
223
+ expect(model.messages[0].renderedDelegate).not.toBe(delegateBefore);
224
+ });
225
+ it('should not replace renderedDelegate when only edited changes', async () => {
226
+ const id = UUID.uuid4();
227
+ sharedModel.addMessage({
228
+ type: 'msg',
229
+ id,
230
+ body: 'hello',
231
+ time: 1000,
232
+ sender: TEST_USER.username
233
+ });
234
+ await flushPromises();
235
+ const delegateBefore = model.messages[0].renderedDelegate;
236
+ sharedModel.updateMessage(0, {
237
+ type: 'msg',
238
+ id,
239
+ body: 'hello',
240
+ time: 1000,
241
+ sender: TEST_USER.username,
242
+ edited: true
243
+ });
244
+ await flushPromises();
245
+ expect(model.messages[0].renderedDelegate).toBe(delegateBefore);
246
+ });
247
+ });
248
+ });
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
@@ -196,7 +196,8 @@ export class LabChatModel extends AbstractChatModel {
196
196
  'raw_time',
197
197
  'deleted',
198
198
  'edited',
199
- 'metadata'
199
+ 'metadata',
200
+ 'mime_model'
200
201
  ].includes(key)) {
201
202
  const update = {};
202
203
  update[key] = value;
@@ -233,11 +234,14 @@ export class LabChatModel extends AbstractChatModel {
233
234
  if (changes.stateChange && !this._sharedModel.id) {
234
235
  if (changes.stateChange.some(change => change.name === 'dirty' && !change.newValue)) {
235
236
  this._sharedModel.id = UUID.uuid4();
237
+ this.setReady();
236
238
  }
237
239
  }
238
240
  };
239
241
  this.defaultKernelName = '';
240
242
  this.defaultKernelLanguage = '';
243
+ this._documentSynced = false;
244
+ this._preReadyMessages = [];
241
245
  this._dirty = false;
242
246
  this._readOnly = false;
243
247
  this._contentChanged = new Signal(this);
@@ -293,11 +297,10 @@ export class LabChatModel extends AbstractChatModel {
293
297
  set readOnly(value) {
294
298
  this._readOnly = value;
295
299
  }
296
- set id(value) {
297
- super.id = value;
298
- if (value) {
299
- this.setReady();
300
- }
300
+ setReady() {
301
+ this._documentSynced = true;
302
+ this._flushPreReadyMessages();
303
+ super.setReady();
301
304
  }
302
305
  /**
303
306
  * Notify the model that its shared document has been synchronized with the
@@ -315,10 +318,13 @@ export class LabChatModel extends AbstractChatModel {
315
318
  */
316
319
  markDocumentSynced() {
317
320
  if (!this._sharedModel.id) {
318
- // Assigning the shared ID emits a metadata change, which sets the model
319
- // 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.
320
323
  this._sharedModel.id = UUID.uuid4();
321
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();
322
328
  }
323
329
  dispose() {
324
330
  if (this.isDisposed) {
@@ -345,24 +351,39 @@ export class LabChatModel extends AbstractChatModel {
345
351
  createChatContext() {
346
352
  return new LabChatContext({ model: this });
347
353
  }
348
- async messagesInserted(index, messages) {
349
- // Ensure the chat has an ID before inserting the messages, to properly catch the
350
- // unread messages (the last read message is saved using the chat ID).
351
- 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) {
352
368
  super.messagesInserted(index, messages);
353
- });
369
+ }
370
+ this._preReadyMessages = [];
354
371
  }
355
372
  sendMessage(message) {
356
- var _a, _b, _c, _d;
357
- if (!message.body && !message.mime_model && !((_a = message.attachments) === null || _a === void 0 ? void 0 : _a.length)) {
373
+ var _a, _b, _c, _d, _e;
374
+ // Allow empty message for bot only, as it may be streamed later.
375
+ if (!message.body &&
376
+ !message.mime_model &&
377
+ !((_a = message.attachments) === null || _a === void 0 ? void 0 : _a.length) &&
378
+ !((_b = message.sender) === null || _b === void 0 ? void 0 : _b.bot)) {
358
379
  return null;
359
380
  }
360
381
  this._resetWritingStatus();
361
382
  if (this._timeoutWriting !== null) {
362
383
  window.clearTimeout(this._timeoutWriting);
363
384
  }
364
- const body = (_b = message.body) !== null && _b !== void 0 ? _b : '';
365
- const user = (_c = message.sender) !== null && _c !== void 0 ? _c : this._user;
385
+ const body = (_c = message.body) !== null && _c !== void 0 ? _c : '';
386
+ const user = (_d = message.sender) !== null && _d !== void 0 ? _d : this._user;
366
387
  const msg = {
367
388
  type: 'msg',
368
389
  id: UUID.uuid4(),
@@ -380,7 +401,7 @@ export class LabChatModel extends AbstractChatModel {
380
401
  this.sharedModel.setUser(user);
381
402
  }
382
403
  // Add the attachments to the message.
383
- const attachmentIds = (_d = message.attachments) === null || _d === void 0 ? void 0 : _d.map(attachment => this.sharedModel.setAttachment(attachment));
404
+ const attachmentIds = (_e = message.attachments) === null || _e === void 0 ? void 0 : _e.map(attachment => this.sharedModel.setAttachment(attachment));
384
405
  if (attachmentIds === null || attachmentIds === void 0 ? void 0 : attachmentIds.length) {
385
406
  msg.attachments = attachmentIds;
386
407
  }
@@ -408,7 +429,7 @@ export class LabChatModel extends AbstractChatModel {
408
429
  let message = this.sharedModel.getMessage(index);
409
430
  if (message) {
410
431
  message.body = updatedMessage.body;
411
- message.edited = true;
432
+ message.edited = updatedMessage.sender.bot ? updatedMessage.edited : true;
412
433
  }
413
434
  else {
414
435
  const sender = updatedMessage.sender.username;
@@ -418,9 +439,13 @@ export class LabChatModel extends AbstractChatModel {
418
439
  body: updatedMessage.body,
419
440
  time: updatedMessage.time || Date.now() / 1000,
420
441
  sender: sender,
421
- edited: true
442
+ edited: updatedMessage.sender.bot ? false : true
422
443
  };
423
444
  }
445
+ // Update the mime model.
446
+ if (updatedMessage.mime_model) {
447
+ message.mime_model = updatedMessage.mime_model;
448
+ }
424
449
  // Update the attachments.
425
450
  const attachmentIds = (_a = updatedMessage.attachments) === null || _a === void 0 ? void 0 : _a.map(attachment => this.sharedModel.setAttachment(attachment));
426
451
  if (attachmentIds === null || attachmentIds === void 0 ? void 0 : attachmentIds.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab-chat",
3
- "version": "0.24.0",
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.0",
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",
@@ -0,0 +1,289 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { UUID } from '@lumino/coreutils';
7
+ import { Signal } from '@lumino/signaling';
8
+
9
+ import { LabChatModel } from '../model';
10
+ import { IWidgetConfig } from '../token';
11
+ import { YChat } from '../ychat';
12
+
13
+ const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0));
14
+
15
+ const TEST_USER = {
16
+ username: 'test-user',
17
+ name: 'Test User',
18
+ display_name: 'Test',
19
+ initials: 'TU',
20
+ color: '#aabbcc'
21
+ };
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
+
32
+ function makeWidgetConfig(): IWidgetConfig {
33
+ const owner = {} as IWidgetConfig;
34
+ owner.config = {};
35
+ (owner as any).configChanged = new Signal<IWidgetConfig, any>(owner);
36
+ return owner;
37
+ }
38
+
39
+ describe('LabChatModel', () => {
40
+ let sharedModel: YChat;
41
+ let model: LabChatModel;
42
+
43
+ beforeEach(() => {
44
+ sharedModel = YChat.create();
45
+ model = new LabChatModel({
46
+ widgetConfig: makeWidgetConfig(),
47
+ user: TEST_USER,
48
+ sharedModel
49
+ });
50
+ model.markDocumentSynced();
51
+ });
52
+
53
+ afterEach(() => {
54
+ Signal.clearData(model);
55
+ sharedModel.dispose();
56
+ });
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
+
100
+ describe('message attribute updates from shared model', () => {
101
+ it('should reflect a body change', async () => {
102
+ const id = UUID.uuid4();
103
+ sharedModel.addMessage({
104
+ type: 'msg',
105
+ id,
106
+ body: 'original',
107
+ time: 1000,
108
+ sender: TEST_USER.username
109
+ });
110
+ await flushPromises();
111
+
112
+ sharedModel.updateMessage(0, {
113
+ type: 'msg',
114
+ id,
115
+ body: 'updated',
116
+ time: 1000,
117
+ sender: TEST_USER.username
118
+ });
119
+ await flushPromises();
120
+
121
+ expect(model.messages[0].body).toBe('updated');
122
+ });
123
+
124
+ it('should reflect a mime_model change', async () => {
125
+ const id = UUID.uuid4();
126
+ sharedModel.addMessage({
127
+ type: 'msg',
128
+ id,
129
+ body: '',
130
+ time: 1000,
131
+ sender: TEST_USER.username
132
+ });
133
+ await flushPromises();
134
+
135
+ const mimeModel = { data: { 'text/plain': 'streamed output' } };
136
+ sharedModel.updateMessage(0, {
137
+ type: 'msg',
138
+ id,
139
+ body: '',
140
+ time: 1000,
141
+ sender: TEST_USER.username,
142
+ mime_model: mimeModel
143
+ });
144
+ await flushPromises();
145
+
146
+ expect(model.messages[0].mime_model).toEqual(mimeModel);
147
+ });
148
+ });
149
+
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({
159
+ type: 'msg',
160
+ id: UUID.uuid4(),
161
+ body: 'pre-sync',
162
+ time: 1000,
163
+ sender: TEST_USER.username
164
+ });
165
+ // Not yet synced: message must be buffered, not inserted.
166
+ expect(m.messages).toHaveLength(0);
167
+ sm.dispose();
168
+ });
169
+
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({
178
+ type: 'msg',
179
+ id: UUID.uuid4(),
180
+ body: 'pre-sync',
181
+ time: 1000,
182
+ sender: TEST_USER.username
183
+ });
184
+
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');
233
+ });
234
+ });
235
+
236
+ describe('rerender tracking', () => {
237
+ it('should replace renderedDelegate when mime_model changes', async () => {
238
+ const id = UUID.uuid4();
239
+ sharedModel.addMessage({
240
+ type: 'msg',
241
+ id,
242
+ body: '',
243
+ time: 1000,
244
+ sender: TEST_USER.username
245
+ });
246
+ await flushPromises();
247
+
248
+ const delegateBefore = model.messages[0].renderedDelegate;
249
+
250
+ sharedModel.updateMessage(0, {
251
+ type: 'msg',
252
+ id,
253
+ body: '',
254
+ time: 1000,
255
+ sender: TEST_USER.username,
256
+ mime_model: { data: { 'text/plain': 'streamed output' } }
257
+ });
258
+ await flushPromises();
259
+
260
+ expect(model.messages[0].renderedDelegate).not.toBe(delegateBefore);
261
+ });
262
+
263
+ it('should not replace renderedDelegate when only edited changes', async () => {
264
+ const id = UUID.uuid4();
265
+ sharedModel.addMessage({
266
+ type: 'msg',
267
+ id,
268
+ body: 'hello',
269
+ time: 1000,
270
+ sender: TEST_USER.username
271
+ });
272
+ await flushPromises();
273
+
274
+ const delegateBefore = model.messages[0].renderedDelegate;
275
+
276
+ sharedModel.updateMessage(0, {
277
+ type: 'msg',
278
+ id,
279
+ body: 'hello',
280
+ time: 1000,
281
+ sender: TEST_USER.username,
282
+ edited: true
283
+ });
284
+ await flushPromises();
285
+
286
+ expect(model.messages[0].renderedDelegate).toBe(delegateBefore);
287
+ });
288
+ });
289
+ });
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,19 +224,34 @@ 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 {
237
- if (!message.body && !message.mime_model && !message.attachments?.length) {
248
+ // Allow empty message for bot only, as it may be streamed later.
249
+ if (
250
+ !message.body &&
251
+ !message.mime_model &&
252
+ !message.attachments?.length &&
253
+ !message.sender?.bot
254
+ ) {
238
255
  return null;
239
256
  }
240
257
  this._resetWritingStatus();
@@ -302,7 +319,7 @@ export class LabChatModel
302
319
  let message = this.sharedModel.getMessage(index);
303
320
  if (message) {
304
321
  message.body = updatedMessage.body;
305
- message.edited = true;
322
+ message.edited = updatedMessage.sender.bot ? updatedMessage.edited : true;
306
323
  } else {
307
324
  const sender = updatedMessage.sender.username;
308
325
 
@@ -312,10 +329,15 @@ export class LabChatModel
312
329
  body: updatedMessage.body,
313
330
  time: updatedMessage.time || Date.now() / 1000,
314
331
  sender: sender,
315
- edited: true
332
+ edited: updatedMessage.sender.bot ? false : true
316
333
  };
317
334
  }
318
335
 
336
+ // Update the mime model.
337
+ if (updatedMessage.mime_model) {
338
+ message.mime_model = updatedMessage.mime_model;
339
+ }
340
+
319
341
  // Update the attachments.
320
342
  const attachmentIds = updatedMessage.attachments?.map(attachment =>
321
343
  this.sharedModel.setAttachment(attachment)
@@ -551,7 +573,8 @@ export class LabChatModel
551
573
  'raw_time',
552
574
  'deleted',
553
575
  'edited',
554
- 'metadata'
576
+ 'metadata',
577
+ 'mime_model'
555
578
  ].includes(key)
556
579
  ) {
557
580
  const update: Partial<IMessageContent> = {};
@@ -597,6 +620,7 @@ export class LabChatModel
597
620
  )
598
621
  ) {
599
622
  this._sharedModel.id = UUID.uuid4();
623
+ this.setReady();
600
624
  }
601
625
  }
602
626
  };
@@ -606,6 +630,11 @@ export class LabChatModel
606
630
 
607
631
  private _sharedModel: YChat;
608
632
 
633
+ private _documentSynced = false;
634
+ private _preReadyMessages: Array<{
635
+ index: number;
636
+ messages: IMessageContent[];
637
+ }> = [];
609
638
  private _dirty = false;
610
639
  private _readOnly = false;
611
640
  private _contentChanged = new Signal<this, void>(this);