@jupyter/chat 0.1.0 → 0.2.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.
@@ -3,31 +3,103 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
- import React, { useState } from 'react';
6
+ import React, { useEffect, useRef, useState } from 'react';
7
7
 
8
8
  import {
9
+ Autocomplete,
9
10
  Box,
11
+ IconButton,
12
+ InputAdornment,
10
13
  SxProps,
11
14
  TextField,
12
- Theme,
13
- IconButton,
14
- InputAdornment
15
+ Theme
15
16
  } from '@mui/material';
16
17
  import { Send, Cancel } from '@mui/icons-material';
17
18
  import clsx from 'clsx';
19
+ import { AutocompleteCommand, IAutocompletionCommandsProps } from '../types';
20
+ import { IAutocompletionRegistry } from '../registry';
18
21
 
19
22
  const INPUT_BOX_CLASS = 'jp-chat-input-container';
20
23
  const SEND_BUTTON_CLASS = 'jp-chat-send-button';
21
24
  const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button';
22
25
 
23
26
  export function ChatInput(props: ChatInput.IProps): JSX.Element {
24
- const [input, setInput] = useState(props.value || '');
27
+ const { autocompletionName, autocompletionRegistry, sendWithShiftEnter } =
28
+ props;
29
+ const autocompletion = useRef<IAutocompletionCommandsProps>();
30
+ const [input, setInput] = useState<string>(props.value || '');
31
+
32
+ // The autocomplete commands options.
33
+ const [commandOptions, setCommandOptions] = useState<AutocompleteCommand[]>(
34
+ []
35
+ );
36
+ // whether any option is highlighted in the slash command autocomplete
37
+ const [highlighted, setHighlighted] = useState<boolean>(false);
38
+ // controls whether the slash command autocomplete is open
39
+ const [open, setOpen] = useState<boolean>(false);
40
+
41
+ /**
42
+ * Effect: fetch the list of available autocomplete commands.
43
+ */
44
+ useEffect(() => {
45
+ if (autocompletionRegistry === undefined) {
46
+ return;
47
+ }
48
+ autocompletion.current = autocompletionName
49
+ ? autocompletionRegistry.get(autocompletionName)
50
+ : autocompletionRegistry.getDefaultCompletion();
51
+
52
+ if (autocompletion.current === undefined) {
53
+ return;
54
+ }
55
+
56
+ if (Array.isArray(autocompletion.current.commands)) {
57
+ setCommandOptions(autocompletion.current.commands);
58
+ } else if (typeof autocompletion.current.commands === 'function') {
59
+ autocompletion.current
60
+ .commands()
61
+ .then((commands: AutocompleteCommand[]) => {
62
+ setCommandOptions(commands);
63
+ });
64
+ }
65
+ }, []);
66
+
67
+ /**
68
+ * Effect: Open the autocomplete when the user types the 'opener' string into an
69
+ * empty chat input. Close the autocomplete and reset the last selected value when
70
+ * the user clears the chat input.
71
+ */
72
+ useEffect(() => {
73
+ if (!autocompletion.current?.opener) {
74
+ return;
75
+ }
76
+
77
+ if (input === autocompletion.current?.opener) {
78
+ setOpen(true);
79
+ return;
80
+ }
81
+
82
+ if (input === '') {
83
+ setOpen(false);
84
+ return;
85
+ }
86
+ }, [input]);
25
87
 
26
88
  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
89
+ if (event.key !== 'Enter') {
90
+ return;
91
+ }
92
+
93
+ // do not send the message if the user was selecting a suggested command from the
94
+ // Autocomplete component.
95
+ if (highlighted) {
96
+ return;
97
+ }
98
+
27
99
  if (
28
100
  event.key === 'Enter' &&
29
- ((props.sendWithShiftEnter && event.shiftKey) ||
30
- (!props.sendWithShiftEnter && !event.shiftKey))
101
+ ((sendWithShiftEnter && event.shiftKey) ||
102
+ (!sendWithShiftEnter && !event.shiftKey))
31
103
  ) {
32
104
  onSend();
33
105
  event.stopPropagation();
@@ -64,49 +136,106 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
64
136
 
65
137
  return (
66
138
  <Box sx={props.sx} className={clsx(INPUT_BOX_CLASS)}>
67
- <Box sx={{ display: 'flex' }}>
68
- <TextField
69
- value={input}
70
- onChange={e => setInput(e.target.value)}
71
- fullWidth
72
- variant="outlined"
73
- multiline
74
- onKeyDown={handleKeyDown}
75
- placeholder="Start chatting"
76
- InputProps={{
77
- endAdornment: (
78
- <InputAdornment position="end">
79
- {props.onCancel && (
139
+ <Autocomplete
140
+ options={commandOptions}
141
+ value={props.value}
142
+ open={open}
143
+ autoHighlight
144
+ freeSolo
145
+ // ensure the autocomplete popup always renders on top
146
+ componentsProps={{
147
+ popper: {
148
+ placement: 'top'
149
+ },
150
+ paper: {
151
+ sx: {
152
+ border: '1px solid lightgray'
153
+ }
154
+ }
155
+ }}
156
+ ListboxProps={{
157
+ sx: {
158
+ '& .MuiAutocomplete-option': {
159
+ padding: 2
160
+ }
161
+ }
162
+ }}
163
+ renderInput={params => (
164
+ <TextField
165
+ {...params}
166
+ fullWidth
167
+ variant="outlined"
168
+ multiline
169
+ onKeyDown={handleKeyDown}
170
+ placeholder="Start chatting"
171
+ InputProps={{
172
+ ...params.InputProps,
173
+ endAdornment: (
174
+ <InputAdornment position="end">
175
+ {props.onCancel && (
176
+ <IconButton
177
+ size="small"
178
+ color="primary"
179
+ onClick={onCancel}
180
+ title={'Cancel edition'}
181
+ className={clsx(CANCEL_BUTTON_CLASS)}
182
+ >
183
+ <Cancel />
184
+ </IconButton>
185
+ )}
80
186
  <IconButton
81
187
  size="small"
82
188
  color="primary"
83
- onClick={onCancel}
84
- disabled={!input.trim().length}
85
- title={'Cancel edition'}
86
- className={clsx(CANCEL_BUTTON_CLASS)}
189
+ onClick={onSend}
190
+ disabled={
191
+ props.onCancel
192
+ ? input === props.value
193
+ : !input.trim().length
194
+ }
195
+ title={`Send message ${sendWithShiftEnter ? '(SHIFT+ENTER)' : '(ENTER)'}`}
196
+ className={clsx(SEND_BUTTON_CLASS)}
87
197
  >
88
- <Cancel />
198
+ <Send />
89
199
  </IconButton>
90
- )}
91
- <IconButton
92
- size="small"
93
- color="primary"
94
- onClick={onSend}
95
- disabled={!input.trim().length}
96
- title={`Send message ${props.sendWithShiftEnter ? '(SHIFT+ENTER)' : '(ENTER)'}`}
97
- className={clsx(SEND_BUTTON_CLASS)}
98
- >
99
- <Send />
100
- </IconButton>
101
- </InputAdornment>
102
- )
103
- }}
104
- FormHelperTextProps={{
105
- sx: { marginLeft: 'auto', marginRight: 0 }
106
- }}
107
- helperText={input.length > 2 ? helperText : ' '}
108
- />
109
- </Box>
200
+ </InputAdornment>
201
+ )
202
+ }}
203
+ FormHelperTextProps={{
204
+ sx: { marginLeft: 'auto', marginRight: 0 }
205
+ }}
206
+ helperText={input.length > 2 ? helperText : ' '}
207
+ />
208
+ )}
209
+ {...autocompletion.current?.props}
210
+ inputValue={input}
211
+ onInputChange={(_, newValue: string) => {
212
+ setInput(newValue);
213
+ }}
214
+ onHighlightChange={
215
+ /**
216
+ * On highlight change: set `highlighted` to whether an option is
217
+ * highlighted by the user.
218
+ *
219
+ * This isn't called when an option is selected for some reason, so we
220
+ * need to call `setHighlighted(false)` in `onClose()`.
221
+ */
222
+ (_, highlightedOption) => {
223
+ setHighlighted(!!highlightedOption);
224
+ }
225
+ }
226
+ onClose={
227
+ /**
228
+ * On close: set `highlighted` to `false` and close the popup by
229
+ * setting `open` to `false`.
230
+ */
231
+ () => {
232
+ setHighlighted(false);
233
+ setOpen(false);
234
+ }
235
+ }
236
+ // hide default extra right padding in the text field
237
+ disableClearable
238
+ />
110
239
  </Box>
111
240
  );
112
241
  }
@@ -139,5 +268,13 @@ export namespace ChatInput {
139
268
  * Custom mui/material styles.
140
269
  */
141
270
  sx?: SxProps<Theme>;
271
+ /**
272
+ * Autocompletion properties.
273
+ */
274
+ autocompletionRegistry?: IAutocompletionRegistry;
275
+ /**
276
+ * Autocompletion name.
277
+ */
278
+ autocompletionName?: string;
142
279
  }
143
280
  }