@jupyter/chat 0.1.0 → 0.3.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.
Files changed (55) hide show
  1. package/lib/active-cell-manager.d.ts +151 -0
  2. package/lib/active-cell-manager.js +201 -0
  3. package/lib/components/chat-input.d.ts +14 -4
  4. package/lib/components/chat-input.js +118 -10
  5. package/lib/components/chat-messages.d.ts +45 -15
  6. package/lib/components/chat-messages.js +237 -55
  7. package/lib/components/chat.d.ts +21 -6
  8. package/lib/components/chat.js +15 -45
  9. package/lib/components/code-blocks/code-toolbar.d.ts +13 -0
  10. package/lib/components/code-blocks/code-toolbar.js +70 -0
  11. package/lib/components/{copy-button.d.ts → code-blocks/copy-button.d.ts} +1 -0
  12. package/lib/components/code-blocks/copy-button.js +43 -0
  13. package/lib/components/mui-extras/contrasting-tooltip.d.ts +6 -0
  14. package/lib/components/mui-extras/contrasting-tooltip.js +21 -0
  15. package/lib/components/mui-extras/tooltipped-icon-button.d.ts +35 -0
  16. package/lib/components/mui-extras/tooltipped-icon-button.js +36 -0
  17. package/lib/components/rendermime-markdown.d.ts +2 -0
  18. package/lib/components/rendermime-markdown.js +29 -15
  19. package/lib/components/scroll-container.js +1 -19
  20. package/lib/icons.d.ts +2 -0
  21. package/lib/icons.js +10 -0
  22. package/lib/index.d.ts +2 -0
  23. package/lib/index.js +2 -0
  24. package/lib/model.d.ts +98 -14
  25. package/lib/model.js +197 -6
  26. package/lib/registry.d.ts +78 -0
  27. package/lib/registry.js +83 -0
  28. package/lib/types.d.ts +60 -4
  29. package/lib/widgets/chat-sidebar.d.ts +3 -4
  30. package/lib/widgets/chat-sidebar.js +2 -2
  31. package/lib/widgets/chat-widget.d.ts +2 -8
  32. package/lib/widgets/chat-widget.js +6 -6
  33. package/package.json +204 -200
  34. package/src/active-cell-manager.ts +318 -0
  35. package/src/components/chat-input.tsx +196 -50
  36. package/src/components/chat-messages.tsx +357 -95
  37. package/src/components/chat.tsx +43 -69
  38. package/src/components/code-blocks/code-toolbar.tsx +143 -0
  39. package/src/components/code-blocks/copy-button.tsx +68 -0
  40. package/src/components/mui-extras/contrasting-tooltip.tsx +27 -0
  41. package/src/components/mui-extras/tooltipped-icon-button.tsx +84 -0
  42. package/src/components/rendermime-markdown.tsx +44 -20
  43. package/src/components/scroll-container.tsx +1 -25
  44. package/src/icons.ts +12 -0
  45. package/src/index.ts +2 -0
  46. package/src/model.ts +275 -21
  47. package/src/registry.ts +129 -0
  48. package/src/types.ts +62 -4
  49. package/src/widgets/chat-sidebar.tsx +3 -15
  50. package/src/widgets/chat-widget.tsx +8 -21
  51. package/style/chat.css +40 -0
  52. package/style/icons/read.svg +11 -0
  53. package/style/icons/replace-cell.svg +8 -0
  54. package/lib/components/copy-button.js +0 -35
  55. package/src/components/copy-button.tsx +0 -55
@@ -3,55 +3,177 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
+ import { Button } from '@jupyter/react-components';
6
7
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
8
+ import {
9
+ LabIcon,
10
+ caretDownEmptyIcon,
11
+ classes
12
+ } from '@jupyterlab/ui-components';
7
13
  import { Avatar, Box, Typography } from '@mui/material';
8
14
  import type { SxProps, Theme } from '@mui/material';
9
15
  import clsx from 'clsx';
10
- import React, { useState, useEffect } from 'react';
16
+ import React, { useEffect, useState, useRef } from 'react';
11
17
 
12
18
  import { ChatInput } from './chat-input';
13
19
  import { RendermimeMarkdown } from './rendermime-markdown';
20
+ import { ScrollContainer } from './scroll-container';
14
21
  import { IChatModel } from '../model';
15
- import { IChatMessage, IUser } from '../types';
22
+ import { IChatMessage } from '../types';
16
23
 
17
24
  const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
18
25
  const MESSAGE_CLASS = 'jp-chat-message';
26
+ const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked';
19
27
  const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
20
28
  const MESSAGE_TIME_CLASS = 'jp-chat-message-time';
29
+ const NAVIGATION_BUTTON_CLASS = 'jp-chat-navigation';
30
+ const NAVIGATION_UNREAD_CLASS = 'jp-chat-navigation-unread';
31
+ const NAVIGATION_TOP_CLASS = 'jp-chat-navigation-top';
32
+ const NAVIGATION_BOTTOM_CLASS = 'jp-chat-navigation-bottom';
21
33
 
34
+ /**
35
+ * The base components props.
36
+ */
22
37
  type BaseMessageProps = {
23
38
  rmRegistry: IRenderMimeRegistry;
24
39
  model: IChatModel;
25
40
  };
26
41
 
27
- type ChatMessageProps = BaseMessageProps & {
28
- message: IChatMessage;
29
- };
42
+ /**
43
+ * The messages list component.
44
+ */
45
+ export function ChatMessages(props: BaseMessageProps): JSX.Element {
46
+ const { model } = props;
47
+ const [messages, setMessages] = useState<IChatMessage[]>(model.messages);
48
+ const refMsgBox = useRef<HTMLDivElement>(null);
49
+ const inViewport = useRef<number[]>([]);
30
50
 
31
- type ChatMessagesProps = BaseMessageProps & {
32
- messages: IChatMessage[];
33
- };
51
+ // The intersection observer that listen to all the message visibility.
52
+ const observerRef = useRef<IntersectionObserver>(
53
+ new IntersectionObserver(viewportChange)
54
+ );
55
+
56
+ /**
57
+ * Effect: fetch history and config on initial render
58
+ */
59
+ useEffect(() => {
60
+ async function fetchHistory() {
61
+ if (!model.getHistory) {
62
+ return;
63
+ }
64
+ model
65
+ .getHistory()
66
+ .then(history => setMessages(history.messages))
67
+ .catch(e => console.error(e));
68
+ }
69
+
70
+ fetchHistory();
71
+ }, [model]);
72
+
73
+ /**
74
+ * Effect: listen to chat messages.
75
+ */
76
+ useEffect(() => {
77
+ function handleChatEvents() {
78
+ setMessages([...model.messages]);
79
+ }
80
+
81
+ model.messagesUpdated.connect(handleChatEvents);
82
+ return function cleanup() {
83
+ model.messagesUpdated.disconnect(handleChatEvents);
84
+ };
85
+ }, [model]);
86
+
87
+ /**
88
+ * Function called when a message enter or leave the viewport.
89
+ */
90
+ function viewportChange(entries: IntersectionObserverEntry[]) {
91
+ const unread = [...model.unreadMessages];
92
+ let unreadModified = false;
93
+ entries.forEach(entry => {
94
+ const index = parseInt(entry.target.getAttribute('data-index') ?? '');
95
+ if (!isNaN(index)) {
96
+ if (unread.length) {
97
+ const unreadIdx = unread.indexOf(index);
98
+ if (unreadIdx !== -1 && entry.isIntersecting) {
99
+ unread.splice(unreadIdx, 1);
100
+ unreadModified = true;
101
+ }
102
+ }
103
+ const viewportIdx = inViewport.current.indexOf(index);
104
+ if (!entry.isIntersecting && viewportIdx !== -1) {
105
+ inViewport.current.splice(viewportIdx, 1);
106
+ } else if (entry.isIntersecting && viewportIdx === -1) {
107
+ inViewport.current.push(index);
108
+ }
109
+ }
110
+ });
111
+
112
+ props.model.messagesInViewport = inViewport.current;
113
+ if (unreadModified) {
114
+ props.model.unreadMessages = unread;
115
+ }
34
116
 
35
- export type ChatMessageHeaderProps = IUser & {
36
- timestamp: number;
37
- rawTime?: boolean;
38
- deleted?: boolean;
39
- edited?: boolean;
117
+ return () => {
118
+ observerRef.current?.disconnect();
119
+ };
120
+ }
121
+
122
+ return (
123
+ <>
124
+ <ScrollContainer sx={{ flexGrow: 1 }}>
125
+ <Box ref={refMsgBox} className={clsx(MESSAGES_BOX_CLASS)}>
126
+ {messages.map((message, i) => {
127
+ return (
128
+ // extra div needed to ensure each bubble is on a new line
129
+ <Box
130
+ key={i}
131
+ className={clsx(
132
+ MESSAGE_CLASS,
133
+ message.stacked ? MESSAGE_STACKED_CLASS : ''
134
+ )}
135
+ >
136
+ <ChatMessageHeader message={message} />
137
+ <ChatMessage
138
+ {...props}
139
+ message={message}
140
+ observer={observerRef.current}
141
+ index={i}
142
+ />
143
+ </Box>
144
+ );
145
+ })}
146
+ </Box>
147
+ </ScrollContainer>
148
+ <Navigation {...props} refMsgBox={refMsgBox} />
149
+ </>
150
+ );
151
+ }
152
+
153
+ /**
154
+ * The message header props.
155
+ */
156
+ type ChatMessageHeaderProps = {
157
+ message: IChatMessage;
40
158
  sx?: SxProps<Theme>;
41
159
  };
42
160
 
161
+ /**
162
+ * The message header component.
163
+ */
43
164
  export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
44
165
  const [datetime, setDatetime] = useState<Record<number, string>>({});
45
166
  const sharedStyles: SxProps<Theme> = {
46
167
  height: '24px',
47
168
  width: '24px'
48
169
  };
49
-
170
+ const message = props.message;
171
+ const sender = message.sender;
50
172
  /**
51
173
  * Effect: update cached datetime strings upon receiving a new message.
52
174
  */
53
175
  useEffect(() => {
54
- if (!datetime[props.timestamp]) {
176
+ if (!datetime[message.time]) {
55
177
  const newDatetime: Record<number, string> = {};
56
178
  let datetime: string;
57
179
  const currentDate = new Date();
@@ -60,7 +182,7 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
60
182
  date.getMonth() === currentDate.getMonth() &&
61
183
  date.getDate() === currentDate.getDate();
62
184
 
63
- const msgDate = new Date(props.timestamp * 1000); // Convert message time to milliseconds
185
+ const msgDate = new Date(message.time * 1000); // Convert message time to milliseconds
64
186
 
65
187
  // Display only the time if the day of the message is the current one.
66
188
  if (sameDay(msgDate)) {
@@ -79,21 +201,21 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
79
201
  minute: '2-digit'
80
202
  });
81
203
  }
82
- newDatetime[props.timestamp] = datetime;
204
+ newDatetime[message.time] = datetime;
83
205
  setDatetime(newDatetime);
84
206
  }
85
207
  });
86
208
 
87
- const bgcolor = props.color;
88
- const avatar = props.avatar_url ? (
209
+ const bgcolor = sender.color;
210
+ const avatar = message.stacked ? null : sender.avatar_url ? (
89
211
  <Avatar
90
212
  sx={{
91
213
  ...sharedStyles,
92
214
  ...(bgcolor && { bgcolor })
93
215
  }}
94
- src={props.avatar_url}
216
+ src={sender.avatar_url}
95
217
  ></Avatar>
96
- ) : props.initials ? (
218
+ ) : sender.initials ? (
97
219
  <Avatar
98
220
  sx={{
99
221
  ...sharedStyles,
@@ -106,13 +228,13 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
106
228
  color: 'var(--jp-ui-inverse-font-color1)'
107
229
  }}
108
230
  >
109
- {props.initials}
231
+ {sender.initials}
110
232
  </Typography>
111
233
  </Avatar>
112
234
  ) : null;
113
235
 
114
236
  const name =
115
- props.display_name ?? props.name ?? (props.username || 'User undefined');
237
+ sender.display_name ?? sender.name ?? (sender.username || 'User undefined');
116
238
 
117
239
  return (
118
240
  <Box
@@ -123,6 +245,7 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
123
245
  '& > :not(:last-child)': {
124
246
  marginRight: 3
125
247
  },
248
+ marginBottom: message.stacked ? '0px' : '12px',
126
249
  ...props.sx
127
250
  }}
128
251
  >
@@ -137,20 +260,25 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
137
260
  }}
138
261
  >
139
262
  <Box sx={{ display: 'flex', alignItems: 'center' }}>
140
- <Typography
141
- sx={{ fontWeight: 700, color: 'var(--jp-ui-font-color1)' }}
142
- >
143
- {name}
144
- </Typography>
145
- {(props.deleted || props.edited) && (
263
+ {!message.stacked && (
264
+ <Typography
265
+ sx={{
266
+ fontWeight: 700,
267
+ color: 'var(--jp-ui-font-color1)',
268
+ paddingRight: '0.5em'
269
+ }}
270
+ >
271
+ {name}
272
+ </Typography>
273
+ )}
274
+ {(message.deleted || message.edited) && (
146
275
  <Typography
147
276
  sx={{
148
277
  fontStyle: 'italic',
149
- fontSize: 'var(--jp-content-font-size0)',
150
- paddingLeft: '0.5em'
278
+ fontSize: 'var(--jp-content-font-size0)'
151
279
  }}
152
280
  >
153
- {props.deleted ? '(message deleted)' : '(edited)'}
281
+ {message.deleted ? '(message deleted)' : '(edited)'}
154
282
  </Typography>
155
283
  )}
156
284
  </Box>
@@ -161,9 +289,9 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
161
289
  color: 'var(--jp-ui-font-color2)',
162
290
  fontWeight: 300
163
291
  }}
164
- title={props.rawTime ? 'Unverified time' : ''}
292
+ title={message.raw_time ? 'Unverified time' : ''}
165
293
  >
166
- {`${datetime[props.timestamp]}${props.rawTime ? '*' : ''}`}
294
+ {`${datetime[message.time]}${message.raw_time ? '*' : ''}`}
167
295
  </Typography>
168
296
  </Box>
169
297
  </Box>
@@ -171,74 +299,70 @@ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
171
299
  }
172
300
 
173
301
  /**
174
- * The messages list UI.
302
+ * The message component props.
175
303
  */
176
- export function ChatMessages(props: ChatMessagesProps): JSX.Element {
177
- return (
178
- <Box
179
- sx={{
180
- '& > :not(:last-child)': {
181
- borderBottom: '1px solid var(--jp-border-color2)'
182
- }
183
- }}
184
- className={clsx(MESSAGES_BOX_CLASS)}
185
- >
186
- {props.messages.map((message, i) => {
187
- let sender: IUser;
188
- if (typeof message.sender === 'string') {
189
- sender = { username: message.sender };
190
- } else {
191
- sender = message.sender;
192
- }
193
- return (
194
- // extra div needed to ensure each bubble is on a new line
195
- <Box
196
- key={i}
197
- sx={{ padding: '1em 1em 0 1em' }}
198
- className={clsx(MESSAGE_CLASS)}
199
- >
200
- <ChatMessageHeader
201
- {...sender}
202
- timestamp={message.time}
203
- rawTime={message.raw_time}
204
- deleted={message.deleted}
205
- edited={message.edited}
206
- sx={{ marginBottom: 3 }}
207
- />
208
- <ChatMessage {...props} message={message} />
209
- </Box>
210
- );
211
- })}
212
- </Box>
213
- );
214
- }
304
+ type ChatMessageProps = BaseMessageProps & {
305
+ /**
306
+ * The message to display.
307
+ */
308
+ message: IChatMessage;
309
+ /**
310
+ * The index of the message in the list.
311
+ */
312
+ index: number;
313
+ /**
314
+ * The intersection observer for all the messages.
315
+ */
316
+ observer: IntersectionObserver | null;
317
+ };
215
318
 
216
319
  /**
217
- * the message UI.
320
+ * The message component body.
218
321
  */
219
322
  export function ChatMessage(props: ChatMessageProps): JSX.Element {
220
323
  const { message, model, rmRegistry } = props;
221
- let canEdit = false;
222
- let canDelete = false;
223
- if (model.user !== undefined && !message.deleted) {
224
- const username =
225
- typeof message.sender === 'string'
226
- ? message.sender
227
- : message.sender.username;
228
-
229
- if (model.user.username === username && model.updateMessage !== undefined) {
230
- canEdit = true;
324
+ const elementRef = useRef<HTMLDivElement>(null);
325
+ const [edit, setEdit] = useState<boolean>(false);
326
+ const [deleted, setDeleted] = useState<boolean>(false);
327
+ const [canEdit, setCanEdit] = useState<boolean>(false);
328
+ const [canDelete, setCanDelete] = useState<boolean>(false);
329
+
330
+ // Add the current message to the observer, to actualize viewport and unread messages.
331
+ useEffect(() => {
332
+ if (elementRef.current === null) {
333
+ return;
231
334
  }
232
- if (model.user.username === username && model.deleteMessage !== undefined) {
233
- canDelete = true;
335
+
336
+ // If the observer is defined, let's observe the message.
337
+ props.observer?.observe(elementRef.current);
338
+
339
+ return () => {
340
+ if (elementRef.current !== null) {
341
+ props.observer?.unobserve(elementRef.current);
342
+ }
343
+ };
344
+ }, [model]);
345
+
346
+ // Look if the message can be deleted or edited.
347
+ useEffect(() => {
348
+ setDeleted(message.deleted ?? false);
349
+ if (model.user !== undefined && !message.deleted) {
350
+ if (model.user.username === message.sender.username) {
351
+ setCanEdit(model.updateMessage !== undefined);
352
+ setCanDelete(model.deleteMessage !== undefined);
353
+ }
354
+ } else {
355
+ setCanEdit(false);
356
+ setCanDelete(false);
234
357
  }
235
- }
236
- const [edit, setEdit] = useState<boolean>(false);
358
+ }, [model, message]);
237
359
 
360
+ // Cancel the current edition of the message.
238
361
  const cancelEdition = (): void => {
239
362
  setEdit(false);
240
363
  };
241
364
 
365
+ // Update the content of the message.
242
366
  const updateMessage = (id: string, input: string): void => {
243
367
  if (!canEdit) {
244
368
  return;
@@ -250,30 +374,31 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element {
250
374
  setEdit(false);
251
375
  };
252
376
 
377
+ // Delete the message.
253
378
  const deleteMessage = (id: string): void => {
254
379
  if (!canDelete) {
255
380
  return;
256
381
  }
257
- // Delete the message
258
382
  model.deleteMessage!(id);
259
383
  };
260
384
 
261
- // Empty if the message has been deleted
262
- return message.deleted ? (
263
- <></>
385
+ // Empty if the message has been deleted.
386
+ return deleted ? (
387
+ <div ref={elementRef} data-index={props.index}></div>
264
388
  ) : (
265
- <div>
389
+ <div ref={elementRef} data-index={props.index}>
266
390
  {edit && canEdit ? (
267
391
  <ChatInput
268
392
  value={message.body}
269
393
  onSend={(input: string) => updateMessage(message.id, input)}
270
394
  onCancel={() => cancelEdition()}
271
- sendWithShiftEnter={model.config.sendWithShiftEnter ?? false}
395
+ model={model}
272
396
  />
273
397
  ) : (
274
398
  <RendermimeMarkdown
275
399
  rmRegistry={rmRegistry}
276
400
  markdownStr={message.body}
401
+ model={model}
277
402
  edit={canEdit ? () => setEdit(true) : undefined}
278
403
  delete={canDelete ? () => deleteMessage(message.id) : undefined}
279
404
  />
@@ -281,3 +406,140 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element {
281
406
  </div>
282
407
  );
283
408
  }
409
+
410
+ /**
411
+ * The navigation component props.
412
+ */
413
+ type NavigationProps = BaseMessageProps & {
414
+ /**
415
+ * The reference to the messages container.
416
+ */
417
+ refMsgBox: React.RefObject<HTMLDivElement>;
418
+ };
419
+
420
+ /**
421
+ * The navigation component, to navigate to unread messages.
422
+ */
423
+ export function Navigation(props: NavigationProps): JSX.Element {
424
+ const { model } = props;
425
+ const [lastInViewport, setLastInViewport] = useState<boolean>(true);
426
+ const [unreadBefore, setUnreadBefore] = useState<number | null>(null);
427
+ const [unreadAfter, setUnreadAfter] = useState<number | null>(null);
428
+
429
+ const gotoMessage = (msgIdx: number) => {
430
+ props.refMsgBox.current?.children.item(msgIdx)?.scrollIntoView();
431
+ };
432
+
433
+ // Listen for change in unread messages, and find the first unread message before or
434
+ // after the current viewport, to display navigation buttons.
435
+ useEffect(() => {
436
+ const unreadChanged = (model: IChatModel, unreadIndexes: number[]) => {
437
+ const viewport = model.messagesInViewport;
438
+ if (!viewport) {
439
+ return;
440
+ }
441
+
442
+ // Initialize the next values with the current values if there still relevant.
443
+ let before =
444
+ unreadBefore !== null &&
445
+ unreadIndexes.includes(unreadBefore) &&
446
+ unreadBefore < Math.min(...viewport)
447
+ ? unreadBefore
448
+ : null;
449
+
450
+ let after =
451
+ unreadAfter !== null &&
452
+ unreadIndexes.includes(unreadAfter) &&
453
+ unreadAfter > Math.max(...viewport)
454
+ ? unreadAfter
455
+ : null;
456
+
457
+ unreadIndexes.forEach(unread => {
458
+ if (viewport?.includes(unread)) {
459
+ return;
460
+ }
461
+ if (unread < (before ?? Math.min(...viewport))) {
462
+ before = unread;
463
+ } else if (
464
+ unread > Math.max(...viewport) &&
465
+ unread < (after ?? model.messages.length)
466
+ ) {
467
+ after = unread;
468
+ }
469
+ });
470
+
471
+ setUnreadBefore(before);
472
+ setUnreadAfter(after);
473
+ };
474
+
475
+ model.unreadChanged?.connect(unreadChanged);
476
+
477
+ unreadChanged(model, model.unreadMessages);
478
+
479
+ // Move to first the unread message or to last message on first rendering.
480
+ if (model.unreadMessages.length) {
481
+ gotoMessage(Math.min(...model.unreadMessages));
482
+ } else {
483
+ gotoMessage(model.messages.length - 1);
484
+ }
485
+
486
+ return () => {
487
+ model.unreadChanged?.disconnect(unreadChanged);
488
+ };
489
+ }, [model]);
490
+
491
+ // Listen for change in the viewport, to add a navigation button if the last is not
492
+ // in viewport.
493
+ useEffect(() => {
494
+ const viewportChanged = (model: IChatModel, viewport: number[]) => {
495
+ setLastInViewport(viewport.includes(model.messages.length - 1));
496
+ };
497
+
498
+ model.viewportChanged?.connect(viewportChanged);
499
+
500
+ viewportChanged(model, model.messagesInViewport ?? []);
501
+
502
+ return () => {
503
+ model.viewportChanged?.disconnect(viewportChanged);
504
+ };
505
+ }, [model]);
506
+
507
+ return (
508
+ <>
509
+ {unreadBefore !== null && (
510
+ <Button
511
+ className={`${NAVIGATION_BUTTON_CLASS} ${NAVIGATION_UNREAD_CLASS} ${NAVIGATION_TOP_CLASS}`}
512
+ onClick={() => gotoMessage!(unreadBefore)}
513
+ title={'Go to unread messages'}
514
+ >
515
+ <LabIcon.resolveReact
516
+ display={'flex'}
517
+ icon={caretDownEmptyIcon}
518
+ iconClass={classes('jp-Icon')}
519
+ />
520
+ </Button>
521
+ )}
522
+ {(unreadAfter !== null || !lastInViewport) && (
523
+ <Button
524
+ className={`${NAVIGATION_BUTTON_CLASS} ${unreadAfter !== null ? NAVIGATION_UNREAD_CLASS : ''} ${NAVIGATION_BOTTOM_CLASS}`}
525
+ onClick={() =>
526
+ gotoMessage!(
527
+ unreadAfter !== null ? unreadAfter : model.messages.length - 1
528
+ )
529
+ }
530
+ title={
531
+ unreadAfter !== null
532
+ ? 'Go to unread messages'
533
+ : 'Go to last message'
534
+ }
535
+ >
536
+ <LabIcon.resolveReact
537
+ display={'flex'}
538
+ icon={caretDownEmptyIcon}
539
+ iconClass={classes('jp-Icon')}
540
+ />
541
+ </Button>
542
+ )}
543
+ </>
544
+ );
545
+ }