@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.
- package/lib/__tests__/writers.spec.d.ts +1 -0
- package/lib/__tests__/writers.spec.js +61 -0
- package/lib/components/messages/message-renderer.js +8 -1
- package/lib/components/writing-indicator.js +18 -2
- package/lib/model.d.ts +56 -0
- package/lib/model.js +96 -10
- package/package.json +1 -1
- package/src/__tests__/writers.spec.ts +75 -0
- package/src/components/messages/message-renderer.tsx +11 -1
- package/src/components/writing-indicator.tsx +23 -7
- package/src/model.ts +163 -12
|
@@ -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
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
this.
|
|
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
|
@@ -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
|
|
45
|
-
w
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
this.
|
|
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
|
|
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
|
+
}
|