@jupyter/chat 0.21.1 → 0.22.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 (47) hide show
  1. package/lib/__tests__/chat-body-placeholder.spec.d.ts +1 -0
  2. package/lib/__tests__/chat-body-placeholder.spec.js +54 -0
  3. package/lib/__tests__/multichat-panel.spec.d.ts +1 -0
  4. package/lib/__tests__/multichat-panel.spec.js +116 -0
  5. package/lib/components/chat.d.ts +6 -0
  6. package/lib/components/input/buttons/send-button.d.ts +1 -1
  7. package/lib/components/input/buttons/send-button.js +108 -8
  8. package/lib/components/input/chat-input.js +4 -3
  9. package/lib/components/messages/chat-body-placeholder.d.ts +6 -0
  10. package/lib/components/messages/chat-body-placeholder.js +19 -0
  11. package/lib/components/messages/index.d.ts +1 -0
  12. package/lib/components/messages/index.js +1 -0
  13. package/lib/components/messages/messages.js +54 -2
  14. package/lib/components/scroll-container.d.ts +6 -13
  15. package/lib/components/scroll-container.js +11 -22
  16. package/lib/input-model.d.ts +4 -0
  17. package/lib/theme-provider.js +2 -2
  18. package/lib/tokens.d.ts +50 -1
  19. package/lib/tokens.js +12 -0
  20. package/lib/types.d.ts +4 -0
  21. package/lib/widgets/chat-widget.js +5 -1
  22. package/lib/widgets/index.d.ts +1 -0
  23. package/lib/widgets/index.js +1 -0
  24. package/lib/widgets/multichat-panel.d.ts +15 -0
  25. package/lib/widgets/multichat-panel.js +41 -9
  26. package/lib/widgets/placeholder.d.ts +48 -0
  27. package/lib/widgets/placeholder.js +48 -0
  28. package/package.json +3 -3
  29. package/src/__tests__/chat-body-placeholder.spec.ts +69 -0
  30. package/src/__tests__/multichat-panel.spec.ts +135 -0
  31. package/src/components/chat.tsx +6 -0
  32. package/src/components/input/buttons/send-button.tsx +158 -14
  33. package/src/components/input/chat-input.tsx +6 -5
  34. package/src/components/messages/chat-body-placeholder.tsx +24 -0
  35. package/src/components/messages/index.ts +1 -0
  36. package/src/components/messages/messages.tsx +59 -2
  37. package/src/components/scroll-container.tsx +27 -34
  38. package/src/input-model.ts +4 -0
  39. package/src/theme-provider.ts +2 -2
  40. package/src/tokens.ts +61 -1
  41. package/src/types.ts +4 -0
  42. package/src/widgets/chat-widget.tsx +14 -1
  43. package/src/widgets/index.ts +1 -0
  44. package/src/widgets/multichat-panel.tsx +56 -10
  45. package/src/widgets/placeholder.tsx +122 -0
  46. package/style/base.css +1 -0
  47. package/style/placeholder.css +29 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { RenderMimeRegistry } from '@jupyterlab/rendermime';
6
+ import { ChatWidget } from '../widgets/chat-widget';
7
+ import { MockChatModel } from './mocks';
8
+ describe('ChatBodyPlaceholder', () => {
9
+ let model;
10
+ let rmRegistry;
11
+ beforeEach(() => {
12
+ model = new MockChatModel();
13
+ rmRegistry = new RenderMimeRegistry();
14
+ });
15
+ it('should create a widget without chatBodyPlaceholderFactory', () => {
16
+ const widget = new ChatWidget({ model, rmRegistry });
17
+ expect(widget).toBeInstanceOf(ChatWidget);
18
+ widget.dispose();
19
+ });
20
+ it('should create a widget with chatBodyPlaceholderFactory', () => {
21
+ const factory = {
22
+ create: jest.fn().mockReturnValue(null)
23
+ };
24
+ const widget = new ChatWidget({
25
+ model,
26
+ rmRegistry,
27
+ chatBodyPlaceholderFactory: factory
28
+ });
29
+ expect(widget).toBeInstanceOf(ChatWidget);
30
+ widget.dispose();
31
+ });
32
+ it('should call sendMessage when factory onSend is invoked', () => {
33
+ const factory = {
34
+ create: jest.fn().mockReturnValue(null)
35
+ };
36
+ const sendSpy = jest.spyOn(model, 'sendMessage');
37
+ // Simulate what ChatBodyPlaceholder component does:
38
+ factory.create({ onSend: (body) => model.sendMessage({ body }) });
39
+ // Invoke the onSend passed to factory
40
+ const props = factory.create.mock
41
+ .calls[0][0];
42
+ props.onSend('Hello world');
43
+ expect(sendSpy).toHaveBeenCalledWith({ body: 'Hello world' });
44
+ });
45
+ it('should not fail when factory returns null', () => {
46
+ const factory = {
47
+ create: jest.fn().mockReturnValue(null)
48
+ };
49
+ const result = factory.create({
50
+ onSend: (body) => model.sendMessage({ body })
51
+ });
52
+ expect(result).toBeNull();
53
+ });
54
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { RenderMimeRegistry } from '@jupyterlab/rendermime';
6
+ import { Widget } from '@lumino/widgets';
7
+ import { MultiChatPanel } from '../widgets/multichat-panel';
8
+ import { defaultPlaceholder } from '../widgets/placeholder';
9
+ describe('MultiChatPanel', () => {
10
+ let rmRegistry;
11
+ beforeEach(() => {
12
+ rmRegistry = new RenderMimeRegistry();
13
+ });
14
+ describe('placeholderFactory', () => {
15
+ it('should use defaultPlaceholder when no factory is provided', () => {
16
+ const panel = new MultiChatPanel({ rmRegistry });
17
+ const placeholder = Array.from(panel.widgets).find(w => w instanceof defaultPlaceholder);
18
+ expect(placeholder).toBeInstanceOf(defaultPlaceholder);
19
+ panel.dispose();
20
+ });
21
+ it('should call factory.create when a factory is provided', () => {
22
+ const factory = {
23
+ create: jest.fn().mockReturnValue(new Widget())
24
+ };
25
+ const panel = new MultiChatPanel({
26
+ rmRegistry,
27
+ placeholderFactory: factory
28
+ });
29
+ expect(factory.create).toHaveBeenCalledTimes(1);
30
+ panel.dispose();
31
+ });
32
+ it('should add the widget returned by the factory to the panel', () => {
33
+ const customWidget = new Widget();
34
+ const factory = {
35
+ create: jest.fn().mockReturnValue(customWidget)
36
+ };
37
+ const panel = new MultiChatPanel({
38
+ rmRegistry,
39
+ placeholderFactory: factory
40
+ });
41
+ expect(Array.from(panel.widgets)).toContain(customWidget);
42
+ panel.dispose();
43
+ });
44
+ it('should not use defaultPlaceholder when a factory is provided', () => {
45
+ const factory = {
46
+ create: jest.fn().mockReturnValue(new Widget())
47
+ };
48
+ const panel = new MultiChatPanel({
49
+ rmRegistry,
50
+ placeholderFactory: factory
51
+ });
52
+ const placeholder = Array.from(panel.widgets).find(w => w instanceof defaultPlaceholder);
53
+ expect(placeholder).toBeUndefined();
54
+ panel.dispose();
55
+ });
56
+ it('should pass empty chatNames in props when no chats are loaded', () => {
57
+ const factory = {
58
+ create: jest.fn().mockReturnValue(new Widget())
59
+ };
60
+ const panel = new MultiChatPanel({
61
+ rmRegistry,
62
+ placeholderFactory: factory
63
+ });
64
+ const props = factory.create.mock
65
+ .calls[0][0];
66
+ expect(props.chatNames).toEqual({});
67
+ panel.dispose();
68
+ });
69
+ it('should pass undefined onCreate when createModel is not provided', () => {
70
+ const factory = {
71
+ create: jest.fn().mockReturnValue(new Widget())
72
+ };
73
+ const panel = new MultiChatPanel({
74
+ rmRegistry,
75
+ placeholderFactory: factory
76
+ });
77
+ const props = factory.create.mock
78
+ .calls[0][0];
79
+ expect(props.onCreate).toBeUndefined();
80
+ panel.dispose();
81
+ });
82
+ it('should pass onCreate when createModel is provided', () => {
83
+ const factory = {
84
+ create: jest.fn().mockReturnValue(new Widget())
85
+ };
86
+ const createModel = jest.fn().mockResolvedValue({});
87
+ const panel = new MultiChatPanel({
88
+ rmRegistry,
89
+ placeholderFactory: factory,
90
+ createModel
91
+ });
92
+ const props = factory.create.mock
93
+ .calls[0][0];
94
+ expect(props.onCreate).toBeDefined();
95
+ panel.dispose();
96
+ });
97
+ it('should call factory.create again when the placeholder is re-added after closing a chat', async () => {
98
+ const factory = {
99
+ create: jest.fn().mockReturnValue(new Widget())
100
+ };
101
+ const createModel = jest.fn().mockResolvedValue({});
102
+ const panel = new MultiChatPanel({
103
+ rmRegistry,
104
+ placeholderFactory: factory,
105
+ createModel
106
+ });
107
+ // Simulate opening and closing a chat to trigger _addPlaceholder again.
108
+ const { MockChatModel } = await import('./mocks');
109
+ const model = new MockChatModel();
110
+ panel.open({ model, displayName: 'test-chat' });
111
+ panel.disposeLoadedModel('test-chat');
112
+ expect(factory.create).toHaveBeenCalledTimes(2);
113
+ panel.dispose();
114
+ });
115
+ });
116
+ });
@@ -6,6 +6,7 @@ import { IInputToolbarRegistry } from './input';
6
6
  import { IChatModel } from '../model';
7
7
  import { IAttachmentOpenerRegistry, IChatCommandRegistry, IMessageFooterRegistry, IMessagePreambleRegistry } from '../registers';
8
8
  import { ChatArea } from '../types';
9
+ import { IChatBodyPlaceholderFactory } from '../tokens';
9
10
  export declare function ChatBody(props: Chat.IChatProps): JSX.Element;
10
11
  export declare function Chat(props: Chat.IOptions): JSX.Element;
11
12
  /**
@@ -48,6 +49,11 @@ export declare namespace Chat {
48
49
  * The welcome message.
49
50
  */
50
51
  welcomeMessage?: string;
52
+ /**
53
+ * Optional factory to create chat body placeholder shown when the chat has
54
+ * no messages.
55
+ */
56
+ chatBodyPlaceholderFactory?: IChatBodyPlaceholderFactory;
51
57
  /**
52
58
  * The area where the chat is displayed.
53
59
  */
@@ -1,6 +1,6 @@
1
1
  /// <reference types="react" />
2
2
  import { InputToolbarRegistry } from '../toolbar-registry';
3
3
  /**
4
- * The send button.
4
+ * The send button, with optional 'include selection' menu.
5
5
  */
6
6
  export declare function SendButton(props: InputToolbarRegistry.IToolbarItemProps): JSX.Element;
@@ -3,22 +3,42 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
6
- import React, { useEffect, useState } from 'react';
6
+ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
7
+ import { Box, Menu, MenuItem, Typography } from '@mui/material';
8
+ import React, { useCallback, useEffect, useState } from 'react';
7
9
  import { TooltippedIconButton } from '../../mui-extras';
8
10
  import { useTranslator } from '../../../context';
11
+ import { includeSelectionIcon } from '../../../icons';
9
12
  const SEND_BUTTON_CLASS = 'jp-chat-send-button';
13
+ const SEND_INCLUDE_OPENER_CLASS = 'jp-chat-send-include-opener';
14
+ const SEND_INCLUDE_LI_CLASS = 'jp-chat-send-include';
10
15
  /**
11
- * The send button.
16
+ * The send button, with optional 'include selection' menu.
12
17
  */
13
18
  export function SendButton(props) {
19
+ var _a;
14
20
  const { model, chatCommandRegistry, edit } = props;
21
+ const { activeCellManager, selectionWatcher } = model;
22
+ const supportSelection = !!activeCellManager || !!selectionWatcher;
15
23
  const trans = useTranslator();
16
24
  // Don't show this button when in edit mode
17
25
  if (edit) {
18
26
  return React.createElement(React.Fragment, null);
19
27
  }
28
+ const [menuAnchorEl, setMenuAnchorEl] = useState(null);
29
+ const [menuOpen, setMenuOpen] = useState(false);
20
30
  const [disabled, setDisabled] = useState(false);
21
31
  const [tooltip, setTooltip] = useState('');
32
+ const [selectionTooltip, setSelectionTooltip] = useState('');
33
+ const [disableInclude, setDisableInclude] = useState(true);
34
+ const [showSendWithSelection, setShowSendWithSelection] = useState(supportSelection && ((_a = model.config.sendWithSelection) !== null && _a !== void 0 ? _a : true));
35
+ const openMenu = useCallback((el) => {
36
+ setMenuAnchorEl(el);
37
+ setMenuOpen(true);
38
+ }, []);
39
+ const closeMenu = useCallback(() => {
40
+ setMenuOpen(false);
41
+ }, []);
22
42
  useEffect(() => {
23
43
  var _a;
24
44
  const inputChanged = () => {
@@ -29,10 +49,11 @@ export function SendButton(props) {
29
49
  (_a = model.attachmentsChanged) === null || _a === void 0 ? void 0 : _a.connect(inputChanged);
30
50
  inputChanged();
31
51
  const configChanged = (_, config) => {
32
- var _a;
52
+ var _a, _b;
33
53
  setTooltip(((_a = config.sendWithShiftEnter) !== null && _a !== void 0 ? _a : false)
34
54
  ? trans.__('Send message (SHIFT+ENTER)')
35
55
  : trans.__('Send message (ENTER)'));
56
+ setShowSendWithSelection(supportSelection && ((_b = config.sendWithSelection) !== null && _b !== void 0 ? _b : true));
36
57
  };
37
58
  model.configChanged.connect(configChanged);
38
59
  // Initialize the tooltip.
@@ -44,6 +65,26 @@ export function SendButton(props) {
44
65
  (_b = model.configChanged) === null || _b === void 0 ? void 0 : _b.disconnect(configChanged);
45
66
  };
46
67
  }, [model]);
68
+ useEffect(() => {
69
+ const toggleIncludeState = () => {
70
+ setDisableInclude(!((selectionWatcher === null || selectionWatcher === void 0 ? void 0 : selectionWatcher.selection) || (activeCellManager === null || activeCellManager === void 0 ? void 0 : activeCellManager.available)));
71
+ const tip = (selectionWatcher === null || selectionWatcher === void 0 ? void 0 : selectionWatcher.selection)
72
+ ? trans.__('%1 line(s) selected', selectionWatcher.selection.numLines)
73
+ : (activeCellManager === null || activeCellManager === void 0 ? void 0 : activeCellManager.available)
74
+ ? trans.__('Code from 1 active cell')
75
+ : trans.__('No selection or active cell');
76
+ setSelectionTooltip(tip);
77
+ };
78
+ if (showSendWithSelection) {
79
+ selectionWatcher === null || selectionWatcher === void 0 ? void 0 : selectionWatcher.selectionChanged.connect(toggleIncludeState);
80
+ activeCellManager === null || activeCellManager === void 0 ? void 0 : activeCellManager.availabilityChanged.connect(toggleIncludeState);
81
+ toggleIncludeState();
82
+ }
83
+ return () => {
84
+ selectionWatcher === null || selectionWatcher === void 0 ? void 0 : selectionWatcher.selectionChanged.disconnect(toggleIncludeState);
85
+ activeCellManager === null || activeCellManager === void 0 ? void 0 : activeCellManager.availabilityChanged.disconnect(toggleIncludeState);
86
+ };
87
+ }, [activeCellManager, selectionWatcher, showSendWithSelection]);
47
88
  async function send() {
48
89
  // Run all command providers
49
90
  await (chatCommandRegistry === null || chatCommandRegistry === void 0 ? void 0 : chatCommandRegistry.onSubmit(model));
@@ -52,9 +93,68 @@ export function SendButton(props) {
52
93
  model.send(body);
53
94
  model.focus();
54
95
  }
55
- return (React.createElement(TooltippedIconButton, { onClick: send, tooltip: tooltip, disabled: disabled, buttonProps: {
56
- title: tooltip,
57
- className: SEND_BUTTON_CLASS
58
- }, "aria-label": tooltip },
59
- React.createElement(ArrowUpwardIcon, null)));
96
+ async function sendWithSelection() {
97
+ await (chatCommandRegistry === null || chatCommandRegistry === void 0 ? void 0 : chatCommandRegistry.onSubmit(model));
98
+ let source = '';
99
+ let language;
100
+ if (selectionWatcher === null || selectionWatcher === void 0 ? void 0 : selectionWatcher.selection) {
101
+ source = selectionWatcher.selection.text;
102
+ language = selectionWatcher.selection.language;
103
+ }
104
+ else if (activeCellManager === null || activeCellManager === void 0 ? void 0 : activeCellManager.available) {
105
+ const content = activeCellManager.getContent(false);
106
+ source = content.source;
107
+ language = content === null || content === void 0 ? void 0 : content.language;
108
+ }
109
+ let body = model.value;
110
+ if (source) {
111
+ body += `\n\n\`\`\`${language !== null && language !== void 0 ? language : ''}\n${source}\n\`\`\`\n`;
112
+ }
113
+ model.value = '';
114
+ closeMenu();
115
+ model.send(body);
116
+ model.focus();
117
+ }
118
+ return (React.createElement(Box, { sx: { display: 'inline-flex', alignItems: 'center', gap: '1px' } },
119
+ React.createElement(TooltippedIconButton, { onClick: send, tooltip: tooltip, disabled: disabled, sx: {
120
+ borderRadius: showSendWithSelection
121
+ ? 'var(--jp-border-radius) 0 0 var(--jp-border-radius) !important'
122
+ : 'var(--jp-border-radius)'
123
+ }, buttonProps: {
124
+ title: tooltip,
125
+ className: SEND_BUTTON_CLASS
126
+ }, "aria-label": tooltip },
127
+ React.createElement(ArrowUpwardIcon, null)),
128
+ showSendWithSelection && (React.createElement(React.Fragment, null,
129
+ React.createElement(TooltippedIconButton, { onClick: e => openMenu(e.currentTarget), tooltip: trans.__('Send with selection'), disabled: disabled, sx: {
130
+ borderRadius: '0 var(--jp-border-radius) var(--jp-border-radius) 0 !important',
131
+ width: '16px',
132
+ minWidth: '16px'
133
+ }, buttonProps: {
134
+ onKeyDown: e => {
135
+ if (e.key !== 'Enter' && e.key !== ' ') {
136
+ return;
137
+ }
138
+ openMenu(e.currentTarget);
139
+ e.stopPropagation();
140
+ },
141
+ className: SEND_INCLUDE_OPENER_CLASS
142
+ } },
143
+ React.createElement(KeyboardArrowDownIcon, { sx: { fontSize: '12px' } })),
144
+ React.createElement(Menu, { open: menuOpen, onClose: closeMenu, anchorEl: menuAnchorEl, anchorOrigin: { vertical: 'top', horizontal: 'right' }, transformOrigin: { vertical: 'bottom', horizontal: 'right' }, sx: {
145
+ '& .MuiMenuItem-root': {
146
+ display: 'flex',
147
+ alignItems: 'center',
148
+ gap: '8px'
149
+ },
150
+ '& svg': { lineHeight: 0 }
151
+ } },
152
+ React.createElement(MenuItem, { onClick: e => {
153
+ sendWithSelection();
154
+ e.stopPropagation();
155
+ }, disabled: disableInclude, className: SEND_INCLUDE_LI_CLASS },
156
+ React.createElement(includeSelectionIcon.react, null),
157
+ React.createElement(Box, null,
158
+ React.createElement(Typography, { display: "block" }, trans.__('Send message with selection')),
159
+ React.createElement(Typography, { display: "block", sx: { opacity: 0.618 } }, selectionTooltip))))))));
60
160
  }
@@ -120,15 +120,16 @@ export function ChatInput(props) {
120
120
  * called with an invalid `string` instead of a `ChatCommand`.
121
121
  */
122
122
  event.stopPropagation();
123
+ const isSendCombination = (sendWithShiftEnter && event.shiftKey) ||
124
+ (!sendWithShiftEnter && !event.shiftKey);
123
125
  // Do not send empty messages, and avoid adding new line in empty message.
124
- if (!inputExists) {
126
+ if (!inputExists && (!isSendCombination || attachments.length === 0)) {
125
127
  event.stopPropagation();
126
128
  event.preventDefault();
127
129
  return;
128
130
  }
129
131
  // Finally, send the message when all other conditions are met.
130
- if ((sendWithShiftEnter && event.shiftKey) ||
131
- (!sendWithShiftEnter && !event.shiftKey)) {
132
+ if (isSendCombination) {
132
133
  // Run all command providers
133
134
  await (chatCommandRegistry === null || chatCommandRegistry === void 0 ? void 0 : chatCommandRegistry.onSubmit(model));
134
135
  model.send(model.value);
@@ -0,0 +1,6 @@
1
+ /// <reference types="react" />
2
+ /**
3
+ * Component that renders chat body placeholder using the factory from context.
4
+ * Renders nothing if no factory is provided.
5
+ */
6
+ export declare function ChatBodyPlaceholder(): JSX.Element | null;
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { useChatContext } from '../../context';
6
+ /**
7
+ * Component that renders chat body placeholder using the factory from context.
8
+ * Renders nothing if no factory is provided.
9
+ */
10
+ export function ChatBodyPlaceholder() {
11
+ const { model, chatBodyPlaceholderFactory } = useChatContext();
12
+ if (!chatBodyPlaceholderFactory) {
13
+ return null;
14
+ }
15
+ const onSend = (body) => {
16
+ model.sendMessage({ body });
17
+ };
18
+ return chatBodyPlaceholderFactory.create({ onSend });
19
+ }
@@ -7,3 +7,4 @@ export * from './navigation';
7
7
  export * from './preamble';
8
8
  export * from './toolbar';
9
9
  export * from './welcome';
10
+ export * from './chat-body-placeholder';
@@ -11,3 +11,4 @@ export * from './navigation';
11
11
  export * from './preamble';
12
12
  export * from './toolbar';
13
13
  export * from './welcome';
14
+ export * from './chat-body-placeholder';
@@ -12,6 +12,7 @@ import { ChatMessage } from './message';
12
12
  import { MessagePreambleComponent } from './preamble';
13
13
  import { Navigation } from './navigation';
14
14
  import { WelcomeMessage } from './welcome';
15
+ import { ChatBodyPlaceholder } from './chat-body-placeholder';
15
16
  import { ScrollContainer } from '../scroll-container';
16
17
  import { useChatContext } from '../../context';
17
18
  import { Message } from '../../message';
@@ -46,11 +47,24 @@ export function ChatMessages() {
46
47
  }
47
48
  fetchHistory();
48
49
  }, [model]);
50
+ const scrollContainerRef = useRef(null);
51
+ // Starts true so the chat scrolls to bottom on first render.
52
+ const shouldScrollRef = useRef(true);
49
53
  /**
50
- * Effect: listen to chat messages.
54
+ * Effect: listen to chat messages and decide whether to auto-scroll.
55
+ *
56
+ * We check `prevLastIdx` (length - 2) because by the time the signal fires,
57
+ * model.messages already contains the new message. So length - 2 is the
58
+ * message that WAS last before the new one arrived. If that was visible,
59
+ * the user was at the bottom and we should follow.
51
60
  */
52
61
  useEffect(() => {
53
62
  function handleChatEvents() {
63
+ var _a;
64
+ const viewport = (_a = model.messagesInViewport) !== null && _a !== void 0 ? _a : [];
65
+ const prevLastIdx = model.messages.length - 2;
66
+ shouldScrollRef.current =
67
+ prevLastIdx < 0 || viewport.includes(prevLastIdx);
54
68
  setMessages([...model.messages]);
55
69
  }
56
70
  model.messagesUpdated.connect(handleChatEvents);
@@ -58,6 +72,43 @@ export function ChatMessages() {
58
72
  model.messagesUpdated.disconnect(handleChatEvents);
59
73
  };
60
74
  }, [model]);
75
+ /**
76
+ * Effect: observe DOM mutations in the scroll container to keep the viewport
77
+ * pinned to the bottom as message content renders asynchronously.
78
+ *
79
+ * A single scrollTop assignment on state change isn't enough because message
80
+ * content (markdown, code blocks, images) renders after the initial DOM
81
+ * commit. The MutationObserver fires on every subtree change, keeping us
82
+ * pinned as content streams in or expands.
83
+ *
84
+ * The scroll listener lets a user manually scroll to the bottom during
85
+ * streaming to re-engage auto-scroll, or scroll up to disengage it.
86
+ */
87
+ useEffect(() => {
88
+ const el = scrollContainerRef.current;
89
+ if (!el) {
90
+ return;
91
+ }
92
+ const observer = new MutationObserver(() => {
93
+ if (shouldScrollRef.current) {
94
+ el.scrollTop = el.scrollHeight;
95
+ }
96
+ });
97
+ const handleScroll = () => {
98
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
99
+ shouldScrollRef.current = atBottom;
100
+ };
101
+ observer.observe(el, {
102
+ childList: true,
103
+ subtree: true,
104
+ characterData: true
105
+ });
106
+ el.addEventListener('scroll', handleScroll);
107
+ return () => {
108
+ observer.disconnect();
109
+ el.removeEventListener('scroll', handleScroll);
110
+ };
111
+ }, []);
61
112
  /**
62
113
  * Effect: Listen to the config change.
63
114
  */
@@ -134,8 +185,9 @@ export function ChatMessages() {
134
185
  }, [messages, allRendered]);
135
186
  const horizontalPadding = area === 'main' ? 8 : 4;
136
187
  return (React.createElement(React.Fragment, null,
137
- React.createElement(ScrollContainer, { sx: { flexGrow: 1 } },
188
+ React.createElement(ScrollContainer, { ref: scrollContainerRef, sx: { flexGrow: 1 } },
138
189
  welcomeMessage && React.createElement(WelcomeMessage, { content: welcomeMessage }),
190
+ messages.length === 0 && React.createElement(ChatBodyPlaceholder, null),
139
191
  React.createElement(Box, { sx: {
140
192
  paddingLeft: horizontalPadding,
141
193
  paddingRight: horizontalPadding,
@@ -5,19 +5,12 @@ type ScrollContainerProps = {
5
5
  sx?: SxProps<Theme>;
6
6
  };
7
7
  /**
8
- * Component that handles intelligent scrolling.
8
+ * Scrollable container for chat messages. Scroll position is managed
9
+ * explicitly by the parent (ChatMessages) via a forwarded ref.
9
10
  *
10
- * - If viewport is at the bottom of the overflow container, appending new
11
- * children keeps the viewport on the bottom of the overflow container.
12
- *
13
- * - If viewport is in the middle of the overflow container, appending new
14
- * children leaves the viewport unaffected.
15
- *
16
- * Currently only works for Chrome and Firefox due to reliance on
17
- * `overflow-anchor`.
18
- *
19
- * **References**
20
- * - https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/
11
+ * `overflowAnchor: 'none'` disables the browser's built-in scroll anchoring
12
+ * which caused partial viewport shifts on new content in Chrome/Firefox
13
+ * and is unsupported in Safari.
21
14
  */
22
- export declare function ScrollContainer(props: ScrollContainerProps): JSX.Element;
15
+ export declare const ScrollContainer: React.ForwardRefExoticComponent<ScrollContainerProps & React.RefAttributes<HTMLDivElement>>;
23
16
  export {};
@@ -2,32 +2,21 @@
2
2
  * Copyright (c) Jupyter Development Team.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
- import React, { useMemo } from 'react';
5
+ import React, { forwardRef, useMemo } from 'react';
6
6
  import { Box } from '@mui/material';
7
7
  /**
8
- * Component that handles intelligent scrolling.
8
+ * Scrollable container for chat messages. Scroll position is managed
9
+ * explicitly by the parent (ChatMessages) via a forwarded ref.
9
10
  *
10
- * - If viewport is at the bottom of the overflow container, appending new
11
- * children keeps the viewport on the bottom of the overflow container.
12
- *
13
- * - If viewport is in the middle of the overflow container, appending new
14
- * children leaves the viewport unaffected.
15
- *
16
- * Currently only works for Chrome and Firefox due to reliance on
17
- * `overflow-anchor`.
18
- *
19
- * **References**
20
- * - https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom/
11
+ * `overflowAnchor: 'none'` disables the browser's built-in scroll anchoring
12
+ * which caused partial viewport shifts on new content in Chrome/Firefox
13
+ * and is unsupported in Safari.
21
14
  */
22
- export function ScrollContainer(props) {
15
+ export const ScrollContainer = forwardRef(function ScrollContainer(props, ref) {
23
16
  const id = useMemo(() => 'jupyter-chat-scroll-container-' + Date.now().toString(), []);
24
- return (React.createElement(Box, { id: id, sx: {
17
+ return (React.createElement(Box, { ref: ref, id: id, sx: {
25
18
  overflowY: 'scroll',
26
- '& *': {
27
- overflowAnchor: 'none'
28
- },
19
+ overflowAnchor: 'none',
29
20
  ...props.sx
30
- } },
31
- React.createElement(Box, null, props.children),
32
- React.createElement(Box, { sx: { overflowAnchor: 'auto', height: '1px' } })));
33
- }
21
+ } }, props.children));
22
+ });
@@ -333,5 +333,9 @@ export declare namespace InputModel {
333
333
  * Whether to send a message via Shift-Enter instead of Enter.
334
334
  */
335
335
  sendWithShiftEnter?: boolean;
336
+ /**
337
+ * Whether to display the 'send with selection' button.
338
+ */
339
+ sendWithSelection?: boolean;
336
340
  }
337
341
  }
@@ -43,7 +43,7 @@ export async function getJupyterLabTheme() {
43
43
  style: {
44
44
  backgroundColor: `var(--jp-brand-color${light ? '1' : '2'})`,
45
45
  color: 'var(--jp-ui-inverse-font-color1)',
46
- borderRadius: '4px',
46
+ borderRadius: 'var(--jp-border-radius)',
47
47
  boxShadow: 'none',
48
48
  '&:hover': {
49
49
  backgroundColor: `var(--jp-brand-color${light ? '0' : '1'})`,
@@ -100,7 +100,7 @@ export async function getJupyterLabTheme() {
100
100
  style: {
101
101
  backgroundColor: `var(--jp-brand-color${light ? '1' : '2'})`,
102
102
  color: 'var(--jp-ui-inverse-font-color1)',
103
- borderRadius: '4px',
103
+ borderRadius: 'var(--jp-border-radius)',
104
104
  boxShadow: 'none',
105
105
  '&:hover': {
106
106
  backgroundColor: `var(--jp-brand-color${light ? '0' : '1'})`,