jupyterlab-chat 0.25.0-alpha.7 → 0.25.0

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.
@@ -39,8 +39,7 @@ describe('LabChatModel', () => {
39
39
  sharedModel,
40
40
  collaborative: true
41
41
  });
42
- // Resolve the ready promise so messagesInserted can proceed.
43
- model.id = UUID.uuid4();
42
+ model.markDocumentSynced();
44
43
  });
45
44
  afterEach(() => {
46
45
  Signal.clearData(model);
@@ -126,6 +125,84 @@ describe('LabChatModel', () => {
126
125
  expect(model.messages[0].mime_model).toEqual(mimeModel);
127
126
  });
128
127
  });
128
+ describe('ready resolves after messages are loaded', () => {
129
+ it('messages present in sharedModel before sync are buffered, not yet in model', () => {
130
+ const sm = YChat.create();
131
+ const m = new LabChatModel({
132
+ widgetConfig: makeWidgetConfig(),
133
+ user: TEST_USER,
134
+ sharedModel: sm,
135
+ collaborative: true
136
+ });
137
+ sm.addMessage({
138
+ type: 'msg',
139
+ id: UUID.uuid4(),
140
+ body: 'pre-sync',
141
+ time: 1000,
142
+ sender: TEST_USER.username
143
+ });
144
+ // Not yet synced: message must be buffered, not inserted.
145
+ expect(m.messages).toHaveLength(0);
146
+ sm.dispose();
147
+ });
148
+ it('messages are in model.messages when ready resolves', async () => {
149
+ const sm = YChat.create();
150
+ const m = new LabChatModel({
151
+ widgetConfig: makeWidgetConfig(),
152
+ user: TEST_USER,
153
+ sharedModel: sm,
154
+ collaborative: true
155
+ });
156
+ sm.addMessage({
157
+ type: 'msg',
158
+ id: UUID.uuid4(),
159
+ body: 'pre-sync',
160
+ time: 1000,
161
+ sender: TEST_USER.username
162
+ });
163
+ m.markDocumentSynced();
164
+ await m.ready;
165
+ expect(m.messages).toHaveLength(1);
166
+ expect(m.messages[0].body).toBe('pre-sync');
167
+ sm.dispose();
168
+ });
169
+ it('model.messages is already populated inside the ready .then() callback', async () => {
170
+ const sm = YChat.create();
171
+ const m = new LabChatModel({
172
+ widgetConfig: makeWidgetConfig(),
173
+ user: TEST_USER,
174
+ sharedModel: sm,
175
+ collaborative: true
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
+ let messagesAtReady = -1;
185
+ const check = m.ready.then(() => {
186
+ messagesAtReady = m.messages.length;
187
+ });
188
+ m.markDocumentSynced();
189
+ await check;
190
+ expect(messagesAtReady).toBe(1);
191
+ sm.dispose();
192
+ });
193
+ it('messages added after sync are inserted directly without buffering', async () => {
194
+ await model.ready;
195
+ sharedModel.addMessage({
196
+ type: 'msg',
197
+ id: UUID.uuid4(),
198
+ body: 'post-sync',
199
+ time: 1000,
200
+ sender: TEST_USER.username
201
+ });
202
+ expect(model.messages).toHaveLength(1);
203
+ expect(model.messages[0].body).toBe('post-sync');
204
+ });
205
+ });
129
206
  describe('rerender tracking', () => {
130
207
  it('should replace renderedDelegate when mime_model changes', async () => {
131
208
  const id = UUID.uuid4();
package/lib/model.d.ts CHANGED
@@ -80,6 +80,7 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
80
80
  set readOnly(value: boolean);
81
81
  get id(): string | undefined;
82
82
  set id(value: string | undefined);
83
+ protected setReady(id: string): void;
83
84
  /**
84
85
  * Notify the model that its shared document has been synchronized with the
85
86
  * server, and give it an ID if the document does not carry one yet.
@@ -97,11 +98,12 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
97
98
  markDocumentSynced(): void;
98
99
  dispose(): void;
99
100
  toString(): string;
100
- fromString(data: string): void;
101
+ fromString(_data: string): void;
101
102
  toJSON(): PartialJSONObject;
102
- fromJSON(data: PartialJSONObject): void;
103
+ fromJSON(_data: PartialJSONObject): void;
103
104
  createChatContext(): IChatContext;
104
- messagesInserted(index: number, messages: IMessageContent[]): Promise<void>;
105
+ messagesInserted(index: number, messages: IMessageContent[]): void;
106
+ private _flushPreReadyMessages;
105
107
  sendMessage(message: INewMessage): string | null;
106
108
  /**
107
109
  * Override the clear messages method.
@@ -136,10 +138,26 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
136
138
  broadcastWritingStatus(status: IChatModel.IWritingStatus | null): void;
137
139
  /**
138
140
  * Handle a writing status pushed by the server (e.g. an AI agent) over the
139
- * WebSocket. The sender controls its own lifecycle via explicit start/stop.
141
+ * WebSocket. The sender fully controls the lifecycle via explicit start/stop
142
+ * frames: a `state: true` frame shows the indicator and it stays visible
143
+ * until a `state: false` frame clears it -- no re-broadcasting is required.
140
144
  */
141
145
  private _onWsWriting;
142
146
  private _enforceAutosaveEnabled;
147
+ /**
148
+ * Triggered when there is no backend and a change occur in the shared model (e.g.
149
+ * Jupyterlite). It is used to save the chat content from the frontend.
150
+ */
151
+ private _onServerLessChange;
152
+ /**
153
+ * Load the chat content when no backend is available (e.g. Jupyterlite).
154
+ * Should be called once when initializing the chat.
155
+ */
156
+ private _loadContent;
157
+ /**
158
+ * Save the content to a file when no backend is available (e.g. Jupyterlite).
159
+ */
160
+ private _saveContent;
143
161
  private _onchange;
144
162
  private _onWsUsersChanged;
145
163
  private _onWsMetadata;
@@ -148,6 +166,8 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
148
166
  readonly defaultKernelName: string;
149
167
  readonly defaultKernelLanguage: string;
150
168
  private _sharedModel;
169
+ private _documentSynced;
170
+ private _preReadyMessages;
151
171
  private _dirty;
152
172
  private _readOnly;
153
173
  private _contentChanged;
@@ -155,6 +175,7 @@ export declare class LabChatModel extends AbstractChatModel implements DocumentR
155
175
  private _timeoutWriting;
156
176
  private _user;
157
177
  private _wsHandler;
178
+ private _saveDebouncer;
158
179
  }
159
180
  /**
160
181
  * The chat context to be sent to the input model.
package/lib/model.js CHANGED
@@ -4,19 +4,12 @@
4
4
  */
5
5
  import { AbstractChatModel, AbstractChatContext } from '@jupyter/chat';
6
6
  import { UUID } from '@lumino/coreutils';
7
+ import { Debouncer } from '@lumino/polling';
7
8
  import { Signal } from '@lumino/signaling';
8
9
  import { enforceAutosaveEnabled } from './autosave';
9
10
  import { WebSocketHandler } from './websocket-handler';
10
11
  import { YChat } from './ychat';
11
12
  const WRITING_DELAY = 1000;
12
- /**
13
- * How long a server-pushed (e.g. AI persona) writing status stays visible
14
- * without a refresh. The sender is expected to re-broadcast while still
15
- * writing and to send an explicit stop when done; this is only a safety net so
16
- * a crashed or forgetful sender cannot leave a "is writing" indicator stuck
17
- * forever. An explicit stop clears it immediately, regardless of this value.
18
- */
19
- const WS_WRITING_TIMEOUT = 3000;
20
13
  /**
21
14
  * Coerce an untrusted value to an `IUser`, or `null` if it is not one.
22
15
  * Awareness state is written by arbitrary clients, so the only field we rely on
@@ -189,7 +182,9 @@ export class LabChatModel extends AbstractChatModel {
189
182
  };
190
183
  /**
191
184
  * Handle a writing status pushed by the server (e.g. an AI agent) over the
192
- * WebSocket. The sender controls its own lifecycle via explicit start/stop.
185
+ * WebSocket. The sender fully controls the lifecycle via explicit start/stop
186
+ * frames: a `state: true` frame shows the indicator and it stays visible
187
+ * until a `state: false` frame clears it -- no re-broadcasting is required.
193
188
  */
194
189
  this._onWsWriting = (_, writing) => {
195
190
  if (writing.user.username === this.user.username) {
@@ -199,7 +194,7 @@ export class LabChatModel extends AbstractChatModel {
199
194
  this.setWritingStatus(writing.user, {
200
195
  messageID: writing.messageID,
201
196
  typingIndicator: writing.typingIndicator
202
- }, WS_WRITING_TIMEOUT);
197
+ });
203
198
  }
204
199
  else {
205
200
  this.clearWritingStatus(writing.user);
@@ -208,6 +203,14 @@ export class LabChatModel extends AbstractChatModel {
208
203
  this._enforceAutosaveEnabled = () => {
209
204
  enforceAutosaveEnabled(this.sharedModel.awareness);
210
205
  };
206
+ /**
207
+ * Triggered when there is no backend and a change occur in the shared model (e.g.
208
+ * Jupyterlite). It is used to save the chat content from the frontend.
209
+ */
210
+ this._onServerLessChange = () => {
211
+ this.dirty = true;
212
+ void this._saveDebouncer.invoke();
213
+ };
211
214
  this._onchange = async (_, changes) => {
212
215
  if (changes.messageListChanges) {
213
216
  const msgDelta = changes.messageListChanges;
@@ -335,7 +338,9 @@ export class LabChatModel extends AbstractChatModel {
335
338
  // Not needed in WS mode — readiness is signalled by the connection message.
336
339
  if (changes.stateChange && !this._sharedModel.id && !this._wsHandler) {
337
340
  if (changes.stateChange.some(change => change.name === 'dirty' && !change.newValue)) {
338
- this._sharedModel.id = UUID.uuid4();
341
+ const id = UUID.uuid4();
342
+ this._sharedModel.id = id;
343
+ this.setReady(id);
339
344
  }
340
345
  }
341
346
  };
@@ -363,12 +368,18 @@ export class LabChatModel extends AbstractChatModel {
363
368
  };
364
369
  this.defaultKernelName = '';
365
370
  this.defaultKernelLanguage = '';
371
+ this._documentSynced = false;
372
+ this._preReadyMessages = [];
366
373
  this._dirty = false;
367
374
  this._readOnly = false;
368
375
  this._contentChanged = new Signal(this);
369
376
  this._stateChanged = new Signal(this);
370
377
  this._timeoutWriting = null;
378
+ // Web socket to use if RTC is not available
371
379
  this._wsHandler = null;
380
+ // Debouncer used to save file from the frontend if RTC and web socket are not
381
+ // available (e.g. jupyterlite).
382
+ this._saveDebouncer = new Debouncer(() => this._saveContent(), 500);
372
383
  this.collaborative = (_a = options.collaborative) !== null && _a !== void 0 ? _a : true;
373
384
  // initialize current user
374
385
  this._user = new LabChatUser(options.user);
@@ -416,7 +427,15 @@ export class LabChatModel extends AbstractChatModel {
416
427
  return this._dirty;
417
428
  }
418
429
  set dirty(value) {
430
+ const old = this._dirty;
419
431
  this._dirty = value;
432
+ if (old !== value) {
433
+ this._stateChanged.emit({
434
+ name: 'dirty',
435
+ oldValue: old,
436
+ newValue: value
437
+ });
438
+ }
420
439
  }
421
440
  get readOnly() {
422
441
  return this._readOnly;
@@ -432,9 +451,11 @@ export class LabChatModel extends AbstractChatModel {
432
451
  }
433
452
  set id(value) {
434
453
  super.id = value;
435
- if (value) {
436
- this.setReady(value);
437
- }
454
+ }
455
+ setReady(id) {
456
+ this._documentSynced = true;
457
+ this._flushPreReadyMessages();
458
+ super.setReady(id);
438
459
  }
439
460
  /**
440
461
  * Notify the model that its shared document has been synchronized with the
@@ -469,15 +490,33 @@ export class LabChatModel extends AbstractChatModel {
469
490
  return;
470
491
  }
471
492
  this._user = new LabChatUser(wsUser);
472
- if (!this.id) {
473
- this.id = serverId;
474
- }
493
+ this.id = serverId;
494
+ this.setReady(serverId);
475
495
  })
476
496
  .catch(e => {
477
- console.error('WS chat connection failed', e);
478
- // Fail `ready` so the hosting widget disposes the chat and notifies
479
- // the user, instead of leaving a loading spinner forever.
480
- this.setError(e instanceof Error ? e : new Error(String(e)));
497
+ var _a;
498
+ (_a = this._wsHandler) === null || _a === void 0 ? void 0 : _a.dispose();
499
+ this._wsHandler = null;
500
+ if (e.wsCloseCode !== 1006) {
501
+ // The server was reachable but rejected the connection (e.g. invalid
502
+ // path): surface the error rather than silently falling back.
503
+ this.setError(e);
504
+ return;
505
+ }
506
+ // Server unreachable (e.g. JupyterLite): fall back to the shared model.
507
+ console.warn('WS chat connection failed, falling back to shared model', e);
508
+ // Restore content from disk before wiring the change handler, so the
509
+ // initial population doesn't itself trigger a save. `ready` is
510
+ // resolved only after the load completes (see #532).
511
+ void this._loadContent().then(() => {
512
+ if (!this._sharedModel.id) {
513
+ this._sharedModel.id = UUID.uuid4();
514
+ }
515
+ const id = this._sharedModel.id;
516
+ // Any subsequent change must be saved by the frontend (no backend).
517
+ this._sharedModel.changed.connect(this._onServerLessChange, this);
518
+ this.setReady(id);
519
+ });
481
520
  });
482
521
  return;
483
522
  }
@@ -488,15 +527,17 @@ export class LabChatModel extends AbstractChatModel {
488
527
  // sync populates it without emitting an `_onchange` metadata delta, so
489
528
  // nothing else would set the model id and `ready` would never resolve.
490
529
  if (this._sharedModel.id) {
491
- this.id = this._sharedModel.id;
530
+ const id = this._sharedModel.id;
531
+ this.id = id; // no _onchange delta fires for the existing id at sync time
532
+ this.setReady(id);
492
533
  }
493
534
  else {
494
- // Brand-new document with no id yet: assigning the shared id emits a
495
- // metadata change that sets the model id - and therefore resolves `ready`
496
- // - through `_onchange`. A server-authored id that arrives later is
497
- // likewise adopted through `_onchange`. We do NOT mint an id that would
498
- // diverge from the server's, since the server reads back this value.
499
- this._sharedModel.id = UUID.uuid4();
535
+ // Brand-new document: writing the id to the shared model fires _onchange
536
+ // synchronously, which sets this.id (and thus super.id) via metadataChanges.
537
+ // setReady() then flushes buffered messages and resolves `ready`.
538
+ const id = UUID.uuid4();
539
+ this._sharedModel.id = id;
540
+ this.setReady(id);
500
541
  }
501
542
  }
502
543
  dispose() {
@@ -504,6 +545,7 @@ export class LabChatModel extends AbstractChatModel {
504
545
  if (this.isDisposed) {
505
546
  return;
506
547
  }
548
+ this._saveDebouncer.dispose();
507
549
  (_a = this._wsHandler) === null || _a === void 0 ? void 0 : _a.dispose();
508
550
  this._wsHandler = null;
509
551
  super.dispose();
@@ -513,26 +555,38 @@ export class LabChatModel extends AbstractChatModel {
513
555
  Signal.clearData(this);
514
556
  }
515
557
  toString() {
516
- return JSON.stringify({}, null, 2);
558
+ return JSON.stringify(this._sharedModel.getSource(), null, 2);
517
559
  }
518
- fromString(data) {
519
- /** */
560
+ fromString(_data) {
561
+ // Content is loaded on demand via _loadContent() in the serverless
562
+ // (JupyterLite) fallback; in RTC/WS modes the transport owns the data.
520
563
  }
521
564
  toJSON() {
522
565
  return JSON.parse(this.toString());
523
566
  }
524
- fromJSON(data) {
525
- // nothing to do
567
+ fromJSON(_data) {
568
+ // nothing to do — see fromString
526
569
  }
527
570
  createChatContext() {
528
571
  return new LabChatContext({ model: this });
529
572
  }
530
- async messagesInserted(index, messages) {
531
- // Ensure the chat has an ID before inserting the messages, to properly catch the
532
- // unread messages (the last read message is saved using the chat ID).
533
- return this.ready.then(() => {
573
+ messagesInserted(index, messages) {
574
+ // Buffer messages that arrive before the document is synced. They are
575
+ // flushed synchronously in markDocumentSynced() so that `ready` resolves
576
+ // only after all initial messages and metadata are already in the model.
577
+ // This ensures that the chat has an ID before inserting the messages, to properly
578
+ // catch the unread messages (the last read message is saved using the chat ID).
579
+ if (!this._documentSynced) {
580
+ this._preReadyMessages.push({ index, messages });
581
+ return;
582
+ }
583
+ super.messagesInserted(index, messages);
584
+ }
585
+ _flushPreReadyMessages() {
586
+ for (const { index, messages } of this._preReadyMessages) {
534
587
  super.messagesInserted(index, messages);
535
- });
588
+ }
589
+ this._preReadyMessages = [];
536
590
  }
537
591
  sendMessage(message) {
538
592
  var _a, _b, _c, _d;
@@ -558,7 +612,10 @@ export class LabChatModel extends AbstractChatModel {
558
612
  body: (_c = message.body) !== null && _c !== void 0 ? _c : '',
559
613
  time: Date.now() / 1000,
560
614
  sender: (_d = message.sender) !== null && _d !== void 0 ? _d : this._user,
561
- raw_time: true
615
+ // Set the raw time only if there is a server to update the time to a reference
616
+ // one (the server time is source of truth). At that stage, without collaboration,
617
+ // this means that the chat is running without server (jupyterlite).
618
+ raw_time: this.collaborative ? true : false
562
619
  };
563
620
  this.sharedModel.addMessage(this._contentToYmessage(content));
564
621
  return content.id;
@@ -621,6 +678,46 @@ export class LabChatModel extends AbstractChatModel {
621
678
  awareness.setLocalStateField('typingIndicator', (_c = status.typingIndicator) !== null && _c !== void 0 ? _c : null);
622
679
  }
623
680
  }
681
+ /**
682
+ * Load the chat content when no backend is available (e.g. Jupyterlite).
683
+ * Should be called once when initializing the chat.
684
+ */
685
+ async _loadContent() {
686
+ if (!this.documentManager) {
687
+ return;
688
+ }
689
+ try {
690
+ const file = await this.documentManager.services.contents.get(this.name, {
691
+ content: true,
692
+ format: 'text'
693
+ });
694
+ if (typeof file.content === 'string' && file.content) {
695
+ this._sharedModel.setSource(JSON.parse(file.content));
696
+ }
697
+ }
698
+ catch (_a) {
699
+ // Missing or invalid file — start with an empty chat.
700
+ }
701
+ }
702
+ /**
703
+ * Save the content to a file when no backend is available (e.g. Jupyterlite).
704
+ */
705
+ async _saveContent() {
706
+ if (!this.documentManager) {
707
+ return;
708
+ }
709
+ try {
710
+ await this.documentManager.services.contents.save(this.name, {
711
+ type: 'file',
712
+ format: 'text',
713
+ content: this.toString()
714
+ });
715
+ this.dirty = false;
716
+ }
717
+ catch (e) {
718
+ console.error('Failed to save chat file', e);
719
+ }
720
+ }
624
721
  _contentToYmessage(msg) {
625
722
  var _a, _b, _c;
626
723
  const sender = msg.sender;
@@ -258,12 +258,12 @@ export class WebSocketHandler {
258
258
  if (this._disposed) {
259
259
  return;
260
260
  }
261
- // Closed before the connection frame arrived: the chat could not be
262
- // opened (e.g. the server rejected the path). Fail `ready` so the hosting
263
- // widget can surface the error, instead of reconnecting into the same
264
- // failure or leaving a spinner hanging forever.
261
+ // Closed before the connection frame arrived: signal failure so callers can
262
+ // distinguish between an unreachable server (code 1006, e.g. JupyterLite) and
263
+ // an explicit server-side rejection (any other code, e.g. 1008 for invalid path).
265
264
  if (!this._connected) {
266
- this._ready.reject(new Error(`Chat WebSocket was closed before opening (code ${event.code})`));
265
+ this._ready.reject(Object.assign(new Error(event.reason ||
266
+ `Chat WebSocket was closed before opening (code ${event.code})`), { wsCloseCode: event.code }));
267
267
  return;
268
268
  }
269
269
  // An established connection dropped abnormally: try to reconnect.
@@ -275,6 +275,10 @@ export class WebSocketHandler {
275
275
  }, 1000);
276
276
  }
277
277
  };
278
- this._socket.onerror = error => console.error('WS chat connection error:', error);
278
+ this._socket.onerror = error => {
279
+ if (this._connected) {
280
+ console.error('WS chat connection error:', error);
281
+ }
282
+ };
279
283
  }
280
284
  }
package/lib/ychat.js CHANGED
@@ -205,15 +205,16 @@ export class YChat extends YDocument {
205
205
  getSource() {
206
206
  const users = this._users.toJSON();
207
207
  const messages = this._messages.toJSON();
208
+ const attachments = this._attachments.toJSON();
208
209
  const metadata = this._metadata.toJSON();
209
- return { users, messages, metadata };
210
+ return { users, messages, attachments, metadata };
210
211
  }
211
212
  setSource(value) {
212
213
  if (!value) {
213
214
  return;
214
215
  }
215
216
  this.transact(() => {
216
- var _a, _b, _c;
217
+ var _a, _b, _c, _d;
217
218
  const messages = (_a = value['messages']) !== null && _a !== void 0 ? _a : [];
218
219
  const ymessages = [];
219
220
  messages.forEach(message => {
@@ -226,7 +227,9 @@ export class YChat extends YDocument {
226
227
  this._messages.push(ymessages);
227
228
  const users = (_b = value['users']) !== null && _b !== void 0 ? _b : {};
228
229
  Object.entries(users).forEach(([key, val]) => this._users.set(key, val));
229
- const metadata = (_c = value['metadata']) !== null && _c !== void 0 ? _c : {};
230
+ const attachments = (_c = value['attachments']) !== null && _c !== void 0 ? _c : {};
231
+ Object.entries(attachments).forEach(([key, val]) => this._attachments.set(key, val));
232
+ const metadata = (_d = value['metadata']) !== null && _d !== void 0 ? _d : {};
230
233
  Object.entries(metadata).forEach(([key, val]) => this._metadata.set(key, val));
231
234
  });
232
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab-chat",
3
- "version": "0.25.0-alpha.7",
3
+ "version": "0.25.0",
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.25.0-alpha.7",
45
+ "@jupyter/chat": "^0.25.0",
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",
@@ -59,6 +59,7 @@
59
59
  "@jupyterlab/ui-components": "^4.5.0",
60
60
  "@lumino/commands": "^2.3.3",
61
61
  "@lumino/coreutils": "^2.2.2",
62
+ "@lumino/polling": "^2.1.5",
62
63
  "@lumino/signaling": "^2.1.5",
63
64
  "@lumino/widgets": "^2.8.0",
64
65
  "react": "^18.2.0",
@@ -48,8 +48,7 @@ describe('LabChatModel', () => {
48
48
  sharedModel,
49
49
  collaborative: true
50
50
  });
51
- // Resolve the ready promise so messagesInserted can proceed.
52
- model.id = UUID.uuid4();
51
+ model.markDocumentSynced();
53
52
  });
54
53
 
55
54
  afterEach(() => {
@@ -149,6 +148,95 @@ describe('LabChatModel', () => {
149
148
  });
150
149
  });
151
150
 
151
+ describe('ready resolves after messages are loaded', () => {
152
+ it('messages present in sharedModel before sync are buffered, not yet in model', () => {
153
+ const sm = YChat.create();
154
+ const m = new LabChatModel({
155
+ widgetConfig: makeWidgetConfig(),
156
+ user: TEST_USER,
157
+ sharedModel: sm,
158
+ collaborative: true
159
+ });
160
+ sm.addMessage({
161
+ type: 'msg',
162
+ id: UUID.uuid4(),
163
+ body: 'pre-sync',
164
+ time: 1000,
165
+ sender: TEST_USER.username
166
+ });
167
+ // Not yet synced: message must be buffered, not inserted.
168
+ expect(m.messages).toHaveLength(0);
169
+ sm.dispose();
170
+ });
171
+
172
+ it('messages are in model.messages when ready resolves', async () => {
173
+ const sm = YChat.create();
174
+ const m = new LabChatModel({
175
+ widgetConfig: makeWidgetConfig(),
176
+ user: TEST_USER,
177
+ sharedModel: sm,
178
+ collaborative: true
179
+ });
180
+ sm.addMessage({
181
+ type: 'msg',
182
+ id: UUID.uuid4(),
183
+ body: 'pre-sync',
184
+ time: 1000,
185
+ sender: TEST_USER.username
186
+ });
187
+
188
+ m.markDocumentSynced();
189
+ await m.ready;
190
+
191
+ expect(m.messages).toHaveLength(1);
192
+ expect(m.messages[0].body).toBe('pre-sync');
193
+ sm.dispose();
194
+ });
195
+
196
+ it('model.messages is already populated inside the ready .then() callback', async () => {
197
+ const sm = YChat.create();
198
+ const m = new LabChatModel({
199
+ widgetConfig: makeWidgetConfig(),
200
+ user: TEST_USER,
201
+ sharedModel: sm,
202
+ collaborative: true
203
+ });
204
+ sm.addMessage({
205
+ type: 'msg',
206
+ id: UUID.uuid4(),
207
+ body: 'pre-sync',
208
+ time: 1000,
209
+ sender: TEST_USER.username
210
+ });
211
+
212
+ let messagesAtReady = -1;
213
+ const check = m.ready.then(() => {
214
+ messagesAtReady = m.messages.length;
215
+ });
216
+
217
+ m.markDocumentSynced();
218
+ await check;
219
+
220
+ expect(messagesAtReady).toBe(1);
221
+ sm.dispose();
222
+ });
223
+
224
+ it('messages added after sync are inserted directly without buffering', async () => {
225
+ await model.ready;
226
+
227
+ sharedModel.addMessage({
228
+ type: 'msg',
229
+ id: UUID.uuid4(),
230
+ body: 'post-sync',
231
+ time: 1000,
232
+ sender: TEST_USER.username
233
+ });
234
+
235
+ expect(model.messages).toHaveLength(1);
236
+ expect(model.messages[0].body).toBe('post-sync');
237
+ });
238
+ });
239
+
152
240
  describe('rerender tracking', () => {
153
241
  it('should replace renderedDelegate when mime_model changes', async () => {
154
242
  const id = UUID.uuid4();
package/src/model.ts CHANGED
@@ -19,6 +19,7 @@ import { IChangedArgs } from '@jupyterlab/coreutils';
19
19
  import { DocumentRegistry } from '@jupyterlab/docregistry';
20
20
  import { ServerConnection, User } from '@jupyterlab/services';
21
21
  import { PartialJSONObject, UUID } from '@lumino/coreutils';
22
+ import { Debouncer } from '@lumino/polling';
22
23
  import { ISignal, Signal } from '@lumino/signaling';
23
24
 
24
25
  import { enforceAutosaveEnabled } from './autosave';
@@ -28,15 +29,6 @@ import { IChatChanges, IYmessage, YChat } from './ychat';
28
29
 
29
30
  const WRITING_DELAY = 1000;
30
31
 
31
- /**
32
- * How long a server-pushed (e.g. AI persona) writing status stays visible
33
- * without a refresh. The sender is expected to re-broadcast while still
34
- * writing and to send an explicit stop when done; this is only a safety net so
35
- * a crashed or forgetful sender cannot leave a "is writing" indicator stuck
36
- * forever. An explicit stop clears it immediately, regardless of this value.
37
- */
38
- const WS_WRITING_TIMEOUT = 3000;
39
-
40
32
  /**
41
33
  * Coerce an untrusted value to an `IUser`, or `null` if it is not one.
42
34
  * Awareness state is written by arbitrary clients, so the only field we rely on
@@ -268,7 +260,15 @@ export class LabChatModel
268
260
  return this._dirty;
269
261
  }
270
262
  set dirty(value: boolean) {
263
+ const old = this._dirty;
271
264
  this._dirty = value;
265
+ if (old !== value) {
266
+ this._stateChanged.emit({
267
+ name: 'dirty',
268
+ oldValue: old,
269
+ newValue: value
270
+ });
271
+ }
272
272
  }
273
273
 
274
274
  get readOnly(): boolean {
@@ -286,9 +286,12 @@ export class LabChatModel
286
286
  }
287
287
  set id(value: string | undefined) {
288
288
  super.id = value;
289
- if (value) {
290
- this.setReady(value);
291
- }
289
+ }
290
+
291
+ protected setReady(id: string): void {
292
+ this._documentSynced = true;
293
+ this._flushPreReadyMessages();
294
+ super.setReady(id);
292
295
  }
293
296
 
294
297
  /**
@@ -326,15 +329,35 @@ export class LabChatModel
326
329
  return;
327
330
  }
328
331
  this._user = new LabChatUser(wsUser);
329
- if (!this.id) {
330
- this.id = serverId;
331
- }
332
+ this.id = serverId;
333
+ this.setReady(serverId);
332
334
  })
333
335
  .catch(e => {
334
- console.error('WS chat connection failed', e);
335
- // Fail `ready` so the hosting widget disposes the chat and notifies
336
- // the user, instead of leaving a loading spinner forever.
337
- this.setError(e instanceof Error ? e : new Error(String(e)));
336
+ this._wsHandler?.dispose();
337
+ this._wsHandler = null;
338
+ if ((e as { wsCloseCode?: number }).wsCloseCode !== 1006) {
339
+ // The server was reachable but rejected the connection (e.g. invalid
340
+ // path): surface the error rather than silently falling back.
341
+ this.setError(e);
342
+ return;
343
+ }
344
+ // Server unreachable (e.g. JupyterLite): fall back to the shared model.
345
+ console.warn(
346
+ 'WS chat connection failed, falling back to shared model',
347
+ e
348
+ );
349
+ // Restore content from disk before wiring the change handler, so the
350
+ // initial population doesn't itself trigger a save. `ready` is
351
+ // resolved only after the load completes (see #532).
352
+ void this._loadContent().then(() => {
353
+ if (!this._sharedModel.id) {
354
+ this._sharedModel.id = UUID.uuid4();
355
+ }
356
+ const id = this._sharedModel.id;
357
+ // Any subsequent change must be saved by the frontend (no backend).
358
+ this._sharedModel.changed.connect(this._onServerLessChange, this);
359
+ this.setReady(id);
360
+ });
338
361
  });
339
362
  return;
340
363
  }
@@ -345,14 +368,16 @@ export class LabChatModel
345
368
  // sync populates it without emitting an `_onchange` metadata delta, so
346
369
  // nothing else would set the model id and `ready` would never resolve.
347
370
  if (this._sharedModel.id) {
348
- this.id = this._sharedModel.id;
371
+ const id = this._sharedModel.id;
372
+ this.id = id; // no _onchange delta fires for the existing id at sync time
373
+ this.setReady(id);
349
374
  } else {
350
- // Brand-new document with no id yet: assigning the shared id emits a
351
- // metadata change that sets the model id - and therefore resolves `ready`
352
- // - through `_onchange`. A server-authored id that arrives later is
353
- // likewise adopted through `_onchange`. We do NOT mint an id that would
354
- // diverge from the server's, since the server reads back this value.
355
- this._sharedModel.id = UUID.uuid4();
375
+ // Brand-new document: writing the id to the shared model fires _onchange
376
+ // synchronously, which sets this.id (and thus super.id) via metadataChanges.
377
+ // setReady() then flushes buffered messages and resolves `ready`.
378
+ const id = UUID.uuid4();
379
+ this._sharedModel.id = id;
380
+ this.setReady(id);
356
381
  }
357
382
  }
358
383
 
@@ -360,6 +385,7 @@ export class LabChatModel
360
385
  if (this.isDisposed) {
361
386
  return;
362
387
  }
388
+ this._saveDebouncer.dispose();
363
389
  this._wsHandler?.dispose();
364
390
  this._wsHandler = null;
365
391
  super.dispose();
@@ -370,34 +396,44 @@ export class LabChatModel
370
396
  }
371
397
 
372
398
  toString(): string {
373
- return JSON.stringify({}, null, 2);
399
+ return JSON.stringify(this._sharedModel.getSource(), null, 2);
374
400
  }
375
401
 
376
- fromString(data: string): void {
377
- /** */
402
+ fromString(_data: string): void {
403
+ // Content is loaded on demand via _loadContent() in the serverless
404
+ // (JupyterLite) fallback; in RTC/WS modes the transport owns the data.
378
405
  }
379
406
 
380
407
  toJSON(): PartialJSONObject {
381
408
  return JSON.parse(this.toString());
382
409
  }
383
410
 
384
- fromJSON(data: PartialJSONObject): void {
385
- // nothing to do
411
+ fromJSON(_data: PartialJSONObject): void {
412
+ // nothing to do — see fromString
386
413
  }
387
414
 
388
415
  createChatContext(): IChatContext {
389
416
  return new LabChatContext({ model: this });
390
417
  }
391
418
 
392
- async messagesInserted(
393
- index: number,
394
- messages: IMessageContent[]
395
- ): Promise<void> {
396
- // Ensure the chat has an ID before inserting the messages, to properly catch the
397
- // unread messages (the last read message is saved using the chat ID).
398
- return this.ready.then(() => {
419
+ messagesInserted(index: number, messages: IMessageContent[]): void {
420
+ // Buffer messages that arrive before the document is synced. They are
421
+ // flushed synchronously in markDocumentSynced() so that `ready` resolves
422
+ // only after all initial messages and metadata are already in the model.
423
+ // This ensures that the chat has an ID before inserting the messages, to properly
424
+ // catch the unread messages (the last read message is saved using the chat ID).
425
+ if (!this._documentSynced) {
426
+ this._preReadyMessages.push({ index, messages });
427
+ return;
428
+ }
429
+ super.messagesInserted(index, messages);
430
+ }
431
+
432
+ private _flushPreReadyMessages(): void {
433
+ for (const { index, messages } of this._preReadyMessages) {
399
434
  super.messagesInserted(index, messages);
400
- });
435
+ }
436
+ this._preReadyMessages = [];
401
437
  }
402
438
 
403
439
  sendMessage(message: INewMessage): string | null {
@@ -427,7 +463,10 @@ export class LabChatModel
427
463
  body: message.body ?? '',
428
464
  time: Date.now() / 1000,
429
465
  sender: message.sender ?? this._user,
430
- raw_time: true
466
+ // Set the raw time only if there is a server to update the time to a reference
467
+ // one (the server time is source of truth). At that stage, without collaboration,
468
+ // this means that the chat is running without server (jupyterlite).
469
+ raw_time: this.collaborative ? true : false
431
470
  };
432
471
 
433
472
  this.sharedModel.addMessage(this._contentToYmessage(content));
@@ -562,7 +601,9 @@ export class LabChatModel
562
601
 
563
602
  /**
564
603
  * Handle a writing status pushed by the server (e.g. an AI agent) over the
565
- * WebSocket. The sender controls its own lifecycle via explicit start/stop.
604
+ * WebSocket. The sender fully controls the lifecycle via explicit start/stop
605
+ * frames: a `state: true` frame shows the indicator and it stays visible
606
+ * until a `state: false` frame clears it -- no re-broadcasting is required.
566
607
  */
567
608
  private _onWsWriting = (
568
609
  _: WebSocketHandler,
@@ -572,14 +613,10 @@ export class LabChatModel
572
613
  return;
573
614
  }
574
615
  if (writing.state) {
575
- this.setWritingStatus(
576
- writing.user,
577
- {
578
- messageID: writing.messageID,
579
- typingIndicator: writing.typingIndicator
580
- },
581
- WS_WRITING_TIMEOUT
582
- );
616
+ this.setWritingStatus(writing.user, {
617
+ messageID: writing.messageID,
618
+ typingIndicator: writing.typingIndicator
619
+ });
583
620
  } else {
584
621
  this.clearWritingStatus(writing.user);
585
622
  }
@@ -589,6 +626,55 @@ export class LabChatModel
589
626
  enforceAutosaveEnabled(this.sharedModel.awareness);
590
627
  };
591
628
 
629
+ /**
630
+ * Triggered when there is no backend and a change occur in the shared model (e.g.
631
+ * Jupyterlite). It is used to save the chat content from the frontend.
632
+ */
633
+ private _onServerLessChange = (): void => {
634
+ this.dirty = true;
635
+ void this._saveDebouncer.invoke();
636
+ };
637
+
638
+ /**
639
+ * Load the chat content when no backend is available (e.g. Jupyterlite).
640
+ * Should be called once when initializing the chat.
641
+ */
642
+ private async _loadContent(): Promise<void> {
643
+ if (!this.documentManager) {
644
+ return;
645
+ }
646
+ try {
647
+ const file = await this.documentManager.services.contents.get(this.name, {
648
+ content: true,
649
+ format: 'text'
650
+ });
651
+ if (typeof file.content === 'string' && file.content) {
652
+ this._sharedModel.setSource(JSON.parse(file.content));
653
+ }
654
+ } catch {
655
+ // Missing or invalid file — start with an empty chat.
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Save the content to a file when no backend is available (e.g. Jupyterlite).
661
+ */
662
+ private async _saveContent(): Promise<void> {
663
+ if (!this.documentManager) {
664
+ return;
665
+ }
666
+ try {
667
+ await this.documentManager.services.contents.save(this.name, {
668
+ type: 'file',
669
+ format: 'text',
670
+ content: this.toString()
671
+ });
672
+ this.dirty = false;
673
+ } catch (e) {
674
+ console.error('Failed to save chat file', e);
675
+ }
676
+ }
677
+
592
678
  private _onchange = async (_: YChat, changes: IChatChanges) => {
593
679
  if (changes.messageListChanges) {
594
680
  const msgDelta = changes.messageListChanges;
@@ -736,7 +822,9 @@ export class LabChatModel
736
822
  change => change.name === 'dirty' && !change.newValue
737
823
  )
738
824
  ) {
739
- this._sharedModel.id = UUID.uuid4();
825
+ const id = UUID.uuid4();
826
+ this._sharedModel.id = id;
827
+ this.setReady(id);
740
828
  }
741
829
  }
742
830
  };
@@ -828,6 +916,11 @@ export class LabChatModel
828
916
 
829
917
  private _sharedModel: YChat;
830
918
 
919
+ private _documentSynced = false;
920
+ private _preReadyMessages: Array<{
921
+ index: number;
922
+ messages: IMessageContent[];
923
+ }> = [];
831
924
  private _dirty = false;
832
925
  private _readOnly = false;
833
926
  private _contentChanged = new Signal<this, void>(this);
@@ -835,7 +928,13 @@ export class LabChatModel
835
928
  private _timeoutWriting: number | null = null;
836
929
 
837
930
  private _user: IUser;
931
+
932
+ // Web socket to use if RTC is not available
838
933
  private _wsHandler: WebSocketHandler | null = null;
934
+
935
+ // Debouncer used to save file from the frontend if RTC and web socket are not
936
+ // available (e.g. jupyterlite).
937
+ private _saveDebouncer = new Debouncer(() => this._saveContent(), 500);
839
938
  }
840
939
 
841
940
  /**
@@ -299,14 +299,17 @@ export class WebSocketHandler {
299
299
  if (this._disposed) {
300
300
  return;
301
301
  }
302
- // Closed before the connection frame arrived: the chat could not be
303
- // opened (e.g. the server rejected the path). Fail `ready` so the hosting
304
- // widget can surface the error, instead of reconnecting into the same
305
- // failure or leaving a spinner hanging forever.
302
+ // Closed before the connection frame arrived: signal failure so callers can
303
+ // distinguish between an unreachable server (code 1006, e.g. JupyterLite) and
304
+ // an explicit server-side rejection (any other code, e.g. 1008 for invalid path).
306
305
  if (!this._connected) {
307
306
  this._ready.reject(
308
- new Error(
309
- `Chat WebSocket was closed before opening (code ${event.code})`
307
+ Object.assign(
308
+ new Error(
309
+ event.reason ||
310
+ `Chat WebSocket was closed before opening (code ${event.code})`
311
+ ),
312
+ { wsCloseCode: event.code }
310
313
  )
311
314
  );
312
315
  return;
@@ -320,8 +323,11 @@ export class WebSocketHandler {
320
323
  }, 1000);
321
324
  }
322
325
  };
323
- this._socket.onerror = error =>
324
- console.error('WS chat connection error:', error);
326
+ this._socket.onerror = error => {
327
+ if (this._connected) {
328
+ console.error('WS chat connection error:', error);
329
+ }
330
+ };
325
331
  }
326
332
 
327
333
  private _path = '';
package/src/ychat.ts CHANGED
@@ -137,9 +137,10 @@ export class YChat extends YDocument<IChatChanges> {
137
137
  getSource(): JSONObject {
138
138
  const users = this._users.toJSON();
139
139
  const messages = this._messages.toJSON();
140
+ const attachments = this._attachments.toJSON();
140
141
  const metadata = this._metadata.toJSON();
141
142
 
142
- return { users, messages, metadata };
143
+ return { users, messages, attachments, metadata };
143
144
  }
144
145
 
145
146
  setSource(value: JSONObject): void {
@@ -164,6 +165,11 @@ export class YChat extends YDocument<IChatChanges> {
164
165
  this._users.set(key, val as IUser)
165
166
  );
166
167
 
168
+ const attachments = value['attachments'] ?? {};
169
+ Object.entries(attachments).forEach(([key, val]) =>
170
+ this._attachments.set(key, val as IAttachment)
171
+ );
172
+
167
173
  const metadata = value['metadata'] ?? {};
168
174
  Object.entries(metadata).forEach(([key, val]) =>
169
175
  this._metadata.set(key, val as any)