@jupyter/chat 0.24.1 → 0.25.0-alpha.1

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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import React, { act } from 'react';
6
+ import { createRoot } from 'react-dom/client';
7
+ // React 18 asks test environments to declare themselves, otherwise every
8
+ // `act` call warns.
9
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
10
+ import { WritingIndicator } from '../components/writing-indicator';
11
+ const alice = { username: 'a', name: 'Alice', display_name: 'Alice' };
12
+ const writer = (user, typingIndicator) => ({ user, typingIndicator });
13
+ describe('WritingIndicator accessibility', () => {
14
+ let container;
15
+ let root;
16
+ const render = (writers) => {
17
+ act(() => {
18
+ root.render(React.createElement(WritingIndicator, { writers: writers }));
19
+ });
20
+ return container.querySelector('.jp-chat-writers');
21
+ };
22
+ beforeEach(() => {
23
+ container = document.createElement('div');
24
+ document.body.appendChild(container);
25
+ root = createRoot(container);
26
+ });
27
+ afterEach(() => {
28
+ act(() => root.unmount());
29
+ container.remove();
30
+ });
31
+ it('is a polite live region, so a reply on its way is announced', () => {
32
+ // The indicator is the only signal that someone is replying. Without a
33
+ // live region it is visible text that assistive technology never speaks.
34
+ const el = render([]);
35
+ expect(el.getAttribute('role')).toBe('status');
36
+ expect(el.getAttribute('aria-live')).toBe('polite');
37
+ });
38
+ it('reads the whole phrase rather than a fragment of it', () => {
39
+ // Without aria-atomic, a change to part of the text can be announced on
40
+ // its own, so a reader hears a bare name with no context.
41
+ expect(render([]).getAttribute('aria-atomic')).toBe('true');
42
+ });
43
+ it('announces who is writing', () => {
44
+ expect(render([writer(alice)]).textContent).toContain('Alice is typing');
45
+ });
46
+ it('announces a custom indicator, not just a generic one', () => {
47
+ const el = render([writer(alice, 'is running `ripgrep`')]);
48
+ expect(el.textContent).toContain('Alice is running `ripgrep`');
49
+ });
50
+ it('says nothing when nobody is writing', () => {
51
+ var _a;
52
+ // The container is always rendered to reserve space, and holds a
53
+ // non-breaking space as a placeholder. That placeholder must not be
54
+ // announced as if it were a message.
55
+ const text = ((_a = render([]).textContent) !== null && _a !== void 0 ? _a : '').replace(/ /g, '').trim();
56
+ expect(text).toBe('');
57
+ });
58
+ });
@@ -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
  }
@@ -41,7 +57,17 @@ export function WritingIndicator(props) {
41
57
  const trans = useTranslator();
42
58
  // Always render the container to reserve space, even if no writers
43
59
  const writersText = writers.length > 0 ? formatWritersText(writers, trans) : '';
44
- return (React.createElement(Box, { className: WRITERS_ELEMENT_CLASSNAME, sx: {
60
+ return (React.createElement(Box, { className: WRITERS_ELEMENT_CLASSNAME,
61
+ // The indicator already says something useful, "Alice is typing..." or
62
+ // "Jupyternaut is running `ripgrep`", but only on screen. Announcing it
63
+ // politely means a screen reader user learns a reply is coming without
64
+ // being interrupted mid-sentence.
65
+ //
66
+ // The region is the container rather than the text, so it is present in
67
+ // the accessibility tree before a writer appears and the change is
68
+ // announced. `aria-atomic` keeps the phrase together: without it a name
69
+ // change alone can be read out on its own, stripped of its context.
70
+ role: "status", "aria-live": "polite", "aria-atomic": "true", sx: {
45
71
  ...props.sx,
46
72
  minHeight: '16px'
47
73
  } },
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.1",
3
+ "version": "0.25.0-alpha.1",
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
+ });
@@ -0,0 +1,76 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import React, { act } from 'react';
7
+ import { createRoot, Root } from 'react-dom/client';
8
+
9
+ // React 18 asks test environments to declare themselves, otherwise every
10
+ // `act` call warns.
11
+ (
12
+ globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
13
+ ).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ import { WritingIndicator } from '../components/writing-indicator';
16
+ import { IChatModel } from '../model';
17
+ import { IUser } from '../types';
18
+
19
+ const alice: IUser = { username: 'a', name: 'Alice', display_name: 'Alice' };
20
+
21
+ const writer = (user: IUser, typingIndicator?: string): IChatModel.IWriter =>
22
+ ({ user, typingIndicator }) as IChatModel.IWriter;
23
+
24
+ describe('WritingIndicator accessibility', () => {
25
+ let container: HTMLDivElement;
26
+ let root: Root;
27
+
28
+ const render = (writers: IChatModel.IWriter[]) => {
29
+ act(() => {
30
+ root.render(<WritingIndicator writers={writers} />);
31
+ });
32
+ return container.querySelector('.jp-chat-writers') as HTMLElement;
33
+ };
34
+
35
+ beforeEach(() => {
36
+ container = document.createElement('div');
37
+ document.body.appendChild(container);
38
+ root = createRoot(container);
39
+ });
40
+
41
+ afterEach(() => {
42
+ act(() => root.unmount());
43
+ container.remove();
44
+ });
45
+
46
+ it('is a polite live region, so a reply on its way is announced', () => {
47
+ // The indicator is the only signal that someone is replying. Without a
48
+ // live region it is visible text that assistive technology never speaks.
49
+ const el = render([]);
50
+ expect(el.getAttribute('role')).toBe('status');
51
+ expect(el.getAttribute('aria-live')).toBe('polite');
52
+ });
53
+
54
+ it('reads the whole phrase rather than a fragment of it', () => {
55
+ // Without aria-atomic, a change to part of the text can be announced on
56
+ // its own, so a reader hears a bare name with no context.
57
+ expect(render([]).getAttribute('aria-atomic')).toBe('true');
58
+ });
59
+
60
+ it('announces who is writing', () => {
61
+ expect(render([writer(alice)]).textContent).toContain('Alice is typing');
62
+ });
63
+
64
+ it('announces a custom indicator, not just a generic one', () => {
65
+ const el = render([writer(alice, 'is running `ripgrep`')]);
66
+ expect(el.textContent).toContain('Alice is running `ripgrep`');
67
+ });
68
+
69
+ it('says nothing when nobody is writing', () => {
70
+ // The container is always rendered to reserve space, and holds a
71
+ // non-breaking space as a placeholder. That placeholder must not be
72
+ // announced as if it were a message.
73
+ const text = (render([]).textContent ?? '').replace(/ /g, '').trim();
74
+ expect(text).toBe('');
75
+ });
76
+ });
@@ -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]);
@@ -76,6 +92,18 @@ export function WritingIndicator(
76
92
  return (
77
93
  <Box
78
94
  className={WRITERS_ELEMENT_CLASSNAME}
95
+ // The indicator already says something useful, "Alice is typing..." or
96
+ // "Jupyternaut is running `ripgrep`", but only on screen. Announcing it
97
+ // politely means a screen reader user learns a reply is coming without
98
+ // being interrupted mid-sentence.
99
+ //
100
+ // The region is the container rather than the text, so it is present in
101
+ // the accessibility tree before a writer appears and the change is
102
+ // announced. `aria-atomic` keeps the phrase together: without it a name
103
+ // change alone can be read out on its own, stripped of its context.
104
+ role="status"
105
+ aria-live="polite"
106
+ aria-atomic="true"
79
107
  sx={{
80
108
  ...props.sx,
81
109
  minHeight: '16px'
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
+ }