@jupyter/chat 0.24.0 → 0.25.0-alpha.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { MockChatModel } from './mocks';
6
+ const userA = { username: 'a', name: 'Alice', display_name: 'Alice' };
7
+ const userB = { username: 'b', name: 'Bob', display_name: 'Bob' };
8
+ describe('writers state', () => {
9
+ let model;
10
+ let changes;
11
+ beforeEach(() => {
12
+ var _a;
13
+ model = new MockChatModel();
14
+ changes = [];
15
+ (_a = model.writersChanged) === null || _a === void 0 ? void 0 : _a.connect((_, writers) => changes.push(writers));
16
+ });
17
+ afterEach(() => {
18
+ model.dispose();
19
+ });
20
+ it('adds and removes a writer per user', () => {
21
+ model.setWritingStatus(userA);
22
+ expect(model.writers.map(w => w.user.username)).toEqual(['a']);
23
+ model.setWritingStatus(userB);
24
+ expect(model.writers.map(w => w.user.username).sort()).toEqual(['a', 'b']);
25
+ model.clearWritingStatus(userA);
26
+ expect(model.writers.map(w => w.user.username)).toEqual(['b']);
27
+ });
28
+ it('carries a custom typingIndicator', () => {
29
+ model.setWritingStatus(userA, { typingIndicator: 'is running ripgrep' });
30
+ expect(model.writers[0].typingIndicator).toBe('is running ripgrep');
31
+ });
32
+ it('does not re-emit when the status is unchanged', () => {
33
+ model.setWritingStatus(userA, { typingIndicator: 'x' });
34
+ const count = changes.length;
35
+ model.setWritingStatus(userA, { typingIndicator: 'x' });
36
+ expect(changes.length).toBe(count);
37
+ });
38
+ it('auto-clears a writer after the timeout unless refreshed', () => {
39
+ jest.useFakeTimers();
40
+ try {
41
+ model.setWritingStatus(userA, undefined, 2000);
42
+ expect(model.writers).toHaveLength(1);
43
+ // Refresh before expiry keeps it alive.
44
+ jest.advanceTimersByTime(1500);
45
+ model.setWritingStatus(userA, undefined, 2000);
46
+ jest.advanceTimersByTime(1500);
47
+ expect(model.writers).toHaveLength(1);
48
+ // No refresh -> auto-cleared after the timeout (the WS anti-stuck path).
49
+ jest.advanceTimersByTime(2000);
50
+ expect(model.writers).toHaveLength(0);
51
+ }
52
+ finally {
53
+ jest.useRealTimers();
54
+ }
55
+ });
56
+ it('updateWriters replaces the whole list', () => {
57
+ model.setWritingStatus(userA);
58
+ model.updateWriters([{ user: userB }]);
59
+ expect(model.writers.map(w => w.user.username)).toEqual(['b']);
60
+ });
61
+ });
@@ -12,6 +12,11 @@ import { useChatContext } from '../../context';
12
12
  import { replaceMentionToSpan } from '../../utils';
13
13
  const RENDERED_CLASS = 'jp-chat-rendered-message';
14
14
  const DEFAULT_MIME_TYPE = 'text/markdown';
15
+ /**
16
+ * The class name added to an output area widget. This is required to display cell
17
+ * output as a message in the chat.
18
+ */
19
+ const OUTPUT_AREA_CLASS = 'jp-OutputArea';
15
20
  /**
16
21
  * The message renderer base component.
17
22
  */
@@ -22,6 +27,7 @@ function MessageRendererBase(props) {
22
27
  const [renderedContent, setRenderedContent] = useState(null);
23
28
  // Allow edition only on text messages.
24
29
  const [canEdit, setCanEdit] = useState(false);
30
+ const [isOutputArea, setIsOutputArea] = useState(false);
25
31
  // Each element is a two-tuple with the structure [codeToolbarRoot, codeToolbarProps].
26
32
  const [codeToolbarDefns, setCodeToolbarDefns] = useState([]);
27
33
  useEffect(() => {
@@ -86,6 +92,7 @@ function MessageRendererBase(props) {
86
92
  // never been attached, only the node.
87
93
  // This is necessary to render latex.
88
94
  MessageLoop.sendMessage(renderer, Widget.Msg.AfterAttach);
95
+ setIsOutputArea(!isMarkdownRenderer);
89
96
  // Add code toolbar if markdown has been rendered.
90
97
  if (isMarkdownRenderer) {
91
98
  const newCodeToolbarDefns = [];
@@ -110,7 +117,7 @@ function MessageRendererBase(props) {
110
117
  renderContent();
111
118
  }, [message.body, message.mime_model, message.mentions, rmRegistry]);
112
119
  return (React.createElement(React.Fragment, null,
113
- renderedContent && (React.createElement("div", { className: RENDERED_CLASS, ref: node => node && node.replaceChildren(renderedContent) })),
120
+ renderedContent && (React.createElement("div", { className: `${RENDERED_CLASS}${isOutputArea ? ` ${OUTPUT_AREA_CLASS}` : ''}`, ref: node => node && node.replaceChildren(renderedContent) })),
114
121
  React.createElement(MessageToolbar, { edit: canEdit ? props.edit : undefined, delete: props.delete }),
115
122
  // Render a `CodeToolbar` element underneath each code block.
116
123
  // We use ReactDOM.createPortal() so each `CodeToolbar` element is able
@@ -17,10 +17,26 @@ function formatWritersText(writers, trans) {
17
17
  if (writers.length === 0) {
18
18
  return '';
19
19
  }
20
- const names = writers.map(w => {
20
+ const nameOf = (w) => {
21
21
  var _a, _b, _c;
22
22
  return (_c = (_b = (_a = w.user.display_name) !== null && _a !== void 0 ? _a : w.user.name) !== null && _b !== void 0 ? _b : w.user.username) !== null && _c !== void 0 ? _c : trans.__('Unknown');
23
- });
23
+ };
24
+ // When any writer supplies a custom typing indicator, render per-writer
25
+ // phrases ("<name> <indicator>"), so e.g. "Jupyternaut is running `ripgrep`".
26
+ if (writers.some(w => w.typingIndicator)) {
27
+ const phrases = writers.map(w => { var _a; return `${nameOf(w)} ${(_a = w.typingIndicator) !== null && _a !== void 0 ? _a : trans.__('is typing...')}`; });
28
+ if (phrases.length === 1) {
29
+ return phrases[0];
30
+ }
31
+ else if (phrases.length === 2) {
32
+ return trans.__('%1 and %2', phrases[0], phrases[1]);
33
+ }
34
+ else {
35
+ const allButLast = phrases.slice(0, -1).join(', ');
36
+ return trans.__('%1, and %2', allButLast, phrases[phrases.length - 1]);
37
+ }
38
+ }
39
+ const names = writers.map(nameOf);
24
40
  if (names.length === 1) {
25
41
  return trans.__('%1 is typing...', names[0]);
26
42
  }
package/lib/model.d.ts CHANGED
@@ -160,6 +160,27 @@ export interface IChatModel extends IDisposable {
160
160
  * Update the current writers list.
161
161
  */
162
162
  updateWriters(writers: IChatModel.IWriter[]): void;
163
+ /**
164
+ * Mark a user as currently writing (add or update their writer entry).
165
+ *
166
+ * @param user - the writing user.
167
+ * @param status - optional writing status (messageID, custom typingIndicator).
168
+ * @param timeout - optional auto-clear delay in ms. When set, the user is
169
+ * automatically cleared after `timeout` unless refreshed by another call.
170
+ * Used by transports without their own liveness signal (e.g. WebSocket) to
171
+ * avoid a stuck "is typing" if a clear message is never received.
172
+ */
173
+ setWritingStatus(user: IUser, status?: IChatModel.IWritingStatus, timeout?: number): void;
174
+ /**
175
+ * Remove a user from the writers list.
176
+ */
177
+ clearWritingStatus(user: IUser): void;
178
+ /**
179
+ * Broadcast the *current user's* writing status to other clients, or clear it
180
+ * with `null`. Implemented per transport (RTC sets awareness; WebSocket sends
181
+ * a periodic writing frame). Default implementation is a no-op.
182
+ */
183
+ broadcastWritingStatus(status: IChatModel.IWritingStatus | null): void;
163
184
  /**
164
185
  * Create the chat context that will be passed to the input model.
165
186
  */
@@ -332,6 +353,22 @@ export declare abstract class AbstractChatModel implements IChatModel {
332
353
  * This implementation only propagate the list via a signal.
333
354
  */
334
355
  updateWriters(writers: IChatModel.IWriter[]): void;
356
+ /**
357
+ * Mark a user as currently writing. See IChatModel.setWritingStatus.
358
+ */
359
+ setWritingStatus(user: IUser, status?: IChatModel.IWritingStatus, timeout?: number): void;
360
+ /**
361
+ * Remove a user from the writers list. See IChatModel.clearWritingStatus.
362
+ */
363
+ clearWritingStatus(user: IUser): void;
364
+ /**
365
+ * Broadcast the current user's writing status. Default no-op; transports
366
+ * override this (RTC awareness, WebSocket frame).
367
+ */
368
+ broadcastWritingStatus(_status: IChatModel.IWritingStatus | null): void;
369
+ private _removeWriter;
370
+ private _clearWriterTimer;
371
+ private _clearAllWriterTimers;
335
372
  /**
336
373
  * Create the chat context that will be passed to the input model.
337
374
  */
@@ -382,6 +419,7 @@ export declare abstract class AbstractChatModel implements IChatModel {
382
419
  private _documentManager;
383
420
  private _notificationId;
384
421
  private _writers;
422
+ private _writerTimers;
385
423
  private _messageEditions;
386
424
  private _messagesUpdated;
387
425
  private _messageChanged;
@@ -453,6 +491,24 @@ export declare namespace IChatModel {
453
491
  * The message ID (optional)
454
492
  */
455
493
  messageID?: string;
494
+ /**
495
+ * Custom status text to display instead of the default "is typing…",
496
+ * e.g. "is running `ripgrep …`". Falls back to the default when unset.
497
+ */
498
+ typingIndicator?: string;
499
+ }
500
+ /**
501
+ * The writing status broadcast by (or on behalf of) a user.
502
+ */
503
+ interface IWritingStatus {
504
+ /**
505
+ * The ID of the message being edited, if any.
506
+ */
507
+ messageID?: string;
508
+ /**
509
+ * Optional custom typing-indicator text (see IWriter.typingIndicator).
510
+ */
511
+ typingIndicator?: string;
456
512
  }
457
513
  }
458
514
  /**
package/lib/model.js CHANGED
@@ -3,7 +3,6 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  import { nullTranslator } from '@jupyterlab/translation';
6
- import { ArrayExt } from '@lumino/algorithm';
7
6
  import { PromiseDelegate } from '@lumino/coreutils';
8
7
  import { Signal } from '@lumino/signaling';
9
8
  import { TRANSLATION_DOMAIN } from './context';
@@ -30,7 +29,8 @@ export class AbstractChatModel {
30
29
  this._disposed = new Signal(this);
31
30
  this._isDisposed = false;
32
31
  this._notificationId = null;
33
- this._writers = [];
32
+ this._writers = new Map();
33
+ this._writerTimers = new Map();
34
34
  this._messageEditions = new Map();
35
35
  this._messagesUpdated = new Signal(this);
36
36
  this._messageChanged = new Signal(this);
@@ -114,7 +114,7 @@ export class AbstractChatModel {
114
114
  * The current writer list.
115
115
  */
116
116
  get writers() {
117
- return this._writers;
117
+ return [...this._writers.values()];
118
118
  }
119
119
  /**
120
120
  * Get the active cell manager.
@@ -381,16 +381,70 @@ export class AbstractChatModel {
381
381
  * This implementation only propagate the list via a signal.
382
382
  */
383
383
  updateWriters(writers) {
384
- const compareWriters = (a, b) => {
385
- return (a.user.username === b.user.username &&
386
- a.user.display_name === b.user.display_name &&
387
- a.messageID === b.messageID);
384
+ // Reconcile the writers map with the provided snapshot (used by
385
+ // snapshot-style transports such as RTC awareness). Any auto-clear timers
386
+ // are dropped, since the snapshot is authoritative.
387
+ this._clearAllWriterTimers();
388
+ const next = new Map();
389
+ for (const writer of writers) {
390
+ next.set(writer.user.username, writer);
391
+ }
392
+ if (!Private.writersEqual(this._writers, next)) {
393
+ this._writers = next;
394
+ this._writersChanged.emit(this.writers);
395
+ }
396
+ }
397
+ /**
398
+ * Mark a user as currently writing. See IChatModel.setWritingStatus.
399
+ */
400
+ setWritingStatus(user, status, timeout) {
401
+ const writer = {
402
+ user,
403
+ messageID: status === null || status === void 0 ? void 0 : status.messageID,
404
+ typingIndicator: status === null || status === void 0 ? void 0 : status.typingIndicator
388
405
  };
389
- if (!ArrayExt.shallowEqual(this._writers, writers, compareWriters)) {
390
- this._writers = writers;
391
- this._writersChanged.emit(writers);
406
+ this._clearWriterTimer(user.username);
407
+ if (timeout !== undefined) {
408
+ this._writerTimers.set(user.username, window.setTimeout(() => this._removeWriter(user.username), timeout));
409
+ }
410
+ const previous = this._writers.get(user.username);
411
+ this._writers.set(user.username, writer);
412
+ if (!previous || !Private.writerEqual(previous, writer)) {
413
+ this._writersChanged.emit(this.writers);
392
414
  }
393
415
  }
416
+ /**
417
+ * Remove a user from the writers list. See IChatModel.clearWritingStatus.
418
+ */
419
+ clearWritingStatus(user) {
420
+ this._removeWriter(user.username);
421
+ }
422
+ /**
423
+ * Broadcast the current user's writing status. Default no-op; transports
424
+ * override this (RTC awareness, WebSocket frame).
425
+ */
426
+ broadcastWritingStatus(_status) {
427
+ // no-op by default
428
+ }
429
+ _removeWriter(username) {
430
+ this._clearWriterTimer(username);
431
+ if (this._writers.delete(username)) {
432
+ this._writersChanged.emit(this.writers);
433
+ }
434
+ }
435
+ _clearWriterTimer(username) {
436
+ const timer = this._writerTimers.get(username);
437
+ if (timer !== undefined) {
438
+ window.clearTimeout(timer);
439
+ this._writerTimers.delete(username);
440
+ }
441
+ }
442
+ _clearAllWriterTimers() {
443
+ for (const timer of this._writerTimers.values()) {
444
+ window.clearTimeout(timer);
445
+ }
446
+ this._writerTimers.clear();
447
+ }
394
448
  /**
395
449
  * Get the input model of the edited message, given its id.
396
450
  */
@@ -492,3 +546,35 @@ export class AbstractChatContext {
492
546
  return this._model.awareness;
493
547
  }
494
548
  }
549
+ /**
550
+ * A namespace for private functionality.
551
+ */
552
+ var Private;
553
+ (function (Private) {
554
+ /**
555
+ * Whether two writers are equivalent for change-detection purposes.
556
+ */
557
+ function writerEqual(a, b) {
558
+ return (a.user.username === b.user.username &&
559
+ a.user.display_name === b.user.display_name &&
560
+ a.messageID === b.messageID &&
561
+ a.typingIndicator === b.typingIndicator);
562
+ }
563
+ Private.writerEqual = writerEqual;
564
+ /**
565
+ * Whether two writer maps are equivalent.
566
+ */
567
+ function writersEqual(a, b) {
568
+ if (a.size !== b.size) {
569
+ return false;
570
+ }
571
+ for (const [username, writer] of a) {
572
+ const other = b.get(username);
573
+ if (!other || !writerEqual(writer, other)) {
574
+ return false;
575
+ }
576
+ }
577
+ return true;
578
+ }
579
+ Private.writersEqual = writersEqual;
580
+ })(Private || (Private = {}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.24.0",
3
+ "version": "0.25.0-alpha.0",
4
4
  "description": "A package that provides UI components that can be used to create a chat in a Jupyterlab extension.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -0,0 +1,75 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { IChatModel } from '../model';
7
+ import { IUser } from '../types';
8
+ import { MockChatModel } from './mocks';
9
+
10
+ const userA: IUser = { username: 'a', name: 'Alice', display_name: 'Alice' };
11
+ const userB: IUser = { username: 'b', name: 'Bob', display_name: 'Bob' };
12
+
13
+ describe('writers state', () => {
14
+ let model: MockChatModel;
15
+ let changes: IChatModel.IWriter[][];
16
+
17
+ beforeEach(() => {
18
+ model = new MockChatModel();
19
+ changes = [];
20
+ model.writersChanged?.connect((_, writers) => changes.push(writers));
21
+ });
22
+
23
+ afterEach(() => {
24
+ model.dispose();
25
+ });
26
+
27
+ it('adds and removes a writer per user', () => {
28
+ model.setWritingStatus(userA);
29
+ expect(model.writers.map(w => w.user.username)).toEqual(['a']);
30
+
31
+ model.setWritingStatus(userB);
32
+ expect(model.writers.map(w => w.user.username).sort()).toEqual(['a', 'b']);
33
+
34
+ model.clearWritingStatus(userA);
35
+ expect(model.writers.map(w => w.user.username)).toEqual(['b']);
36
+ });
37
+
38
+ it('carries a custom typingIndicator', () => {
39
+ model.setWritingStatus(userA, { typingIndicator: 'is running ripgrep' });
40
+ expect(model.writers[0].typingIndicator).toBe('is running ripgrep');
41
+ });
42
+
43
+ it('does not re-emit when the status is unchanged', () => {
44
+ model.setWritingStatus(userA, { typingIndicator: 'x' });
45
+ const count = changes.length;
46
+ model.setWritingStatus(userA, { typingIndicator: 'x' });
47
+ expect(changes.length).toBe(count);
48
+ });
49
+
50
+ it('auto-clears a writer after the timeout unless refreshed', () => {
51
+ jest.useFakeTimers();
52
+ try {
53
+ model.setWritingStatus(userA, undefined, 2000);
54
+ expect(model.writers).toHaveLength(1);
55
+
56
+ // Refresh before expiry keeps it alive.
57
+ jest.advanceTimersByTime(1500);
58
+ model.setWritingStatus(userA, undefined, 2000);
59
+ jest.advanceTimersByTime(1500);
60
+ expect(model.writers).toHaveLength(1);
61
+
62
+ // No refresh -> auto-cleared after the timeout (the WS anti-stuck path).
63
+ jest.advanceTimersByTime(2000);
64
+ expect(model.writers).toHaveLength(0);
65
+ } finally {
66
+ jest.useRealTimers();
67
+ }
68
+ });
69
+
70
+ it('updateWriters replaces the whole list', () => {
71
+ model.setWritingStatus(userA);
72
+ model.updateWriters([{ user: userB }]);
73
+ expect(model.writers.map(w => w.user.username)).toEqual(['b']);
74
+ });
75
+ });
@@ -19,6 +19,12 @@ import { replaceMentionToSpan } from '../../utils';
19
19
  const RENDERED_CLASS = 'jp-chat-rendered-message';
20
20
  const DEFAULT_MIME_TYPE = 'text/markdown';
21
21
 
22
+ /**
23
+ * The class name added to an output area widget. This is required to display cell
24
+ * output as a message in the chat.
25
+ */
26
+ const OUTPUT_AREA_CLASS = 'jp-OutputArea';
27
+
22
28
  /**
23
29
  * The type of the props for the MessageRenderer component.
24
30
  */
@@ -56,6 +62,8 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
56
62
  // Allow edition only on text messages.
57
63
  const [canEdit, setCanEdit] = useState<boolean>(false);
58
64
 
65
+ const [isOutputArea, setIsOutputArea] = useState<boolean>(false);
66
+
59
67
  // Each element is a two-tuple with the structure [codeToolbarRoot, codeToolbarProps].
60
68
  const [codeToolbarDefns, setCodeToolbarDefns] = useState<
61
69
  Array<[HTMLDivElement, CodeToolbarProps]>
@@ -129,6 +137,8 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
129
137
  // This is necessary to render latex.
130
138
  MessageLoop.sendMessage(renderer, Widget.Msg.AfterAttach);
131
139
 
140
+ setIsOutputArea(!isMarkdownRenderer);
141
+
132
142
  // Add code toolbar if markdown has been rendered.
133
143
  if (isMarkdownRenderer) {
134
144
  const newCodeToolbarDefns: [HTMLDivElement, CodeToolbarProps][] = [];
@@ -164,7 +174,7 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
164
174
  <>
165
175
  {renderedContent && (
166
176
  <div
167
- className={RENDERED_CLASS}
177
+ className={`${RENDERED_CLASS}${isOutputArea ? ` ${OUTPUT_AREA_CLASS}` : ''}`}
168
178
  ref={node => node && node.replaceChildren(renderedContent)}
169
179
  />
170
180
  )}
@@ -41,13 +41,29 @@ function formatWritersText(
41
41
  return '';
42
42
  }
43
43
 
44
- const names = writers.map(
45
- w =>
46
- w.user.display_name ??
47
- w.user.name ??
48
- w.user.username ??
49
- trans.__('Unknown')
50
- );
44
+ const nameOf = (w: IChatModel.IWriter) =>
45
+ w.user.display_name ??
46
+ w.user.name ??
47
+ w.user.username ??
48
+ trans.__('Unknown');
49
+
50
+ // When any writer supplies a custom typing indicator, render per-writer
51
+ // phrases ("<name> <indicator>"), so e.g. "Jupyternaut is running `ripgrep`".
52
+ if (writers.some(w => w.typingIndicator)) {
53
+ const phrases = writers.map(
54
+ w => `${nameOf(w)} ${w.typingIndicator ?? trans.__('is typing...')}`
55
+ );
56
+ if (phrases.length === 1) {
57
+ return phrases[0];
58
+ } else if (phrases.length === 2) {
59
+ return trans.__('%1 and %2', phrases[0], phrases[1]);
60
+ } else {
61
+ const allButLast = phrases.slice(0, -1).join(', ');
62
+ return trans.__('%1, and %2', allButLast, phrases[phrases.length - 1]);
63
+ }
64
+ }
65
+
66
+ const names = writers.map(nameOf);
51
67
 
52
68
  if (names.length === 1) {
53
69
  return trans.__('%1 is typing...', names[0]);
package/src/model.ts CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  TranslationBundle
11
11
  } from '@jupyterlab/translation';
12
12
  import type { IAwareness } from '@jupyter/ydoc';
13
- import { ArrayExt } from '@lumino/algorithm';
14
13
  import { CommandRegistry } from '@lumino/commands';
15
14
  import { PromiseDelegate } from '@lumino/coreutils';
16
15
  import { IDisposable } from '@lumino/disposable';
@@ -217,6 +216,34 @@ export interface IChatModel extends IDisposable {
217
216
  */
218
217
  updateWriters(writers: IChatModel.IWriter[]): void;
219
218
 
219
+ /**
220
+ * Mark a user as currently writing (add or update their writer entry).
221
+ *
222
+ * @param user - the writing user.
223
+ * @param status - optional writing status (messageID, custom typingIndicator).
224
+ * @param timeout - optional auto-clear delay in ms. When set, the user is
225
+ * automatically cleared after `timeout` unless refreshed by another call.
226
+ * Used by transports without their own liveness signal (e.g. WebSocket) to
227
+ * avoid a stuck "is typing" if a clear message is never received.
228
+ */
229
+ setWritingStatus(
230
+ user: IUser,
231
+ status?: IChatModel.IWritingStatus,
232
+ timeout?: number
233
+ ): void;
234
+
235
+ /**
236
+ * Remove a user from the writers list.
237
+ */
238
+ clearWritingStatus(user: IUser): void;
239
+
240
+ /**
241
+ * Broadcast the *current user's* writing status to other clients, or clear it
242
+ * with `null`. Implemented per transport (RTC sets awareness; WebSocket sends
243
+ * a periodic writing frame). Default implementation is a no-op.
244
+ */
245
+ broadcastWritingStatus(status: IChatModel.IWritingStatus | null): void;
246
+
220
247
  /**
221
248
  * Create the chat context that will be passed to the input model.
222
249
  */
@@ -345,7 +372,7 @@ export abstract class AbstractChatModel implements IChatModel {
345
372
  * The current writer list.
346
373
  */
347
374
  get writers(): IChatModel.IWriter[] {
348
- return this._writers;
375
+ return [...this._writers.values()];
349
376
  }
350
377
 
351
378
  /**
@@ -666,19 +693,84 @@ export abstract class AbstractChatModel implements IChatModel {
666
693
  * This implementation only propagate the list via a signal.
667
694
  */
668
695
  updateWriters(writers: IChatModel.IWriter[]): void {
669
- const compareWriters = (a: IChatModel.IWriter, b: IChatModel.IWriter) => {
670
- return (
671
- a.user.username === b.user.username &&
672
- a.user.display_name === b.user.display_name &&
673
- a.messageID === b.messageID
674
- );
696
+ // Reconcile the writers map with the provided snapshot (used by
697
+ // snapshot-style transports such as RTC awareness). Any auto-clear timers
698
+ // are dropped, since the snapshot is authoritative.
699
+ this._clearAllWriterTimers();
700
+ const next = new Map<string, IChatModel.IWriter>();
701
+ for (const writer of writers) {
702
+ next.set(writer.user.username, writer);
703
+ }
704
+ if (!Private.writersEqual(this._writers, next)) {
705
+ this._writers = next;
706
+ this._writersChanged.emit(this.writers);
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Mark a user as currently writing. See IChatModel.setWritingStatus.
712
+ */
713
+ setWritingStatus(
714
+ user: IUser,
715
+ status?: IChatModel.IWritingStatus,
716
+ timeout?: number
717
+ ): void {
718
+ const writer: IChatModel.IWriter = {
719
+ user,
720
+ messageID: status?.messageID,
721
+ typingIndicator: status?.typingIndicator
675
722
  };
676
- if (!ArrayExt.shallowEqual(this._writers, writers, compareWriters)) {
677
- this._writers = writers;
678
- this._writersChanged.emit(writers);
723
+ this._clearWriterTimer(user.username);
724
+ if (timeout !== undefined) {
725
+ this._writerTimers.set(
726
+ user.username,
727
+ window.setTimeout(() => this._removeWriter(user.username), timeout)
728
+ );
729
+ }
730
+ const previous = this._writers.get(user.username);
731
+ this._writers.set(user.username, writer);
732
+ if (!previous || !Private.writerEqual(previous, writer)) {
733
+ this._writersChanged.emit(this.writers);
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Remove a user from the writers list. See IChatModel.clearWritingStatus.
739
+ */
740
+ clearWritingStatus(user: IUser): void {
741
+ this._removeWriter(user.username);
742
+ }
743
+
744
+ /**
745
+ * Broadcast the current user's writing status. Default no-op; transports
746
+ * override this (RTC awareness, WebSocket frame).
747
+ */
748
+ broadcastWritingStatus(_status: IChatModel.IWritingStatus | null): void {
749
+ // no-op by default
750
+ }
751
+
752
+ private _removeWriter(username: string): void {
753
+ this._clearWriterTimer(username);
754
+ if (this._writers.delete(username)) {
755
+ this._writersChanged.emit(this.writers);
679
756
  }
680
757
  }
681
758
 
759
+ private _clearWriterTimer(username: string): void {
760
+ const timer = this._writerTimers.get(username);
761
+ if (timer !== undefined) {
762
+ window.clearTimeout(timer);
763
+ this._writerTimers.delete(username);
764
+ }
765
+ }
766
+
767
+ private _clearAllWriterTimers(): void {
768
+ for (const timer of this._writerTimers.values()) {
769
+ window.clearTimeout(timer);
770
+ }
771
+ this._writerTimers.clear();
772
+ }
773
+
682
774
  /**
683
775
  * Create the chat context that will be passed to the input model.
684
776
  */
@@ -793,7 +885,8 @@ export abstract class AbstractChatModel implements IChatModel {
793
885
  private _selectionWatcher: ISelectionWatcher | null;
794
886
  private _documentManager: IDocumentManager | null;
795
887
  private _notificationId: string | null = null;
796
- private _writers: IChatModel.IWriter[] = [];
888
+ private _writers = new Map<string, IChatModel.IWriter>();
889
+ private _writerTimers = new Map<string, number>();
797
890
  private _messageEditions = new Map<string, IInputModel>();
798
891
  private _messagesUpdated = new Signal<IChatModel, void>(this);
799
892
  private _messageChanged = new Signal<IChatModel, IMessage>(this);
@@ -877,6 +970,25 @@ export namespace IChatModel {
877
970
  * The message ID (optional)
878
971
  */
879
972
  messageID?: string;
973
+ /**
974
+ * Custom status text to display instead of the default "is typing…",
975
+ * e.g. "is running `ripgrep …`". Falls back to the default when unset.
976
+ */
977
+ typingIndicator?: string;
978
+ }
979
+
980
+ /**
981
+ * The writing status broadcast by (or on behalf of) a user.
982
+ */
983
+ export interface IWritingStatus {
984
+ /**
985
+ * The ID of the message being edited, if any.
986
+ */
987
+ messageID?: string;
988
+ /**
989
+ * Optional custom typing-indicator text (see IWriter.typingIndicator).
990
+ */
991
+ typingIndicator?: string;
880
992
  }
881
993
  }
882
994
 
@@ -942,3 +1054,42 @@ export abstract class AbstractChatContext implements IChatContext {
942
1054
 
943
1055
  protected _model: IChatModel;
944
1056
  }
1057
+
1058
+ /**
1059
+ * A namespace for private functionality.
1060
+ */
1061
+ namespace Private {
1062
+ /**
1063
+ * Whether two writers are equivalent for change-detection purposes.
1064
+ */
1065
+ export function writerEqual(
1066
+ a: IChatModel.IWriter,
1067
+ b: IChatModel.IWriter
1068
+ ): boolean {
1069
+ return (
1070
+ a.user.username === b.user.username &&
1071
+ a.user.display_name === b.user.display_name &&
1072
+ a.messageID === b.messageID &&
1073
+ a.typingIndicator === b.typingIndicator
1074
+ );
1075
+ }
1076
+
1077
+ /**
1078
+ * Whether two writer maps are equivalent.
1079
+ */
1080
+ export function writersEqual(
1081
+ a: Map<string, IChatModel.IWriter>,
1082
+ b: Map<string, IChatModel.IWriter>
1083
+ ): boolean {
1084
+ if (a.size !== b.size) {
1085
+ return false;
1086
+ }
1087
+ for (const [username, writer] of a) {
1088
+ const other = b.get(username);
1089
+ if (!other || !writerEqual(writer, other)) {
1090
+ return false;
1091
+ }
1092
+ }
1093
+ return true;
1094
+ }
1095
+ }