@jupyter/chat 0.21.1 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/__tests__/chat-body-placeholder.spec.d.ts +1 -0
- package/lib/__tests__/chat-body-placeholder.spec.js +54 -0
- package/lib/__tests__/multichat-panel.spec.d.ts +1 -0
- package/lib/__tests__/multichat-panel.spec.js +116 -0
- package/lib/components/chat.d.ts +6 -0
- package/lib/components/input/buttons/send-button.d.ts +1 -1
- package/lib/components/input/buttons/send-button.js +108 -8
- package/lib/components/input/chat-input.js +4 -3
- package/lib/components/messages/chat-body-placeholder.d.ts +6 -0
- package/lib/components/messages/chat-body-placeholder.js +19 -0
- package/lib/components/messages/index.d.ts +1 -0
- package/lib/components/messages/index.js +1 -0
- package/lib/components/messages/messages.js +54 -2
- package/lib/components/scroll-container.d.ts +6 -13
- package/lib/components/scroll-container.js +11 -22
- package/lib/input-model.d.ts +4 -0
- package/lib/theme-provider.js +2 -2
- package/lib/tokens.d.ts +50 -1
- package/lib/tokens.js +12 -0
- package/lib/types.d.ts +4 -0
- package/lib/utils.js +2 -2
- package/lib/widgets/chat-widget.js +5 -1
- package/lib/widgets/index.d.ts +1 -0
- package/lib/widgets/index.js +1 -0
- package/lib/widgets/multichat-panel.d.ts +15 -0
- package/lib/widgets/multichat-panel.js +41 -9
- package/lib/widgets/placeholder.d.ts +48 -0
- package/lib/widgets/placeholder.js +48 -0
- package/package.json +3 -3
- package/src/__tests__/chat-body-placeholder.spec.ts +69 -0
- package/src/__tests__/multichat-panel.spec.ts +135 -0
- package/src/components/chat.tsx +6 -0
- package/src/components/input/buttons/send-button.tsx +158 -14
- package/src/components/input/chat-input.tsx +6 -5
- package/src/components/messages/chat-body-placeholder.tsx +24 -0
- package/src/components/messages/index.ts +1 -0
- package/src/components/messages/messages.tsx +59 -2
- package/src/components/scroll-container.tsx +27 -34
- package/src/input-model.ts +4 -0
- package/src/theme-provider.ts +2 -2
- package/src/tokens.ts +61 -1
- package/src/types.ts +4 -0
- package/src/utils.ts +2 -2
- package/src/widgets/chat-widget.tsx +14 -1
- package/src/widgets/index.ts +1 -0
- package/src/widgets/multichat-panel.tsx +56 -10
- package/src/widgets/placeholder.tsx +122 -0
- package/style/base.css +1 -0
- 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
|
+
});
|
package/lib/components/chat.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 (
|
|
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,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
|
+
}
|
|
@@ -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
|
-
*
|
|
8
|
+
* Scrollable container for chat messages. Scroll position is managed
|
|
9
|
+
* explicitly by the parent (ChatMessages) via a forwarded ref.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
*
|
|
8
|
+
* Scrollable container for chat messages. Scroll position is managed
|
|
9
|
+
* explicitly by the parent (ChatMessages) via a forwarded ref.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
32
|
-
React.createElement(Box, { sx: { overflowAnchor: 'auto', height: '1px' } })));
|
|
33
|
-
}
|
|
21
|
+
} }, props.children));
|
|
22
|
+
});
|
package/lib/input-model.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/theme-provider.js
CHANGED
|
@@ -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: '
|
|
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: '
|
|
103
|
+
borderRadius: 'var(--jp-border-radius)',
|
|
104
104
|
boxShadow: 'none',
|
|
105
105
|
'&:hover': {
|
|
106
106
|
backgroundColor: `var(--jp-brand-color${light ? '0' : '1'})`,
|