@jupyter/chat 0.20.0-alpha.0 → 0.20.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/__tests__/preamble-registry.spec.d.ts +1 -0
  2. package/lib/__tests__/preamble-registry.spec.js +37 -0
  3. package/lib/components/chat.d.ts +5 -1
  4. package/lib/components/input/buttons/send-button.js +3 -6
  5. package/lib/components/messages/footer.js +3 -0
  6. package/lib/components/messages/index.d.ts +1 -0
  7. package/lib/components/messages/index.js +1 -0
  8. package/lib/components/messages/message-renderer.js +7 -17
  9. package/lib/components/messages/message.js +4 -3
  10. package/lib/components/messages/messages.js +3 -1
  11. package/lib/components/messages/preamble.d.ts +12 -0
  12. package/lib/components/messages/preamble.js +31 -0
  13. package/lib/message.d.ts +2 -1
  14. package/lib/message.js +3 -0
  15. package/lib/model.d.ts +1 -0
  16. package/lib/model.js +14 -13
  17. package/lib/registers/index.d.ts +1 -0
  18. package/lib/registers/index.js +1 -0
  19. package/lib/registers/preambles.d.ts +44 -0
  20. package/lib/registers/preambles.js +29 -0
  21. package/lib/types.d.ts +15 -3
  22. package/package.json +1 -1
  23. package/src/__tests__/preamble-registry.spec.ts +51 -0
  24. package/src/components/chat.tsx +6 -1
  25. package/src/components/input/buttons/send-button.tsx +4 -6
  26. package/src/components/messages/footer.tsx +4 -0
  27. package/src/components/messages/index.ts +1 -0
  28. package/src/components/messages/message-renderer.tsx +15 -20
  29. package/src/components/messages/message.tsx +8 -2
  30. package/src/components/messages/messages.tsx +11 -2
  31. package/src/components/messages/preamble.tsx +55 -0
  32. package/src/message.ts +10 -1
  33. package/src/model.ts +20 -13
  34. package/src/registers/index.ts +1 -0
  35. package/src/registers/preambles.ts +65 -0
  36. package/src/types.ts +15 -3
  37. package/style/chat.css +4 -4
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { MessagePreambleRegistry } from '../registers/preambles';
6
+ describe('MessagePreambleRegistry', () => {
7
+ let registry;
8
+ beforeEach(() => {
9
+ registry = new MessagePreambleRegistry();
10
+ });
11
+ it('should start with no components', () => {
12
+ expect(registry.getComponents()).toEqual([]);
13
+ });
14
+ it('should add a component', () => {
15
+ const component = () => null;
16
+ registry.addComponent(component);
17
+ expect(registry.getComponents()).toHaveLength(1);
18
+ expect(registry.getComponents()[0]).toBe(component);
19
+ });
20
+ it('should preserve insertion order', () => {
21
+ const first = () => null;
22
+ const second = () => null;
23
+ registry.addComponent(first);
24
+ registry.addComponent(second);
25
+ const components = registry.getComponents();
26
+ expect(components).toHaveLength(2);
27
+ expect(components[0]).toBe(first);
28
+ expect(components[1]).toBe(second);
29
+ });
30
+ it('should return a copy from getComponents', () => {
31
+ const component = () => null;
32
+ registry.addComponent(component);
33
+ const result = registry.getComponents();
34
+ result.push(() => null);
35
+ expect(registry.getComponents()).toHaveLength(1);
36
+ });
37
+ });
@@ -3,7 +3,7 @@ import { IThemeManager } from '@jupyterlab/apputils';
3
3
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
4
  import { IInputToolbarRegistry } from './input';
5
5
  import { IChatModel } from '../model';
6
- import { IAttachmentOpenerRegistry, IChatCommandRegistry, IMessageFooterRegistry } from '../registers';
6
+ import { IAttachmentOpenerRegistry, IChatCommandRegistry, IMessageFooterRegistry, IMessagePreambleRegistry } from '../registers';
7
7
  import { ChatArea } from '../types';
8
8
  export declare function ChatBody(props: Chat.IChatProps): JSX.Element;
9
9
  export declare function Chat(props: Chat.IOptions): JSX.Element;
@@ -39,6 +39,10 @@ export declare namespace Chat {
39
39
  * The footer registry.
40
40
  */
41
41
  messageFooterRegistry?: IMessageFooterRegistry;
42
+ /**
43
+ * The preamble registry for content above message body.
44
+ */
45
+ messagePreambleRegistry?: IMessagePreambleRegistry;
42
46
  /**
43
47
  * The welcome message.
44
48
  */
@@ -10,7 +10,7 @@ const SEND_BUTTON_CLASS = 'jp-chat-send-button';
10
10
  * The send button.
11
11
  */
12
12
  export function SendButton(props) {
13
- const { model, chatModel, chatCommandRegistry, edit } = props;
13
+ const { model, chatCommandRegistry, edit } = props;
14
14
  // Don't show this button when in edit mode
15
15
  if (edit) {
16
16
  return React.createElement(React.Fragment, null);
@@ -45,12 +45,9 @@ export function SendButton(props) {
45
45
  async function send() {
46
46
  // Run all command providers
47
47
  await (chatCommandRegistry === null || chatCommandRegistry === void 0 ? void 0 : chatCommandRegistry.onSubmit(model));
48
- // send message through chat model
49
- await (chatModel === null || chatModel === void 0 ? void 0 : chatModel.sendMessage({
50
- body: model.value
51
- }));
52
- // clear input model value & re-focus
48
+ const body = model.value;
53
49
  model.value = '';
50
+ model.send(body);
54
51
  model.focus();
55
52
  }
56
53
  return (React.createElement(TooltippedIconButton, { onClick: send, tooltip: tooltip, disabled: disabled, buttonProps: {
@@ -17,6 +17,9 @@ export function MessageFooterComponent(props) {
17
17
  return null;
18
18
  }
19
19
  const footer = messageFooterRegistry.getFooter();
20
+ if (!footer.left && !footer.center && !footer.right) {
21
+ return null;
22
+ }
20
23
  return (React.createElement(Box, { sx: { display: 'flex', justifyContent: 'space-between' } },
21
24
  ((_a = footer.left) === null || _a === void 0 ? void 0 : _a.component) ? (React.createElement(footer.left.component, { message: message, model: model })) : (React.createElement("div", null)),
22
25
  ((_b = footer.center) === null || _b === void 0 ? void 0 : _b.component) ? (React.createElement(footer.center.component, { message: message, model: model })) : (React.createElement("div", null)),
@@ -4,5 +4,6 @@ export * from './message';
4
4
  export * from './message-renderer';
5
5
  export * from './messages';
6
6
  export * from './navigation';
7
+ export * from './preamble';
7
8
  export * from './toolbar';
8
9
  export * from './welcome';
@@ -8,5 +8,6 @@ export * from './message';
8
8
  export * from './message-renderer';
9
9
  export * from './messages';
10
10
  export * from './navigation';
11
+ export * from './preamble';
11
12
  export * from './toolbar';
12
13
  export * from './welcome';
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { MessageLoop } from '@lumino/messaging';
6
6
  import { Widget } from '@lumino/widgets';
7
- import React, { useState, useEffect, useRef } from 'react';
7
+ import React, { useState, useEffect } from 'react';
8
8
  import { createPortal } from 'react-dom';
9
9
  import { MessageToolbar } from './toolbar';
10
10
  import { CodeToolbar } from '../code-blocks/code-toolbar';
@@ -18,17 +18,13 @@ const DEFAULT_MIME_TYPE = 'text/markdown';
18
18
  function MessageRendererBase(props) {
19
19
  const { message } = props;
20
20
  const { model, rmRegistry } = useChatContext();
21
- const containerRef = useRef(null);
21
+ // The rendered content, return by the mime renderer.
22
+ const [renderedContent, setRenderedContent] = useState(null);
22
23
  // Allow edition only on text messages.
23
24
  const [canEdit, setCanEdit] = useState(false);
24
25
  // Each element is a two-tuple with the structure [codeToolbarRoot, codeToolbarProps].
25
26
  const [codeToolbarDefns, setCodeToolbarDefns] = useState([]);
26
27
  useEffect(() => {
27
- let node = null;
28
- const container = containerRef.current;
29
- if (!container) {
30
- return;
31
- }
32
28
  const renderContent = async () => {
33
29
  var _a, _b;
34
30
  let isMarkdownRenderer = true;
@@ -106,21 +102,15 @@ function MessageRendererBase(props) {
106
102
  });
107
103
  setCodeToolbarDefns(newCodeToolbarDefns);
108
104
  }
109
- // Add the rendered node to the DOM.
110
- node = renderer.node;
111
- container.insertBefore(node, container.firstChild);
105
+ // Update the content.
106
+ setRenderedContent(renderer.node);
112
107
  // Resolve the rendered promise.
113
108
  props.rendered.resolve();
114
109
  };
115
110
  renderContent();
116
- return () => {
117
- if (node && container.contains(node)) {
118
- container.removeChild(node);
119
- }
120
- node = null;
121
- };
122
111
  }, [message.body, message.mentions, rmRegistry]);
123
- return (React.createElement("div", { className: RENDERED_CLASS, ref: containerRef },
112
+ return (React.createElement(React.Fragment, null,
113
+ renderedContent && (React.createElement("div", { className: RENDERED_CLASS, ref: node => node && node.replaceChildren(renderedContent) })),
124
114
  React.createElement(MessageToolbar, { edit: canEdit ? props.edit : undefined, delete: props.delete }),
125
115
  // Render a `CodeToolbar` element underneath each code block.
126
116
  // We use ReactDOM.createPortal() so each `CodeToolbar` element is able
@@ -9,6 +9,7 @@ import { ChatInput } from '../input';
9
9
  import { useChatContext } from '../../context';
10
10
  import { InputModel } from '../../input-model';
11
11
  import { replaceSpanToMention } from '../../utils';
12
+ const MESSAGE_CONTAINER_CLASS = 'jp-chat-message-container';
12
13
  /**
13
14
  * The message component body.
14
15
  */
@@ -56,7 +57,7 @@ export const ChatMessage = forwardRef((props, ref) => {
56
57
  }, [props.message]);
57
58
  // Create an input model only if the message is edited.
58
59
  const startEdition = () => {
59
- var _a;
60
+ var _a, _b;
60
61
  if (!canEdit || !(typeof message.body === 'string')) {
61
62
  return;
62
63
  }
@@ -75,7 +76,7 @@ export const ChatMessage = forwardRef((props, ref) => {
75
76
  config: {
76
77
  sendWithShiftEnter: model.config.sendWithShiftEnter
77
78
  },
78
- attachments: message.attachments,
79
+ attachments: structuredClone((_b = message.attachments) !== null && _b !== void 0 ? _b : []),
79
80
  mentions: message.mentions
80
81
  });
81
82
  model.addEditionModel(message.id, inputModel);
@@ -110,7 +111,7 @@ export const ChatMessage = forwardRef((props, ref) => {
110
111
  model.deleteMessage(id);
111
112
  };
112
113
  // Empty if the message has been deleted.
113
- return deleted ? (React.createElement("div", { ref: ref, "data-index": props.index })) : (React.createElement("div", { ref: ref, "data-index": props.index },
114
+ return deleted ? (React.createElement("div", { ref: ref, "data-index": props.index })) : (React.createElement("div", { ref: ref, "data-index": props.index, className: MESSAGE_CONTAINER_CLASS },
114
115
  edit && canEdit && model.getEditionModel(message.id) ? (React.createElement(ChatInput, { onCancel: () => cancelEdition(), model: model.getEditionModel(message.id), edit: true })) : (React.createElement(MessageRenderer, { message: message, edit: canEdit ? startEdition : undefined, delete: canDelete ? () => deleteMessage(message.id) : undefined, rendered: props.renderedPromise })),
115
116
  message.attachments && !edit && (
116
117
  // Display the attachments only if message is not edited, otherwise the
@@ -9,6 +9,7 @@ import React, { useEffect, useState, useRef } from 'react';
9
9
  import { MessageFooterComponent } from './footer';
10
10
  import { ChatMessageHeader } from './header';
11
11
  import { ChatMessage } from './message';
12
+ import { MessagePreambleComponent } from './preamble';
12
13
  import { Navigation } from './navigation';
13
14
  import { WelcomeMessage } from './welcome';
14
15
  import { ScrollContainer } from '../scroll-container';
@@ -22,7 +23,7 @@ const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
22
23
  */
23
24
  export function ChatMessages() {
24
25
  var _a;
25
- const { area, messageFooterRegistry, model, welcomeMessage } = useChatContext();
26
+ const { area, messageFooterRegistry, messagePreambleRegistry, model, welcomeMessage } = useChatContext();
26
27
  const [messages, setMessages] = useState(model.messages);
27
28
  const refMsgBox = useRef(null);
28
29
  const [allRendered, setAllRendered] = useState(false);
@@ -161,6 +162,7 @@ export function ChatMessages() {
161
162
  })
162
163
  }, className: clsx(MESSAGE_CLASS, message.stacked ? MESSAGE_STACKED_CLASS : '') },
163
164
  React.createElement(ChatMessageHeader, { message: message, isCurrentUser: isCurrentUser }),
165
+ messagePreambleRegistry && (React.createElement(MessagePreambleComponent, { message: message })),
164
166
  React.createElement(ChatMessage, { message: message, index: i, renderedPromise: renderedPromise.current[i], ref: el => (listRef.current[i] = el) }),
165
167
  messageFooterRegistry && (React.createElement(MessageFooterComponent, { message: message }))));
166
168
  }))),
@@ -0,0 +1,12 @@
1
+ /// <reference types="react" />
2
+ import { IMessage } from '../../types';
3
+ /**
4
+ * The preamble component properties.
5
+ */
6
+ export interface IMessagePreambleProps {
7
+ message: IMessage;
8
+ }
9
+ /**
10
+ * Renders all registered preamble components vertically above the message body.
11
+ */
12
+ export declare function MessagePreambleComponent(props: IMessagePreambleProps): JSX.Element | null;
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import React, { useEffect, useState } from 'react';
6
+ import { useChatContext } from '../../context';
7
+ /**
8
+ * Renders all registered preamble components vertically above the message body.
9
+ */
10
+ export function MessagePreambleComponent(props) {
11
+ const [message, setMessage] = useState(props.message.content);
12
+ useEffect(() => {
13
+ function messageChanged() {
14
+ setMessage(props.message.content);
15
+ }
16
+ props.message.changed.connect(messageChanged);
17
+ setMessage(props.message.content);
18
+ return () => {
19
+ props.message.changed.disconnect(messageChanged);
20
+ };
21
+ }, [props.message]);
22
+ const { model, messagePreambleRegistry } = useChatContext();
23
+ if (!messagePreambleRegistry) {
24
+ return null;
25
+ }
26
+ const components = messagePreambleRegistry.getComponents();
27
+ if (!components.length) {
28
+ return null;
29
+ }
30
+ return (React.createElement(React.Fragment, null, components.map((Component, i) => (React.createElement(Component, { key: i, model: model, message: message })))));
31
+ }
package/lib/message.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { IRenderMime } from '@jupyterlab/rendermime';
2
2
  import { ISignal } from '@lumino/signaling';
3
- import { IAttachment, IMessageContent, IMessage, IUser } from './types';
3
+ import { IAttachment, IMessageContent, IMessage, IMessageMetadata, IUser } from './types';
4
4
  /**
5
5
  * The message object.
6
6
  */
@@ -29,6 +29,7 @@ export declare class Message implements IMessage {
29
29
  get deleted(): boolean | undefined;
30
30
  get edited(): boolean | undefined;
31
31
  get stacked(): boolean | undefined;
32
+ get metadata(): IMessageMetadata | undefined;
32
33
  /**
33
34
  * A signal emitting when the message has been updated.
34
35
  */
package/lib/message.js CHANGED
@@ -58,6 +58,9 @@ export class Message {
58
58
  get stacked() {
59
59
  return this._content.stacked;
60
60
  }
61
+ get metadata() {
62
+ return this._content.metadata;
63
+ }
61
64
  /**
62
65
  * A signal emitting when the message has been updated.
63
66
  */
package/lib/model.d.ts CHANGED
@@ -341,6 +341,7 @@ export declare abstract class AbstractChatModel implements IChatModel {
341
341
  private _notify;
342
342
  private _messages;
343
343
  private _unreadMessages;
344
+ private _lastRead;
344
345
  private _messagesInViewport;
345
346
  private _id;
346
347
  private _name;
package/lib/model.js CHANGED
@@ -21,6 +21,7 @@ export class AbstractChatModel {
21
21
  var _a, _b, _c, _d;
22
22
  this._messages = [];
23
23
  this._unreadMessages = [];
24
+ this._lastRead = 0;
24
25
  this._messagesInViewport = [];
25
26
  this._name = '';
26
27
  this._readyDelegate = new PromiseDelegate();
@@ -71,6 +72,12 @@ export class AbstractChatModel {
71
72
  }
72
73
  set id(value) {
73
74
  this._id = value;
75
+ // Update the last read message.
76
+ const storage = JSON.parse(localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}');
77
+ if (typeof storage.lastRead === 'number' &&
78
+ storage.lastRead > this._lastRead) {
79
+ this._lastRead = storage.lastRead;
80
+ }
74
81
  }
75
82
  /**
76
83
  * The chat model name.
@@ -124,17 +131,14 @@ export class AbstractChatModel {
124
131
  * Timestamp of the last read message in local storage.
125
132
  */
126
133
  get lastRead() {
127
- var _a;
128
- if (this._id === undefined) {
129
- return 0;
130
- }
131
- const storage = JSON.parse(localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}');
132
- return (_a = storage.lastRead) !== null && _a !== void 0 ? _a : 0;
134
+ return this._lastRead;
133
135
  }
134
136
  set lastRead(value) {
137
+ this._lastRead = value;
135
138
  if (this._id === undefined) {
136
139
  return;
137
140
  }
141
+ // Save the last read message to the local storage, for persistence across reload.
138
142
  const storage = JSON.parse(localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}');
139
143
  storage.lastRead = value;
140
144
  localStorage.setItem(`@jupyter/chat:${this._id}`, JSON.stringify(storage));
@@ -192,17 +196,16 @@ export class AbstractChatModel {
192
196
  return this._unreadMessages;
193
197
  }
194
198
  set unreadMessages(unread) {
195
- var _a;
196
199
  const recentlyRead = this._unreadMessages.filter(elem => !unread.includes(elem));
197
200
  const unreadCountDiff = unread.length - this._unreadMessages.length;
198
201
  this._unreadMessages = unread;
199
202
  this._unreadChanged.emit(this._unreadMessages);
200
203
  // Notify the change.
201
204
  this._notify(unread.length, unreadCountDiff > 0);
202
- // Save the last read to the local storage.
203
- if (this._id !== undefined && recentlyRead.length) {
205
+ // Save the last read.
206
+ if (recentlyRead.length) {
204
207
  let lastReadChanged = false;
205
- let lastRead = (_a = this.lastRead) !== null && _a !== void 0 ? _a : this.messages[recentlyRead[0]].time;
208
+ let lastRead = this.lastRead;
206
209
  recentlyRead.forEach(index => {
207
210
  if (this.messages[index].time > lastRead) {
208
211
  lastRead = this.messages[index].time;
@@ -318,15 +321,13 @@ export class AbstractChatModel {
318
321
  * @param messages - the messages list.
319
322
  */
320
323
  messagesInserted(index, messages) {
321
- var _a;
322
324
  const formattedMessages = [];
323
325
  const unreadIndexes = [];
324
- const lastRead = (_a = this.lastRead) !== null && _a !== void 0 ? _a : 0;
325
326
  // Format the messages.
326
327
  messages.forEach((message, idx) => {
327
328
  const formattedMessage = this.formatChatMessage(message);
328
329
  formattedMessages.push(new Message(formattedMessage));
329
- if (message.time > lastRead) {
330
+ if (message.time > this.lastRead) {
330
331
  unreadIndexes.push(index + idx);
331
332
  }
332
333
  });
@@ -1,3 +1,4 @@
1
1
  export * from './attachment-openers';
2
2
  export * from './chat-commands';
3
3
  export * from './footers';
4
+ export * from './preambles';
@@ -5,3 +5,4 @@
5
5
  export * from './attachment-openers';
6
6
  export * from './chat-commands';
7
7
  export * from './footers';
8
+ export * from './preambles';
@@ -0,0 +1,44 @@
1
+ /// <reference types="react" />
2
+ import { Token } from '@lumino/coreutils';
3
+ import { IChatModel } from '../model';
4
+ import { IMessageContent } from '../types';
5
+ /**
6
+ * The token providing the chat preamble registry.
7
+ */
8
+ export declare const IMessagePreambleRegistry: Token<IMessagePreambleRegistry>;
9
+ /**
10
+ * The props passed to each preamble component.
11
+ */
12
+ export type MessagePreambleProps = {
13
+ model: IChatModel;
14
+ message: IMessageContent;
15
+ };
16
+ /**
17
+ * The interface of a registry to provide message preamble components.
18
+ * Preamble components render above the message body, after the header.
19
+ */
20
+ export interface IMessagePreambleRegistry {
21
+ /**
22
+ * Add a preamble component to the registry.
23
+ * Components are rendered in the order they are added.
24
+ */
25
+ addComponent(component: (props: MessagePreambleProps) => JSX.Element | null): void;
26
+ /**
27
+ * Get all registered preamble components.
28
+ */
29
+ getComponents(): ((props: MessagePreambleProps) => JSX.Element | null)[];
30
+ }
31
+ /**
32
+ * The default implementation of the message preamble registry.
33
+ */
34
+ export declare class MessagePreambleRegistry implements IMessagePreambleRegistry {
35
+ /**
36
+ * Add a preamble component to the registry.
37
+ */
38
+ addComponent(component: (props: MessagePreambleProps) => JSX.Element | null): void;
39
+ /**
40
+ * Get all registered preamble components.
41
+ */
42
+ getComponents(): ((props: MessagePreambleProps) => JSX.Element | null)[];
43
+ private _components;
44
+ }
@@ -0,0 +1,29 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { Token } from '@lumino/coreutils';
6
+ /**
7
+ * The token providing the chat preamble registry.
8
+ */
9
+ export const IMessagePreambleRegistry = new Token('@jupyter/chat:ChatPreambleRegistry');
10
+ /**
11
+ * The default implementation of the message preamble registry.
12
+ */
13
+ export class MessagePreambleRegistry {
14
+ constructor() {
15
+ this._components = [];
16
+ }
17
+ /**
18
+ * Add a preamble component to the registry.
19
+ */
20
+ addComponent(component) {
21
+ this._components.push(component);
22
+ }
23
+ /**
24
+ * Get all registered preamble components.
25
+ */
26
+ getComponents() {
27
+ return [...this._components];
28
+ }
29
+ }
package/lib/types.d.ts CHANGED
@@ -53,6 +53,20 @@ export interface IConfig {
53
53
  */
54
54
  showDeleted?: boolean;
55
55
  }
56
+ /**
57
+ * An empty interface to describe optional metadata attached to a chat message.
58
+ * Extensions can augment this interface to add custom fields:
59
+ *
60
+ * ```ts
61
+ * declare module '@jupyter/chat' {
62
+ * interface IMessageMetadata {
63
+ * myField?: MyType;
64
+ * }
65
+ * }
66
+ * ```
67
+ */
68
+ export interface IMessageMetadata {
69
+ }
56
70
  /**
57
71
  * The chat message description.
58
72
  */
@@ -68,10 +82,8 @@ export type IMessageContent<T = IUser, U = IAttachment> = {
68
82
  deleted?: boolean;
69
83
  edited?: boolean;
70
84
  stacked?: boolean;
85
+ metadata?: IMessageMetadata;
71
86
  };
72
- /**
73
- *
74
- */
75
87
  export interface IMessage extends IMessageContent {
76
88
  /**
77
89
  * Update one or several fields of the message.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.20.0-alpha.0",
3
+ "version": "0.20.0-alpha.2",
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,51 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import {
7
+ MessagePreambleRegistry,
8
+ MessagePreambleProps
9
+ } from '../registers/preambles';
10
+
11
+ describe('MessagePreambleRegistry', () => {
12
+ let registry: MessagePreambleRegistry;
13
+
14
+ beforeEach(() => {
15
+ registry = new MessagePreambleRegistry();
16
+ });
17
+
18
+ it('should start with no components', () => {
19
+ expect(registry.getComponents()).toEqual([]);
20
+ });
21
+
22
+ it('should add a component', () => {
23
+ const component: (props: MessagePreambleProps) => JSX.Element | null = () =>
24
+ null;
25
+ registry.addComponent(component);
26
+ expect(registry.getComponents()).toHaveLength(1);
27
+ expect(registry.getComponents()[0]).toBe(component);
28
+ });
29
+
30
+ it('should preserve insertion order', () => {
31
+ const first: (props: MessagePreambleProps) => JSX.Element | null = () =>
32
+ null;
33
+ const second: (props: MessagePreambleProps) => JSX.Element | null = () =>
34
+ null;
35
+ registry.addComponent(first);
36
+ registry.addComponent(second);
37
+ const components = registry.getComponents();
38
+ expect(components).toHaveLength(2);
39
+ expect(components[0]).toBe(first);
40
+ expect(components[1]).toBe(second);
41
+ });
42
+
43
+ it('should return a copy from getComponents', () => {
44
+ const component: (props: MessagePreambleProps) => JSX.Element | null = () =>
45
+ null;
46
+ registry.addComponent(component);
47
+ const result = registry.getComponents();
48
+ result.push(() => null);
49
+ expect(registry.getComponents()).toHaveLength(1);
50
+ });
51
+ });
@@ -24,7 +24,8 @@ import { IChatModel } from '../model';
24
24
  import {
25
25
  IAttachmentOpenerRegistry,
26
26
  IChatCommandRegistry,
27
- IMessageFooterRegistry
27
+ IMessageFooterRegistry,
28
+ IMessagePreambleRegistry
28
29
  } from '../registers';
29
30
  import { ChatArea } from '../types';
30
31
 
@@ -168,6 +169,10 @@ export namespace Chat {
168
169
  * The footer registry.
169
170
  */
170
171
  messageFooterRegistry?: IMessageFooterRegistry;
172
+ /**
173
+ * The preamble registry for content above message body.
174
+ */
175
+ messagePreambleRegistry?: IMessagePreambleRegistry;
171
176
  /**
172
177
  * The welcome message.
173
178
  */
@@ -18,7 +18,7 @@ const SEND_BUTTON_CLASS = 'jp-chat-send-button';
18
18
  export function SendButton(
19
19
  props: InputToolbarRegistry.IToolbarItemProps
20
20
  ): JSX.Element {
21
- const { model, chatModel, chatCommandRegistry, edit } = props;
21
+ const { model, chatCommandRegistry, edit } = props;
22
22
 
23
23
  // Don't show this button when in edit mode
24
24
  if (edit) {
@@ -62,12 +62,10 @@ export function SendButton(
62
62
  // Run all command providers
63
63
  await chatCommandRegistry?.onSubmit(model);
64
64
 
65
- // send message through chat model
66
- await chatModel?.sendMessage({
67
- body: model.value
68
- });
69
- // clear input model value & re-focus
65
+ const body = model.value;
66
+
70
67
  model.value = '';
68
+ model.send(body);
71
69
  model.focus();
72
70
  }
73
71
 
@@ -33,6 +33,10 @@ export function MessageFooterComponent(
33
33
  }
34
34
  const footer = messageFooterRegistry.getFooter();
35
35
 
36
+ if (!footer.left && !footer.center && !footer.right) {
37
+ return null;
38
+ }
39
+
36
40
  return (
37
41
  <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
38
42
  {footer.left?.component ? (
@@ -9,5 +9,6 @@ export * from './message';
9
9
  export * from './message-renderer';
10
10
  export * from './messages';
11
11
  export * from './navigation';
12
+ export * from './preamble';
12
13
  export * from './toolbar';
13
14
  export * from './welcome';
@@ -7,7 +7,7 @@ import { IRenderMime } from '@jupyterlab/rendermime';
7
7
  import { PromiseDelegate } from '@lumino/coreutils';
8
8
  import { MessageLoop } from '@lumino/messaging';
9
9
  import { Widget } from '@lumino/widgets';
10
- import React, { useState, useEffect, useRef } from 'react';
10
+ import React, { useState, useEffect } from 'react';
11
11
  import { createPortal } from 'react-dom';
12
12
 
13
13
  import { MessageToolbar } from './toolbar';
@@ -48,7 +48,10 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
48
48
  const { message } = props;
49
49
  const { model, rmRegistry } = useChatContext();
50
50
 
51
- const containerRef = useRef<HTMLDivElement>(null);
51
+ // The rendered content, return by the mime renderer.
52
+ const [renderedContent, setRenderedContent] = useState<HTMLElement | null>(
53
+ null
54
+ );
52
55
 
53
56
  // Allow edition only on text messages.
54
57
  const [canEdit, setCanEdit] = useState<boolean>(false);
@@ -59,12 +62,6 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
59
62
  >([]);
60
63
 
61
64
  useEffect(() => {
62
- let node: HTMLElement | null = null;
63
- const container = containerRef.current;
64
- if (!container) {
65
- return;
66
- }
67
-
68
65
  const renderContent = async () => {
69
66
  let isMarkdownRenderer = true;
70
67
  let renderer: IRenderMime.IRenderer;
@@ -153,26 +150,24 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
153
150
  setCodeToolbarDefns(newCodeToolbarDefns);
154
151
  }
155
152
 
156
- // Add the rendered node to the DOM.
157
- node = renderer.node;
158
- container.insertBefore(node, container.firstChild);
153
+ // Update the content.
154
+ setRenderedContent(renderer.node);
159
155
 
160
156
  // Resolve the rendered promise.
161
157
  props.rendered.resolve();
162
158
  };
163
159
 
164
160
  renderContent();
165
-
166
- return () => {
167
- if (node && container.contains(node)) {
168
- container.removeChild(node);
169
- }
170
- node = null;
171
- };
172
161
  }, [message.body, message.mentions, rmRegistry]);
173
162
 
174
163
  return (
175
- <div className={RENDERED_CLASS} ref={containerRef}>
164
+ <>
165
+ {renderedContent && (
166
+ <div
167
+ className={RENDERED_CLASS}
168
+ ref={node => node && node.replaceChildren(renderedContent)}
169
+ />
170
+ )}
176
171
  <MessageToolbar
177
172
  edit={canEdit ? props.edit : undefined}
178
173
  delete={props.delete}
@@ -189,7 +184,7 @@ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
189
184
  );
190
185
  })
191
186
  }
192
- </div>
187
+ </>
193
188
  );
194
189
  }
195
190
 
@@ -14,6 +14,8 @@ import { IInputModel, InputModel } from '../../input-model';
14
14
  import { IMessageContent, IMessage } from '../../types';
15
15
  import { replaceSpanToMention } from '../../utils';
16
16
 
17
+ const MESSAGE_CONTAINER_CLASS = 'jp-chat-message-container';
18
+
17
19
  /**
18
20
  * The message component props.
19
21
  */
@@ -104,7 +106,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
104
106
  config: {
105
107
  sendWithShiftEnter: model.config.sendWithShiftEnter
106
108
  },
107
- attachments: message.attachments,
109
+ attachments: structuredClone(message.attachments ?? []),
108
110
  mentions: message.mentions
109
111
  });
110
112
  model.addEditionModel(message.id, inputModel);
@@ -148,7 +150,11 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
148
150
  return deleted ? (
149
151
  <div ref={ref} data-index={props.index}></div>
150
152
  ) : (
151
- <div ref={ref} data-index={props.index}>
153
+ <div
154
+ ref={ref}
155
+ data-index={props.index}
156
+ className={MESSAGE_CONTAINER_CLASS}
157
+ >
152
158
  {edit && canEdit && model.getEditionModel(message.id) ? (
153
159
  <ChatInput
154
160
  onCancel={() => cancelEdition()}
@@ -11,6 +11,7 @@ import React, { useEffect, useState, useRef } from 'react';
11
11
  import { MessageFooterComponent } from './footer';
12
12
  import { ChatMessageHeader } from './header';
13
13
  import { ChatMessage } from './message';
14
+ import { MessagePreambleComponent } from './preamble';
14
15
  import { Navigation } from './navigation';
15
16
  import { WelcomeMessage } from './welcome';
16
17
  import { ScrollContainer } from '../scroll-container';
@@ -27,8 +28,13 @@ const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
27
28
  * The messages list component.
28
29
  */
29
30
  export function ChatMessages(): JSX.Element {
30
- const { area, messageFooterRegistry, model, welcomeMessage } =
31
- useChatContext();
31
+ const {
32
+ area,
33
+ messageFooterRegistry,
34
+ messagePreambleRegistry,
35
+ model,
36
+ welcomeMessage
37
+ } = useChatContext();
32
38
 
33
39
  const [messages, setMessages] = useState<IMessage[]>(model.messages);
34
40
  const refMsgBox = useRef<HTMLDivElement>(null);
@@ -203,6 +209,9 @@ export function ChatMessages(): JSX.Element {
203
209
  message={message}
204
210
  isCurrentUser={isCurrentUser}
205
211
  />
212
+ {messagePreambleRegistry && (
213
+ <MessagePreambleComponent message={message} />
214
+ )}
206
215
  <ChatMessage
207
216
  message={message}
208
217
  index={i}
@@ -0,0 +1,55 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import React, { useEffect, useState } from 'react';
7
+
8
+ import { useChatContext } from '../../context';
9
+ import { IMessage, IMessageContent } from '../../types';
10
+
11
+ /**
12
+ * The preamble component properties.
13
+ */
14
+ export interface IMessagePreambleProps {
15
+ message: IMessage;
16
+ }
17
+
18
+ /**
19
+ * Renders all registered preamble components vertically above the message body.
20
+ */
21
+ export function MessagePreambleComponent(
22
+ props: IMessagePreambleProps
23
+ ): JSX.Element | null {
24
+ const [message, setMessage] = useState<IMessageContent>(
25
+ props.message.content
26
+ );
27
+
28
+ useEffect(() => {
29
+ function messageChanged() {
30
+ setMessage(props.message.content);
31
+ }
32
+ props.message.changed.connect(messageChanged);
33
+ setMessage(props.message.content);
34
+ return () => {
35
+ props.message.changed.disconnect(messageChanged);
36
+ };
37
+ }, [props.message]);
38
+
39
+ const { model, messagePreambleRegistry } = useChatContext();
40
+ if (!messagePreambleRegistry) {
41
+ return null;
42
+ }
43
+ const components = messagePreambleRegistry.getComponents();
44
+ if (!components.length) {
45
+ return null;
46
+ }
47
+
48
+ return (
49
+ <>
50
+ {components.map((Component, i) => (
51
+ <Component key={i} model={model} message={message} />
52
+ ))}
53
+ </>
54
+ );
55
+ }
package/src/message.ts CHANGED
@@ -5,7 +5,13 @@
5
5
 
6
6
  import { IRenderMime } from '@jupyterlab/rendermime';
7
7
  import { ISignal, Signal } from '@lumino/signaling';
8
- import { IAttachment, IMessageContent, IMessage, IUser } from './types';
8
+ import {
9
+ IAttachment,
10
+ IMessageContent,
11
+ IMessage,
12
+ IMessageMetadata,
13
+ IUser
14
+ } from './types';
9
15
 
10
16
  /**
11
17
  * The message object.
@@ -65,6 +71,9 @@ export class Message implements IMessage {
65
71
  get stacked(): boolean | undefined {
66
72
  return this._content.stacked;
67
73
  }
74
+ get metadata(): IMessageMetadata | undefined {
75
+ return this._content.metadata;
76
+ }
68
77
 
69
78
  /**
70
79
  * A signal emitting when the message has been updated.
package/src/model.ts CHANGED
@@ -268,6 +268,17 @@ export abstract class AbstractChatModel implements IChatModel {
268
268
  }
269
269
  set id(value: string | undefined) {
270
270
  this._id = value;
271
+
272
+ // Update the last read message.
273
+ const storage = JSON.parse(
274
+ localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}'
275
+ );
276
+ if (
277
+ typeof storage.lastRead === 'number' &&
278
+ storage.lastRead > this._lastRead
279
+ ) {
280
+ this._lastRead = storage.lastRead;
281
+ }
271
282
  }
272
283
 
273
284
  /**
@@ -330,18 +341,15 @@ export abstract class AbstractChatModel implements IChatModel {
330
341
  * Timestamp of the last read message in local storage.
331
342
  */
332
343
  get lastRead(): number {
333
- if (this._id === undefined) {
334
- return 0;
335
- }
336
- const storage = JSON.parse(
337
- localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}'
338
- );
339
- return storage.lastRead ?? 0;
344
+ return this._lastRead;
340
345
  }
341
346
  set lastRead(value: number) {
347
+ this._lastRead = value;
348
+
342
349
  if (this._id === undefined) {
343
350
  return;
344
351
  }
352
+ // Save the last read message to the local storage, for persistence across reload.
345
353
  const storage = JSON.parse(
346
354
  localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}'
347
355
  );
@@ -422,10 +430,10 @@ export abstract class AbstractChatModel implements IChatModel {
422
430
  // Notify the change.
423
431
  this._notify(unread.length, unreadCountDiff > 0);
424
432
 
425
- // Save the last read to the local storage.
426
- if (this._id !== undefined && recentlyRead.length) {
433
+ // Save the last read.
434
+ if (recentlyRead.length) {
427
435
  let lastReadChanged = false;
428
- let lastRead = this.lastRead ?? this.messages[recentlyRead[0]].time;
436
+ let lastRead = this.lastRead;
429
437
  recentlyRead.forEach(index => {
430
438
  if (this.messages[index].time > lastRead) {
431
439
  lastRead = this.messages[index].time;
@@ -569,13 +577,11 @@ export abstract class AbstractChatModel implements IChatModel {
569
577
  const formattedMessages: IMessage[] = [];
570
578
  const unreadIndexes: number[] = [];
571
579
 
572
- const lastRead = this.lastRead ?? 0;
573
-
574
580
  // Format the messages.
575
581
  messages.forEach((message, idx) => {
576
582
  const formattedMessage = this.formatChatMessage(message);
577
583
  formattedMessages.push(new Message(formattedMessage));
578
- if (message.time > lastRead) {
584
+ if (message.time > this.lastRead) {
579
585
  unreadIndexes.push(index + idx);
580
586
  }
581
587
  });
@@ -714,6 +720,7 @@ export abstract class AbstractChatModel implements IChatModel {
714
720
 
715
721
  private _messages: IMessage[] = [];
716
722
  private _unreadMessages: number[] = [];
723
+ private _lastRead: number = 0;
717
724
  private _messagesInViewport: number[] = [];
718
725
  private _id: string | undefined;
719
726
  private _name: string = '';
@@ -6,3 +6,4 @@
6
6
  export * from './attachment-openers';
7
7
  export * from './chat-commands';
8
8
  export * from './footers';
9
+ export * from './preambles';
@@ -0,0 +1,65 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { Token } from '@lumino/coreutils';
7
+ import { IChatModel } from '../model';
8
+ import { IMessageContent } from '../types';
9
+
10
+ /**
11
+ * The token providing the chat preamble registry.
12
+ */
13
+ export const IMessagePreambleRegistry = new Token<IMessagePreambleRegistry>(
14
+ '@jupyter/chat:ChatPreambleRegistry'
15
+ );
16
+
17
+ /**
18
+ * The props passed to each preamble component.
19
+ */
20
+ export type MessagePreambleProps = {
21
+ model: IChatModel;
22
+ message: IMessageContent;
23
+ };
24
+
25
+ /**
26
+ * The interface of a registry to provide message preamble components.
27
+ * Preamble components render above the message body, after the header.
28
+ */
29
+ export interface IMessagePreambleRegistry {
30
+ /**
31
+ * Add a preamble component to the registry.
32
+ * Components are rendered in the order they are added.
33
+ */
34
+ addComponent(
35
+ component: (props: MessagePreambleProps) => JSX.Element | null
36
+ ): void;
37
+ /**
38
+ * Get all registered preamble components.
39
+ */
40
+ getComponents(): ((props: MessagePreambleProps) => JSX.Element | null)[];
41
+ }
42
+
43
+ /**
44
+ * The default implementation of the message preamble registry.
45
+ */
46
+ export class MessagePreambleRegistry implements IMessagePreambleRegistry {
47
+ /**
48
+ * Add a preamble component to the registry.
49
+ */
50
+ addComponent(
51
+ component: (props: MessagePreambleProps) => JSX.Element | null
52
+ ): void {
53
+ this._components.push(component);
54
+ }
55
+
56
+ /**
57
+ * Get all registered preamble components.
58
+ */
59
+ getComponents(): ((props: MessagePreambleProps) => JSX.Element | null)[] {
60
+ return [...this._components];
61
+ }
62
+
63
+ private _components: ((props: MessagePreambleProps) => JSX.Element | null)[] =
64
+ [];
65
+ }
package/src/types.ts CHANGED
@@ -61,6 +61,20 @@ export interface IConfig {
61
61
  showDeleted?: boolean;
62
62
  }
63
63
 
64
+ /**
65
+ * An empty interface to describe optional metadata attached to a chat message.
66
+ * Extensions can augment this interface to add custom fields:
67
+ *
68
+ * ```ts
69
+ * declare module '@jupyter/chat' {
70
+ * interface IMessageMetadata {
71
+ * myField?: MyType;
72
+ * }
73
+ * }
74
+ * ```
75
+ */
76
+ export interface IMessageMetadata {} /* eslint-disable-line @typescript-eslint/no-empty-object-type */
77
+
64
78
  /**
65
79
  * The chat message description.
66
80
  */
@@ -79,11 +93,9 @@ export type IMessageContent<T = IUser, U = IAttachment> = {
79
93
  deleted?: boolean;
80
94
  edited?: boolean;
81
95
  stacked?: boolean;
96
+ metadata?: IMessageMetadata;
82
97
  };
83
98
 
84
- /**
85
- *
86
- */
87
99
  export interface IMessage extends IMessageContent {
88
100
  /**
89
101
  * Update one or several fields of the message.
package/style/chat.css CHANGED
@@ -3,7 +3,7 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
- .jp-chat-rendered-message {
6
+ .jp-chat-message-container {
7
7
  position: relative;
8
8
  }
9
9
 
@@ -58,8 +58,8 @@
58
58
  .jp-chat-toolbar {
59
59
  visibility: hidden;
60
60
  position: absolute;
61
- right: 2px;
62
- top: 2px;
61
+ right: 0;
62
+ top: 0;
63
63
  font-size: var(--jp-ui-font-size0);
64
64
  color: var(--jp-ui-font-color3);
65
65
  }
@@ -69,7 +69,7 @@
69
69
  color: var(--jp-ui-font-color2);
70
70
  }
71
71
 
72
- .jp-chat-rendered-message:hover .jp-chat-toolbar {
72
+ .jp-chat-message-container:hover .jp-chat-toolbar {
73
73
  visibility: visible;
74
74
  }
75
75