@jupyter/chat 0.3.1 → 0.5.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 (41) hide show
  1. package/lib/active-cell-manager.d.ts +3 -0
  2. package/lib/components/chat-input.d.ts +4 -0
  3. package/lib/components/chat-input.js +50 -18
  4. package/lib/components/chat-messages.d.ts +31 -1
  5. package/lib/components/chat-messages.js +55 -19
  6. package/lib/components/chat.js +1 -1
  7. package/lib/components/code-blocks/code-toolbar.js +50 -16
  8. package/lib/components/input/cancel-button.d.ts +12 -0
  9. package/lib/components/input/cancel-button.js +27 -0
  10. package/lib/components/input/send-button.d.ts +18 -0
  11. package/lib/components/input/send-button.js +143 -0
  12. package/lib/components/mui-extras/tooltipped-button.d.ts +41 -0
  13. package/lib/components/mui-extras/tooltipped-button.js +43 -0
  14. package/lib/icons.d.ts +1 -0
  15. package/lib/icons.js +5 -0
  16. package/lib/index.d.ts +1 -0
  17. package/lib/index.js +1 -0
  18. package/lib/model.d.ts +68 -8
  19. package/lib/model.js +57 -12
  20. package/lib/selection-watcher.d.ts +62 -0
  21. package/lib/selection-watcher.js +134 -0
  22. package/lib/types.d.ts +22 -0
  23. package/lib/utils.d.ts +11 -0
  24. package/lib/utils.js +37 -0
  25. package/package.json +3 -15
  26. package/src/active-cell-manager.ts +3 -0
  27. package/src/components/chat-input.tsx +71 -32
  28. package/src/components/chat-messages.tsx +106 -32
  29. package/src/components/chat.tsx +1 -1
  30. package/src/components/code-blocks/code-toolbar.tsx +55 -17
  31. package/src/components/input/cancel-button.tsx +47 -0
  32. package/src/components/input/send-button.tsx +210 -0
  33. package/src/components/mui-extras/tooltipped-button.tsx +92 -0
  34. package/src/icons.ts +6 -0
  35. package/src/index.ts +1 -0
  36. package/src/model.ts +102 -13
  37. package/src/selection-watcher.ts +221 -0
  38. package/src/types.ts +25 -0
  39. package/src/utils.ts +47 -0
  40. package/style/chat.css +13 -0
  41. package/style/icons/include-selection.svg +5 -0
@@ -0,0 +1,210 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
7
+ import SendIcon from '@mui/icons-material/Send';
8
+ import { Box, Menu, MenuItem, Typography } from '@mui/material';
9
+ import React, { useCallback, useEffect, useState } from 'react';
10
+
11
+ import { IChatModel } from '../../model';
12
+ import { TooltippedButton } from '../mui-extras/tooltipped-button';
13
+ import { includeSelectionIcon } from '../../icons';
14
+ import { Selection } from '../../types';
15
+
16
+ const SEND_BUTTON_CLASS = 'jp-chat-send-button';
17
+ const SEND_INCLUDE_OPENER_CLASS = 'jp-chat-send-include-opener';
18
+ const SEND_INCLUDE_LI_CLASS = 'jp-chat-send-include';
19
+
20
+ /**
21
+ * The send button props.
22
+ */
23
+ export type SendButtonProps = {
24
+ model: IChatModel;
25
+ sendWithShiftEnter: boolean;
26
+ inputExists: boolean;
27
+ onSend: (selection?: Selection) => unknown;
28
+ hideIncludeSelection?: boolean;
29
+ hasButtonOnLeft?: boolean;
30
+ };
31
+
32
+ /**
33
+ * The send button, with optional 'include selection' menu.
34
+ */
35
+ export function SendButton(props: SendButtonProps): JSX.Element {
36
+ const { activeCellManager, selectionWatcher } = props.model;
37
+ const hideIncludeSelection = props.hideIncludeSelection ?? false;
38
+ const hasButtonOnLeft = props.hasButtonOnLeft ?? false;
39
+ const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null);
40
+ const [menuOpen, setMenuOpen] = useState(false);
41
+
42
+ const openMenu = useCallback((el: HTMLElement | null) => {
43
+ setMenuAnchorEl(el);
44
+ setMenuOpen(true);
45
+ }, []);
46
+
47
+ const closeMenu = useCallback(() => {
48
+ setMenuOpen(false);
49
+ }, []);
50
+
51
+ const disabled = !props.inputExists;
52
+
53
+ const [selectionTooltip, setSelectionTooltip] = useState<string>('');
54
+ const [disableInclude, setDisableInclude] = useState<boolean>(true);
55
+
56
+ useEffect(() => {
57
+ /**
58
+ * Enable or disable the include selection button, and adapt the tooltip.
59
+ */
60
+ const toggleIncludeState = () => {
61
+ setDisableInclude(
62
+ !(selectionWatcher?.selection || activeCellManager?.available)
63
+ );
64
+ const tooltip = selectionWatcher?.selection
65
+ ? `${selectionWatcher.selection.numLines} line(s) selected`
66
+ : activeCellManager?.available
67
+ ? 'Code from 1 active cell'
68
+ : 'No selection or active cell';
69
+ setSelectionTooltip(tooltip);
70
+ };
71
+
72
+ if (!hideIncludeSelection) {
73
+ selectionWatcher?.selectionChanged.connect(toggleIncludeState);
74
+ activeCellManager?.availabilityChanged.connect(toggleIncludeState);
75
+ toggleIncludeState();
76
+ }
77
+ return () => {
78
+ selectionWatcher?.selectionChanged.disconnect(toggleIncludeState);
79
+ activeCellManager?.availabilityChanged.disconnect(toggleIncludeState);
80
+ };
81
+ }, [activeCellManager, selectionWatcher, hideIncludeSelection]);
82
+
83
+ const defaultTooltip = props.sendWithShiftEnter
84
+ ? 'Send message (SHIFT+ENTER)'
85
+ : 'Send message (ENTER)';
86
+ const tooltip = defaultTooltip;
87
+
88
+ function sendWithSelection() {
89
+ // Append the selected text if exists.
90
+ if (selectionWatcher?.selection) {
91
+ props.onSend({
92
+ type: 'text',
93
+ source: selectionWatcher.selection.text
94
+ });
95
+ closeMenu();
96
+ return;
97
+ }
98
+
99
+ // Append the active cell content if exists.
100
+ if (activeCellManager?.available) {
101
+ props.onSend({
102
+ type: 'cell',
103
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
104
+ source: activeCellManager.getContent(false)!.source
105
+ });
106
+ closeMenu();
107
+ return;
108
+ }
109
+ }
110
+
111
+ return (
112
+ <Box sx={{ display: 'flex', flexWrap: 'nowrap' }}>
113
+ <TooltippedButton
114
+ onClick={() => props.onSend()}
115
+ disabled={disabled}
116
+ tooltip={tooltip}
117
+ buttonProps={{
118
+ size: 'small',
119
+ title: defaultTooltip,
120
+ variant: 'contained',
121
+ className: SEND_BUTTON_CLASS
122
+ }}
123
+ sx={{
124
+ minWidth: 'unset',
125
+ borderTopLeftRadius: hasButtonOnLeft ? '0px' : '2px',
126
+ borderTopRightRadius: hideIncludeSelection ? '2px' : '0px',
127
+ borderBottomRightRadius: hideIncludeSelection ? '2px' : '0px',
128
+ borderBottomLeftRadius: hasButtonOnLeft ? '0px' : '2px'
129
+ }}
130
+ >
131
+ <SendIcon />
132
+ </TooltippedButton>
133
+ {!hideIncludeSelection && (
134
+ <>
135
+ <TooltippedButton
136
+ onClick={e => {
137
+ openMenu(e.currentTarget);
138
+ }}
139
+ disabled={disabled}
140
+ tooltip=""
141
+ buttonProps={{
142
+ variant: 'contained',
143
+ onKeyDown: e => {
144
+ if (e.key !== 'Enter' && e.key !== ' ') {
145
+ return;
146
+ }
147
+ openMenu(e.currentTarget);
148
+ // stopping propagation of this event prevents the prompt from being
149
+ // sent when the dropdown button is selected and clicked via 'Enter'.
150
+ e.stopPropagation();
151
+ },
152
+ className: SEND_INCLUDE_OPENER_CLASS
153
+ }}
154
+ sx={{
155
+ minWidth: 'unset',
156
+ padding: '4px 0px',
157
+ borderRadius: '0px 2px 2px 0px',
158
+ marginLeft: '1px'
159
+ }}
160
+ >
161
+ <KeyboardArrowDown />
162
+ </TooltippedButton>
163
+ <Menu
164
+ open={menuOpen}
165
+ onClose={closeMenu}
166
+ anchorEl={menuAnchorEl}
167
+ anchorOrigin={{
168
+ vertical: 'top',
169
+ horizontal: 'right'
170
+ }}
171
+ transformOrigin={{
172
+ vertical: 'bottom',
173
+ horizontal: 'right'
174
+ }}
175
+ sx={{
176
+ '& .MuiMenuItem-root': {
177
+ display: 'flex',
178
+ alignItems: 'center',
179
+ gap: '8px'
180
+ },
181
+ '& svg': {
182
+ lineHeight: 0
183
+ }
184
+ }}
185
+ >
186
+ <MenuItem
187
+ onClick={e => {
188
+ sendWithSelection();
189
+ // prevent sending second message with no selection
190
+ e.stopPropagation();
191
+ }}
192
+ disabled={disableInclude}
193
+ className={SEND_INCLUDE_LI_CLASS}
194
+ >
195
+ <includeSelectionIcon.react />
196
+ <Box>
197
+ <Typography display="block">
198
+ Send message with selection
199
+ </Typography>
200
+ <Typography display="block" sx={{ opacity: 0.618 }}>
201
+ {selectionTooltip}
202
+ </Typography>
203
+ </Box>
204
+ </MenuItem>
205
+ </Menu>
206
+ </>
207
+ )}
208
+ </Box>
209
+ );
210
+ }
@@ -0,0 +1,92 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import React from 'react';
7
+ import { Button, ButtonProps, SxProps, TooltipProps } from '@mui/material';
8
+
9
+ import { ContrastingTooltip } from './contrasting-tooltip';
10
+
11
+ export type TooltippedButtonProps = {
12
+ onClick: React.MouseEventHandler<HTMLButtonElement>;
13
+ tooltip: string;
14
+ children: JSX.Element;
15
+ disabled?: boolean;
16
+ placement?: TooltipProps['placement'];
17
+ /**
18
+ * The offset of the tooltip popup.
19
+ *
20
+ * The expected syntax is defined by the Popper library:
21
+ * https://popper.js.org/docs/v2/modifiers/offset/
22
+ */
23
+ offset?: [number, number];
24
+ 'aria-label'?: string;
25
+ /**
26
+ * Props passed directly to the MUI `Button` component.
27
+ */
28
+ buttonProps?: ButtonProps;
29
+ /**
30
+ * Styles applied to the MUI `Button` component.
31
+ */
32
+ sx?: SxProps;
33
+ };
34
+
35
+ /**
36
+ * A component that renders an MUI `Button` with a high-contrast tooltip
37
+ * provided by `ContrastingTooltip`. This component differs from the MUI
38
+ * defaults in the following ways:
39
+ *
40
+ * - Shows the tooltip on hover even if disabled.
41
+ * - Renders the tooltip above the button by default.
42
+ * - Renders the tooltip closer to the button by default.
43
+ * - Lowers the opacity of the Button when disabled.
44
+ * - Renders the Button with `line-height: 0` to avoid showing extra
45
+ * vertical space in SVG icons.
46
+ *
47
+ * NOTE TO DEVS: Please keep this component's features synchronized with
48
+ * features available to `TooltippedIconButton`.
49
+ */
50
+ export function TooltippedButton(props: TooltippedButtonProps): JSX.Element {
51
+ return (
52
+ <ContrastingTooltip
53
+ title={props.tooltip}
54
+ placement={props.placement ?? 'top'}
55
+ slotProps={{
56
+ popper: {
57
+ modifiers: [
58
+ {
59
+ name: 'offset',
60
+ options: {
61
+ offset: [0, -8]
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ }}
67
+ >
68
+ {/*
69
+ By default, tooltips never appear when the Button is disabled. The
70
+ official way to support this feature in MUI is to wrap the child Button
71
+ element in a `span` element.
72
+
73
+ See: https://mui.com/material-ui/react-tooltip/#disabled-elements
74
+ */}
75
+ <span style={{ cursor: 'default' }}>
76
+ <Button
77
+ {...props.buttonProps}
78
+ onClick={props.onClick}
79
+ disabled={props.disabled}
80
+ sx={{
81
+ lineHeight: 0,
82
+ ...(props.disabled && { opacity: 0.5 }),
83
+ ...props.sx
84
+ }}
85
+ aria-label={props['aria-label']}
86
+ >
87
+ {props.children}
88
+ </Button>
89
+ </span>
90
+ </ContrastingTooltip>
91
+ );
92
+ }
package/src/icons.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { LabIcon } from '@jupyterlab/ui-components';
9
9
 
10
10
  import chatSvgStr from '../style/icons/chat.svg';
11
+ import includeSelectionIconStr from '../style/icons/include-selection.svg';
11
12
  import readSvgStr from '../style/icons/read.svg';
12
13
  import replaceCellSvg from '../style/icons/replace-cell.svg';
13
14
 
@@ -25,3 +26,8 @@ export const replaceCellIcon = new LabIcon({
25
26
  name: 'jupyter-ai::replace-cell',
26
27
  svgstr: replaceCellSvg
27
28
  });
29
+
30
+ export const includeSelectionIcon = new LabIcon({
31
+ name: 'jupyter-chat::include',
32
+ svgstr: includeSelectionIconStr
33
+ });
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from './model';
8
8
  export * from './registry';
9
9
  export * from './types';
10
10
  export * from './active-cell-manager';
11
+ export * from './selection-watcher';
11
12
  export * from './widgets/chat-error';
12
13
  export * from './widgets/chat-sidebar';
13
14
  export * from './widgets/chat-widget';
package/src/model.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  IUser
16
16
  } from './types';
17
17
  import { IActiveCellManager } from './active-cell-manager';
18
+ import { ISelectionWatcher } from './selection-watcher';
18
19
 
19
20
  /**
20
21
  * The chat model interface.
@@ -55,6 +56,11 @@ export interface IChatModel extends IDisposable {
55
56
  */
56
57
  readonly activeCellManager: IActiveCellManager | null;
57
58
 
59
+ /**
60
+ * Get the selection watcher.
61
+ */
62
+ readonly selectionWatcher: ISelectionWatcher | null;
63
+
58
64
  /**
59
65
  * A signal emitting when the messages list is updated.
60
66
  */
@@ -75,6 +81,16 @@ export interface IChatModel extends IDisposable {
75
81
  */
76
82
  readonly viewportChanged?: ISignal<IChatModel, number[]>;
77
83
 
84
+ /**
85
+ * A signal emitting when the writers change.
86
+ */
87
+ readonly writersChanged?: ISignal<IChatModel, IUser[]>;
88
+
89
+ /**
90
+ * A signal emitting when the focus is requested on the input.
91
+ */
92
+ readonly focusInputSignal?: ISignal<IChatModel, void>;
93
+
78
94
  /**
79
95
  * Send a message, to be defined depending on the chosen technology.
80
96
  * Default to no-op.
@@ -82,7 +98,7 @@ export interface IChatModel extends IDisposable {
82
98
  * @param message - the message to send.
83
99
  * @returns whether the message has been sent or not, or nothing if not needed.
84
100
  */
85
- addMessage(message: INewMessage): Promise<boolean | void> | boolean | void;
101
+ sendMessage(message: INewMessage): Promise<boolean | void> | boolean | void;
86
102
 
87
103
  /**
88
104
  * Optional, to update a message from the chat panel.
@@ -139,6 +155,21 @@ export interface IChatModel extends IDisposable {
139
155
  * @param count - the number of messages to delete.
140
156
  */
141
157
  messagesDeleted(index: number, count: number): void;
158
+
159
+ /**
160
+ * Update the current writers list.
161
+ */
162
+ updateWriters(writers: IUser[]): void;
163
+
164
+ /**
165
+ * Function to request the focus on the input of the chat.
166
+ */
167
+ focusInput(): void;
168
+
169
+ /**
170
+ * Function called by the input on key pressed.
171
+ */
172
+ inputChanged?(input?: string): void;
142
173
  }
143
174
 
144
175
  /**
@@ -154,23 +185,19 @@ export class ChatModel implements IChatModel {
154
185
  const config = options.config ?? {};
155
186
 
156
187
  // Stack consecutive messages from the same user by default.
157
- this._config = { stackMessages: true, ...config };
188
+ this._config = {
189
+ stackMessages: true,
190
+ sendTypingNotification: true,
191
+ ...config
192
+ };
158
193
 
159
194
  this._commands = options.commands;
160
195
 
161
196
  this._activeCellManager = options.activeCellManager ?? null;
162
- }
163
197
 
164
- /**
165
- * The chat messages list.
166
- */
167
- get messages(): IChatMessage[] {
168
- return this._messages;
198
+ this._selectionWatcher = options.selectionWatcher ?? null;
169
199
  }
170
200
 
171
- get activeCellManager(): IActiveCellManager | null {
172
- return this._activeCellManager;
173
- }
174
201
  /**
175
202
  * The chat model id.
176
203
  */
@@ -191,6 +218,26 @@ export class ChatModel implements IChatModel {
191
218
  this._name = value;
192
219
  }
193
220
 
221
+ /**
222
+ * The chat messages list.
223
+ */
224
+ get messages(): IChatMessage[] {
225
+ return this._messages;
226
+ }
227
+ /**
228
+ * Get the active cell manager.
229
+ */
230
+ get activeCellManager(): IActiveCellManager | null {
231
+ return this._activeCellManager;
232
+ }
233
+
234
+ /**
235
+ * Get the selection watcher.
236
+ */
237
+ get selectionWatcher(): ISelectionWatcher | null {
238
+ return this._selectionWatcher;
239
+ }
240
+
194
241
  /**
195
242
  * Timestamp of the last read message in local storage.
196
243
  */
@@ -328,6 +375,20 @@ export class ChatModel implements IChatModel {
328
375
  return this._viewportChanged;
329
376
  }
330
377
 
378
+ /**
379
+ * A signal emitting when the writers change.
380
+ */
381
+ get writersChanged(): ISignal<IChatModel, IUser[]> {
382
+ return this._writersChanged;
383
+ }
384
+
385
+ /**
386
+ * A signal emitting when the focus is requested on the input.
387
+ */
388
+ get focusInputSignal(): ISignal<IChatModel, void> {
389
+ return this._focusInputSignal;
390
+ }
391
+
331
392
  /**
332
393
  * Send a message, to be defined depending on the chosen technology.
333
394
  * Default to no-op.
@@ -335,7 +396,7 @@ export class ChatModel implements IChatModel {
335
396
  * @param message - the message to send.
336
397
  * @returns whether the message has been sent or not.
337
398
  */
338
- addMessage(message: INewMessage): Promise<boolean | void> | boolean | void {}
399
+ sendMessage(message: INewMessage): Promise<boolean | void> | boolean | void {}
339
400
 
340
401
  /**
341
402
  * Dispose the chat model.
@@ -435,6 +496,26 @@ export class ChatModel implements IChatModel {
435
496
  this._messagesUpdated.emit();
436
497
  }
437
498
 
499
+ /**
500
+ * Update the current writers list.
501
+ * This implementation only propagate the list via a signal.
502
+ */
503
+ updateWriters(writers: IUser[]): void {
504
+ this._writersChanged.emit(writers);
505
+ }
506
+
507
+ /**
508
+ * Function to request the focus on the input of the chat.
509
+ */
510
+ focusInput(): void {
511
+ this._focusInputSignal.emit();
512
+ }
513
+
514
+ /**
515
+ * Function called by the input on key pressed.
516
+ */
517
+ inputChanged?(input?: string): void {}
518
+
438
519
  /**
439
520
  * Add unread messages to the list.
440
521
  * @param indexes - list of new indexes.
@@ -493,11 +574,14 @@ export class ChatModel implements IChatModel {
493
574
  private _isDisposed = false;
494
575
  private _commands?: CommandRegistry;
495
576
  private _activeCellManager: IActiveCellManager | null;
577
+ private _selectionWatcher: ISelectionWatcher | null;
496
578
  private _notificationId: string | null = null;
497
579
  private _messagesUpdated = new Signal<IChatModel, void>(this);
498
580
  private _configChanged = new Signal<IChatModel, IConfig>(this);
499
581
  private _unreadChanged = new Signal<IChatModel, number[]>(this);
500
582
  private _viewportChanged = new Signal<IChatModel, number[]>(this);
583
+ private _writersChanged = new Signal<IChatModel, IUser[]>(this);
584
+ private _focusInputSignal = new Signal<ChatModel, void>(this);
501
585
  }
502
586
 
503
587
  /**
@@ -519,8 +603,13 @@ export namespace ChatModel {
519
603
  commands?: CommandRegistry;
520
604
 
521
605
  /**
522
- * Active cell manager
606
+ * Active cell manager.
523
607
  */
524
608
  activeCellManager?: IActiveCellManager | null;
609
+
610
+ /**
611
+ * Selection watcher.
612
+ */
613
+ selectionWatcher?: ISelectionWatcher | null;
525
614
  }
526
615
  }