@jupyter/chat 0.14.0 → 0.16.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.
- package/lib/components/attachments.d.ts +0 -3
- package/lib/components/attachments.js +28 -3
- package/lib/components/chat.js +1 -1
- package/lib/components/input/chat-input.js +1 -1
- package/lib/components/input/use-chat-commands.js +11 -3
- package/lib/input-model.js +55 -5
- package/lib/types.d.ts +73 -8
- package/lib/utils.js +8 -6
- package/lib/widgets/chat-widget.d.ts +42 -0
- package/lib/widgets/chat-widget.js +231 -0
- package/package.json +1 -1
- package/src/components/attachments.tsx +35 -4
- package/src/components/chat.tsx +1 -1
- package/src/components/input/chat-input.tsx +1 -1
- package/src/components/input/use-chat-commands.tsx +11 -3
- package/src/input-model.ts +71 -7
- package/src/types.ts +77 -8
- package/src/utils.ts +8 -6
- package/src/widgets/chat-widget.tsx +279 -0
- package/style/chat.css +9 -0
- package/style/input.css +44 -0
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
7
6
|
import CloseIcon from '@mui/icons-material/Close';
|
|
8
7
|
import { Box } from '@mui/material';
|
|
9
8
|
import React, { useContext } from 'react';
|
|
9
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
10
|
+
import { UUID } from '@lumino/coreutils';
|
|
10
11
|
|
|
11
12
|
import { TooltippedButton } from './mui-extras/tooltipped-button';
|
|
12
13
|
import { IAttachment } from '../types';
|
|
@@ -18,8 +19,34 @@ const ATTACHMENT_CLICKABLE_CLASS = 'jp-chat-attachment-clickable';
|
|
|
18
19
|
const REMOVE_BUTTON_CLASS = 'jp-chat-attachment-remove';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
*
|
|
22
|
+
* Generate a user-friendly display name for an attachment
|
|
22
23
|
*/
|
|
24
|
+
function getAttachmentDisplayName(attachment: IAttachment): string {
|
|
25
|
+
if (attachment.type === 'notebook') {
|
|
26
|
+
// Extract notebook filename with extension
|
|
27
|
+
const notebookName =
|
|
28
|
+
PathExt.basename(attachment.value) || 'Unknown notebook';
|
|
29
|
+
|
|
30
|
+
// Show info about attached cells if there are any
|
|
31
|
+
if (attachment.cells?.length === 1) {
|
|
32
|
+
return `${notebookName}: ${attachment.cells[0].input_type} cell`;
|
|
33
|
+
} else if (attachment.cells && attachment.cells.length > 1) {
|
|
34
|
+
return `${notebookName}: ${attachment.cells.length} cells`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return notebookName;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (attachment.type === 'file') {
|
|
41
|
+
// Extract filename with extension
|
|
42
|
+
const fileName = PathExt.basename(attachment.value) || 'Unknown file';
|
|
43
|
+
|
|
44
|
+
return fileName;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (attachment as any).value || 'Unknown attachment';
|
|
48
|
+
}
|
|
49
|
+
|
|
23
50
|
export type AttachmentsProps = {
|
|
24
51
|
attachments: IAttachment[];
|
|
25
52
|
onRemove?: (attachment: IAttachment) => void;
|
|
@@ -32,7 +59,11 @@ export function AttachmentPreviewList(props: AttachmentsProps): JSX.Element {
|
|
|
32
59
|
return (
|
|
33
60
|
<Box className={ATTACHMENTS_CLASS}>
|
|
34
61
|
{props.attachments.map(attachment => (
|
|
35
|
-
<AttachmentPreview
|
|
62
|
+
<AttachmentPreview
|
|
63
|
+
key={`${PathExt.basename(attachment.value)}-${UUID.uuid4()}`}
|
|
64
|
+
{...props}
|
|
65
|
+
attachment={attachment}
|
|
66
|
+
/>
|
|
36
67
|
))}
|
|
37
68
|
</Box>
|
|
38
69
|
);
|
|
@@ -66,7 +97,7 @@ export function AttachmentPreview(props: AttachmentProps): JSX.Element {
|
|
|
66
97
|
)
|
|
67
98
|
}
|
|
68
99
|
>
|
|
69
|
-
{props.attachment
|
|
100
|
+
{getAttachmentDisplayName(props.attachment)}
|
|
70
101
|
</span>
|
|
71
102
|
{props.onRemove && (
|
|
72
103
|
<TooltippedButton
|
package/src/components/chat.tsx
CHANGED
|
@@ -159,7 +159,7 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
|
|
|
159
159
|
) {
|
|
160
160
|
// Run all command providers
|
|
161
161
|
await props.chatCommandRegistry?.onSubmit(model);
|
|
162
|
-
model.send(
|
|
162
|
+
model.send(model.value);
|
|
163
163
|
event.stopPropagation();
|
|
164
164
|
event.preventDefault();
|
|
165
165
|
}
|
|
@@ -37,7 +37,9 @@ export function useChatCommands(
|
|
|
37
37
|
// whether an option is highlighted in the chat commands menu
|
|
38
38
|
const [highlighted, setHighlighted] = useState(false);
|
|
39
39
|
|
|
40
|
-
// whether the chat commands menu is open
|
|
40
|
+
// whether the chat commands menu is open.
|
|
41
|
+
// NOTE: every `setOpen(false)` call should be followed by a
|
|
42
|
+
// `setHighlighted(false)` call.
|
|
41
43
|
const [open, setOpen] = useState(false);
|
|
42
44
|
|
|
43
45
|
// current list of chat commands matched by the current word.
|
|
@@ -90,7 +92,12 @@ export function useChatCommands(
|
|
|
90
92
|
|
|
91
93
|
// Otherwise, open/close the menu based on the presence of command
|
|
92
94
|
// completions and set the menu entries.
|
|
93
|
-
|
|
95
|
+
if (commandCompletions.length) {
|
|
96
|
+
setOpen(true);
|
|
97
|
+
} else {
|
|
98
|
+
setOpen(false);
|
|
99
|
+
setHighlighted(false);
|
|
100
|
+
}
|
|
94
101
|
setCommands(commandCompletions);
|
|
95
102
|
}
|
|
96
103
|
|
|
@@ -138,7 +145,8 @@ export function useChatCommands(
|
|
|
138
145
|
autocompleteProps: {
|
|
139
146
|
open,
|
|
140
147
|
options: commands,
|
|
141
|
-
getOptionLabel: (command: ChatCommand) =>
|
|
148
|
+
getOptionLabel: (command: ChatCommand | string) =>
|
|
149
|
+
typeof command === 'string' ? '' : command.name,
|
|
142
150
|
renderOption: (
|
|
143
151
|
defaultProps,
|
|
144
152
|
command: ChatCommand,
|
package/src/input-model.ts
CHANGED
|
@@ -9,9 +9,7 @@ import { ISignal, Signal } from '@lumino/signaling';
|
|
|
9
9
|
import { IActiveCellManager } from './active-cell-manager';
|
|
10
10
|
import { ISelectionWatcher } from './selection-watcher';
|
|
11
11
|
import { IChatContext } from './model';
|
|
12
|
-
import { IAttachment, IUser } from './types';
|
|
13
|
-
|
|
14
|
-
const WHITESPACE = new Set([' ', '\n', '\t']);
|
|
12
|
+
import { IAttachment, INotebookAttachment, IUser } from './types';
|
|
15
13
|
|
|
16
14
|
/**
|
|
17
15
|
* The chat input interface.
|
|
@@ -320,13 +318,46 @@ export class InputModel implements IInputModel {
|
|
|
320
318
|
* Add attachment to send with next message.
|
|
321
319
|
*/
|
|
322
320
|
addAttachment = (attachment: IAttachment): void => {
|
|
321
|
+
// Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
|
|
322
|
+
const attachmentJson = JSON.stringify(attachment);
|
|
323
323
|
const duplicateAttachment = this._attachments.find(
|
|
324
|
-
att =>
|
|
324
|
+
att => JSON.stringify(att) === attachmentJson
|
|
325
325
|
);
|
|
326
326
|
if (duplicateAttachment) {
|
|
327
327
|
return;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
// Merge cells from same notebook into the same attachment
|
|
331
|
+
if (attachment.type === 'notebook' && attachment.cells) {
|
|
332
|
+
const existingNotebookIndex = this._attachments.findIndex(
|
|
333
|
+
att => att.type === 'notebook' && att.value === attachment.value
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
if (existingNotebookIndex !== -1) {
|
|
337
|
+
const existingAttachment = this._attachments[
|
|
338
|
+
existingNotebookIndex
|
|
339
|
+
] as INotebookAttachment;
|
|
340
|
+
const existingCells = existingAttachment.cells || [];
|
|
341
|
+
|
|
342
|
+
// Filter out duplicate cells
|
|
343
|
+
const newCells = attachment.cells.filter(
|
|
344
|
+
newCell =>
|
|
345
|
+
!existingCells.some(existingCell => existingCell.id === newCell.id)
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (!newCells.length) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this._attachments[existingNotebookIndex] = {
|
|
353
|
+
...existingAttachment,
|
|
354
|
+
cells: [...existingCells, ...newCells]
|
|
355
|
+
};
|
|
356
|
+
this._attachmentsChanged.emit([...this._attachments]);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
330
361
|
this._attachments.push(attachment);
|
|
331
362
|
this._attachmentsChanged.emit([...this._attachments]);
|
|
332
363
|
};
|
|
@@ -335,8 +366,10 @@ export class InputModel implements IInputModel {
|
|
|
335
366
|
* Remove attachment to be sent.
|
|
336
367
|
*/
|
|
337
368
|
removeAttachment = (attachment: IAttachment): void => {
|
|
369
|
+
// Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
|
|
370
|
+
const attachmentJson = JSON.stringify(attachment);
|
|
338
371
|
const attachmentIndex = this._attachments.findIndex(
|
|
339
|
-
att =>
|
|
372
|
+
att => JSON.stringify(att) === attachmentJson
|
|
340
373
|
);
|
|
341
374
|
if (attachmentIndex === -1) {
|
|
342
375
|
return;
|
|
@@ -525,6 +558,18 @@ export namespace InputModel {
|
|
|
525
558
|
}
|
|
526
559
|
|
|
527
560
|
namespace Private {
|
|
561
|
+
const WHITESPACE = new Set([' ', '\n', '\t']);
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Returns the start index (inclusive) & end index (exclusive) that contain
|
|
565
|
+
* the current word. The start & end index can be passed to `String.slice()`
|
|
566
|
+
* to extract the current word. The returned range never includes any
|
|
567
|
+
* whitespace character unless it is escaped by a backslash `\`.
|
|
568
|
+
*
|
|
569
|
+
* NOTE: the escape sequence handling here is naive and non-recursive. This
|
|
570
|
+
* function considers the space in "`\\ `" as escaped, even though "`\\ `"
|
|
571
|
+
* defines a backslash followed by an _unescaped_ space in most languages.
|
|
572
|
+
*/
|
|
528
573
|
export function getCurrentWordBoundaries(
|
|
529
574
|
input: string,
|
|
530
575
|
cursorIndex: number
|
|
@@ -533,11 +578,30 @@ namespace Private {
|
|
|
533
578
|
let end = cursorIndex;
|
|
534
579
|
const n = input.length;
|
|
535
580
|
|
|
536
|
-
while (
|
|
581
|
+
while (
|
|
582
|
+
// terminate when `input[start - 1]` is whitespace
|
|
583
|
+
// i.e. `input[start]` is never whitespace after exiting
|
|
584
|
+
(start - 1 >= 0 && !WHITESPACE.has(input[start - 1])) ||
|
|
585
|
+
// unless it is preceded by a backslash
|
|
586
|
+
(start - 2 >= 0 &&
|
|
587
|
+
input[start - 2] === '\\' &&
|
|
588
|
+
WHITESPACE.has(input[start - 1]))
|
|
589
|
+
) {
|
|
537
590
|
start--;
|
|
538
591
|
}
|
|
539
592
|
|
|
540
|
-
|
|
593
|
+
// `end` is an exclusive index unlike `start`, hence the different `while`
|
|
594
|
+
// condition here
|
|
595
|
+
while (
|
|
596
|
+
// terminate when `input[end]` is whitespace
|
|
597
|
+
// i.e. `input[end]` may be whitespace after exiting
|
|
598
|
+
(end < n && !WHITESPACE.has(input[end])) ||
|
|
599
|
+
// unless it is preceded by a backslash
|
|
600
|
+
(end < n &&
|
|
601
|
+
end - 1 >= 0 &&
|
|
602
|
+
input[end - 1] === '\\' &&
|
|
603
|
+
WHITESPACE.has(input[end]))
|
|
604
|
+
) {
|
|
541
605
|
end++;
|
|
542
606
|
}
|
|
543
607
|
|
package/src/types.ts
CHANGED
|
@@ -14,7 +14,12 @@ export interface IUser {
|
|
|
14
14
|
color?: string;
|
|
15
15
|
avatar_url?: string;
|
|
16
16
|
/**
|
|
17
|
-
* The string to use to mention a user in the chat.
|
|
17
|
+
* The string to use to mention a user in the chat. This should be computed
|
|
18
|
+
* via the following procedure:
|
|
19
|
+
*
|
|
20
|
+
* 1. Let `mention_name = user.display_name || user.name || user.username`.
|
|
21
|
+
*
|
|
22
|
+
* 2. Replace each ' ' character with '-' in `mention_name`.
|
|
18
23
|
*/
|
|
19
24
|
mention_name?: string;
|
|
20
25
|
/**
|
|
@@ -82,21 +87,85 @@ export interface INewMessage {
|
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
/**
|
|
85
|
-
* The attachment
|
|
90
|
+
* The attachment type. Jupyter Chat allows for two types of attachments
|
|
91
|
+
* currently:
|
|
92
|
+
*
|
|
93
|
+
* 1. File attachments (`IFileAttachment`)
|
|
94
|
+
* 2. Notebook attachments (`INotebookAttachment`)
|
|
95
|
+
*
|
|
96
|
+
* The `type` field is always defined on every attachment, so it can be used to
|
|
97
|
+
* distinguish different attachment types.
|
|
86
98
|
*/
|
|
87
|
-
export
|
|
99
|
+
export type IAttachment = IFileAttachment | INotebookAttachment;
|
|
100
|
+
|
|
101
|
+
export interface IFileAttachment {
|
|
102
|
+
type: 'file';
|
|
103
|
+
/**
|
|
104
|
+
* The path to the file, relative to `ContentsManager.root_dir`.
|
|
105
|
+
*/
|
|
106
|
+
value: string;
|
|
107
|
+
/**
|
|
108
|
+
* (optional) The MIME type of the attachment.
|
|
109
|
+
*/
|
|
110
|
+
mimetype?: string;
|
|
111
|
+
/**
|
|
112
|
+
* (optional) A selection range within the file. See `IAttachmentSelection`
|
|
113
|
+
* for more info.
|
|
114
|
+
*/
|
|
115
|
+
selection?: IAttachmentSelection;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Model of a single cell within a notebook attachment.
|
|
120
|
+
*
|
|
121
|
+
* The corresponding backend model is `NotebookCell`.
|
|
122
|
+
*/
|
|
123
|
+
export interface INotebookAttachmentCell {
|
|
124
|
+
/**
|
|
125
|
+
* The ID of the cell within the notebook.
|
|
126
|
+
*/
|
|
127
|
+
id: string;
|
|
88
128
|
/**
|
|
89
|
-
* The type of the
|
|
129
|
+
* The type of the cell.
|
|
90
130
|
*/
|
|
91
|
-
|
|
131
|
+
input_type: 'raw' | 'markdown' | 'code';
|
|
92
132
|
/**
|
|
93
|
-
*
|
|
133
|
+
* (optional) A selection range within the cell. See `IAttachmentSelection` for
|
|
134
|
+
* more info.
|
|
135
|
+
*/
|
|
136
|
+
selection?: IAttachmentSelection;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Model of a notebook attachment.
|
|
141
|
+
*
|
|
142
|
+
* The corresponding backend model is `NotebookAttachment`.
|
|
143
|
+
*/
|
|
144
|
+
export interface INotebookAttachment {
|
|
145
|
+
type: 'notebook';
|
|
146
|
+
/**
|
|
147
|
+
* The local path of the notebook, relative to `ContentsManager.root_dir`.
|
|
94
148
|
*/
|
|
95
149
|
value: string;
|
|
96
150
|
/**
|
|
97
|
-
*
|
|
151
|
+
* (optional) A list of cells in the notebook.
|
|
98
152
|
*/
|
|
99
|
-
|
|
153
|
+
cells?: INotebookAttachmentCell[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface IAttachmentSelection {
|
|
157
|
+
/**
|
|
158
|
+
* The line number & column number of where the selection begins (inclusive).
|
|
159
|
+
*/
|
|
160
|
+
start: [number, number];
|
|
161
|
+
/**
|
|
162
|
+
* The line number & column number of where the selection ends (inclusive).
|
|
163
|
+
*/
|
|
164
|
+
end: [number, number];
|
|
165
|
+
/**
|
|
166
|
+
* The initial content of the selection.
|
|
167
|
+
*/
|
|
168
|
+
content: string;
|
|
100
169
|
}
|
|
101
170
|
|
|
102
171
|
/**
|
package/src/utils.ts
CHANGED
|
@@ -60,9 +60,10 @@ export function replaceMentionToSpan(content: string, user: IUser): string {
|
|
|
60
60
|
if (!user.mention_name) {
|
|
61
61
|
return content;
|
|
62
62
|
}
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
63
|
+
const mention = '@' + user.mention_name;
|
|
64
|
+
const regex = new RegExp(mention, 'g');
|
|
65
|
+
const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
|
|
66
|
+
return content.replace(regex, mentionEl);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/**
|
|
@@ -75,7 +76,8 @@ export function replaceSpanToMention(content: string, user: IUser): string {
|
|
|
75
76
|
if (!user.mention_name) {
|
|
76
77
|
return content;
|
|
77
78
|
}
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
79
|
+
const mention = '@' + user.mention_name;
|
|
80
|
+
const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
|
|
81
|
+
const regex = new RegExp(mentionEl, 'g');
|
|
82
|
+
return content.replace(regex, mention);
|
|
81
83
|
}
|
|
@@ -4,11 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
7
|
+
import { Cell } from '@jupyterlab/cells';
|
|
8
|
+
import { DirListing } from '@jupyterlab/filebrowser';
|
|
9
|
+
import { ICell, isCode, isMarkdown, isRaw } from '@jupyterlab/nbformat';
|
|
7
10
|
import React from 'react';
|
|
11
|
+
import { Message } from '@lumino/messaging';
|
|
12
|
+
import { Drag } from '@lumino/dragdrop';
|
|
8
13
|
|
|
9
14
|
import { Chat, IInputToolbarRegistry } from '../components';
|
|
10
15
|
import { chatIcon } from '../icons';
|
|
11
16
|
import { IChatModel } from '../model';
|
|
17
|
+
import {
|
|
18
|
+
IFileAttachment,
|
|
19
|
+
INotebookAttachment,
|
|
20
|
+
INotebookAttachmentCell
|
|
21
|
+
} from '../types';
|
|
22
|
+
import { ActiveCellManager } from '../active-cell-manager';
|
|
23
|
+
|
|
24
|
+
// MIME type constant for file browser drag events
|
|
25
|
+
const FILE_BROWSER_MIME = 'application/x-jupyter-icontentsrich';
|
|
26
|
+
|
|
27
|
+
// MIME type constant for Notebook cell drag events
|
|
28
|
+
const NOTEBOOK_CELL_MIME = 'application/vnd.jupyter.cells';
|
|
29
|
+
|
|
30
|
+
// CSS class constants
|
|
31
|
+
const INPUT_CONTAINER_CLASS = 'jp-chat-input-container';
|
|
32
|
+
const DRAG_HOVER_CLASS = 'jp-chat-drag-hover';
|
|
12
33
|
|
|
13
34
|
export class ChatWidget extends ReactWidget {
|
|
14
35
|
constructor(options: Chat.IOptions) {
|
|
@@ -42,5 +63,263 @@ export class ChatWidget extends ReactWidget {
|
|
|
42
63
|
return <Chat {...this._chatOptions} model={this._chatOptions.model} />;
|
|
43
64
|
}
|
|
44
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Handle DOM events for drag and drop
|
|
68
|
+
*/
|
|
69
|
+
handleEvent(event: Event): void {
|
|
70
|
+
// only handle drag-and-drop events
|
|
71
|
+
if (!(event instanceof Drag.Event)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
switch (event.type) {
|
|
76
|
+
case 'lm-dragenter':
|
|
77
|
+
// see `Drag.Event` documentation for context
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
event.stopPropagation();
|
|
80
|
+
break;
|
|
81
|
+
case 'lm-dragover':
|
|
82
|
+
this._handleDrag(event);
|
|
83
|
+
break;
|
|
84
|
+
case 'lm-drop':
|
|
85
|
+
this._handleDrop(event);
|
|
86
|
+
break;
|
|
87
|
+
case 'lm-dragleave': {
|
|
88
|
+
// Remove hover class on leaving the widget
|
|
89
|
+
const targetElement = event.relatedTarget;
|
|
90
|
+
if (!targetElement || !this.node.contains(targetElement as Node)) {
|
|
91
|
+
this._removeDragHoverClass();
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A message handler invoked on an `'after-attach'` message.
|
|
100
|
+
*/
|
|
101
|
+
protected onAfterAttach(msg: Message): void {
|
|
102
|
+
super.onAfterAttach(msg);
|
|
103
|
+
this.node.addEventListener('lm-dragover', this, true);
|
|
104
|
+
this.node.addEventListener('lm-dragenter', this, true);
|
|
105
|
+
this.node.addEventListener('lm-drop', this, true);
|
|
106
|
+
this.node.addEventListener('lm-dragleave', this, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A message handler invoked on a `'before-detach'` message.
|
|
111
|
+
*/
|
|
112
|
+
protected onBeforeDetach(msg: Message): void {
|
|
113
|
+
this.node.removeEventListener('lm-dragover', this, true);
|
|
114
|
+
this.node.removeEventListener('lm-dragenter', this, true);
|
|
115
|
+
this.node.removeEventListener('lm-drop', this, true);
|
|
116
|
+
this.node.removeEventListener('lm-dragleave', this, true);
|
|
117
|
+
super.onBeforeDetach(msg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle drag over events
|
|
122
|
+
*/
|
|
123
|
+
private _handleDrag(event: Drag.Event): void {
|
|
124
|
+
const inputContainer = this.node.querySelector(`.${INPUT_CONTAINER_CLASS}`);
|
|
125
|
+
const target = event.target as HTMLElement;
|
|
126
|
+
const isOverInput =
|
|
127
|
+
inputContainer?.contains(target) || inputContainer === target;
|
|
128
|
+
|
|
129
|
+
if (!isOverInput) {
|
|
130
|
+
this._removeDragHoverClass();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!this._canHandleDrop(event)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
event.stopPropagation();
|
|
140
|
+
event.dropAction = 'move';
|
|
141
|
+
|
|
142
|
+
if (
|
|
143
|
+
inputContainer &&
|
|
144
|
+
!inputContainer.classList.contains(DRAG_HOVER_CLASS)
|
|
145
|
+
) {
|
|
146
|
+
inputContainer.classList.add(DRAG_HOVER_CLASS);
|
|
147
|
+
this._dragTarget = inputContainer as HTMLElement;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if we can handle the drop
|
|
153
|
+
*/
|
|
154
|
+
private _canHandleDrop(event: Drag.Event): boolean {
|
|
155
|
+
const types = event.mimeData.types();
|
|
156
|
+
return (
|
|
157
|
+
types.includes(NOTEBOOK_CELL_MIME) || types.includes(FILE_BROWSER_MIME)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Handle drop events
|
|
163
|
+
*/
|
|
164
|
+
private _handleDrop(event: Drag.Event): void {
|
|
165
|
+
if (!this._canHandleDrop(event)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
event.stopPropagation();
|
|
171
|
+
event.dropAction = 'move';
|
|
172
|
+
|
|
173
|
+
this._removeDragHoverClass();
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (event.mimeData.hasData(NOTEBOOK_CELL_MIME)) {
|
|
177
|
+
this._processCellDrop(event);
|
|
178
|
+
} else if (event.mimeData.hasData(FILE_BROWSER_MIME)) {
|
|
179
|
+
this._processFileDrop(event);
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error('Error processing drop:', error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Process dropped files
|
|
188
|
+
*/
|
|
189
|
+
private _processFileDrop(event: Drag.Event): void {
|
|
190
|
+
const data = event.mimeData.getData(
|
|
191
|
+
FILE_BROWSER_MIME
|
|
192
|
+
) as DirListing.IContentsThunk;
|
|
193
|
+
|
|
194
|
+
if (!data?.model?.path) {
|
|
195
|
+
console.warn('Invalid file browser data in drop event');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const attachment: IFileAttachment = {
|
|
200
|
+
type: 'file',
|
|
201
|
+
value: data.model.path,
|
|
202
|
+
mimetype: data.model.mimetype
|
|
203
|
+
};
|
|
204
|
+
this.model.input.addAttachment?.(attachment);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Process dropped cells
|
|
209
|
+
*/
|
|
210
|
+
private _processCellDrop(event: Drag.Event): void {
|
|
211
|
+
try {
|
|
212
|
+
const cellData = event.mimeData.getData(NOTEBOOK_CELL_MIME) as ICell[];
|
|
213
|
+
|
|
214
|
+
// Cells might come as array or single object
|
|
215
|
+
const cells = Array.isArray(cellData) ? cellData : [cellData];
|
|
216
|
+
|
|
217
|
+
// Get path from first cell as all cells come from same notebook as users can only select or drag cells from one notebook at a time
|
|
218
|
+
if (!cells[0]?.id) {
|
|
219
|
+
console.warn('No valid cells to process');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const notebookPath = this._findNotebookPath(String(cells[0].id));
|
|
224
|
+
if (!notebookPath) {
|
|
225
|
+
console.warn(
|
|
226
|
+
`Cannot find notebook for dragged cells from ${cells[0].id}`
|
|
227
|
+
);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const validCells: INotebookAttachmentCell[] = [];
|
|
232
|
+
|
|
233
|
+
for (const cell of cells) {
|
|
234
|
+
if (!cell.id) {
|
|
235
|
+
console.warn('Dropped cell missing required ID, skipping');
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Use type guards to validate cell type
|
|
240
|
+
let cellType: 'code' | 'markdown' | 'raw';
|
|
241
|
+
if (isCode(cell)) {
|
|
242
|
+
cellType = 'code';
|
|
243
|
+
} else if (isMarkdown(cell)) {
|
|
244
|
+
cellType = 'markdown';
|
|
245
|
+
} else if (isRaw(cell)) {
|
|
246
|
+
cellType = 'raw';
|
|
247
|
+
} else {
|
|
248
|
+
console.warn(`Unknown cell type: ${cell.cell_type}, skipping`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const notebookCell: INotebookAttachmentCell = {
|
|
253
|
+
id: cell.id,
|
|
254
|
+
input_type: cellType
|
|
255
|
+
};
|
|
256
|
+
validCells.push(notebookCell);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Create single attachment with all cells from the notebook
|
|
260
|
+
if (validCells.length) {
|
|
261
|
+
const attachment: INotebookAttachment = {
|
|
262
|
+
type: 'notebook',
|
|
263
|
+
value: notebookPath,
|
|
264
|
+
cells: validCells
|
|
265
|
+
};
|
|
266
|
+
this.model.input.addAttachment?.(attachment);
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error('Failed to process cell drop: ', error);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Find the notebook path for a cell by searching through active and open notebooks
|
|
275
|
+
*/
|
|
276
|
+
private _findNotebookPath(cellId: string): string | null {
|
|
277
|
+
if (!this.model.input.activeCellManager) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const activeCellManager = this.model.input
|
|
282
|
+
.activeCellManager as ActiveCellManager;
|
|
283
|
+
const notebookTracker = (activeCellManager as any)._notebookTracker;
|
|
284
|
+
|
|
285
|
+
if (!notebookTracker) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (notebookTracker.currentWidget) {
|
|
290
|
+
const currentNotebook = notebookTracker.currentWidget;
|
|
291
|
+
const cells = currentNotebook.content.widgets;
|
|
292
|
+
const cellWidget = cells.find((c: Cell) => c.model.id === cellId);
|
|
293
|
+
|
|
294
|
+
if (cellWidget) {
|
|
295
|
+
return currentNotebook.context.path;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// If not in current notebook, check all open notebooks
|
|
300
|
+
const widgets = notebookTracker.widgets || [];
|
|
301
|
+
for (const notebook of widgets) {
|
|
302
|
+
const cells = notebook.content.widgets;
|
|
303
|
+
const cellWidget = cells.find((c: Cell) => c.model.id === cellId);
|
|
304
|
+
|
|
305
|
+
if (cellWidget) {
|
|
306
|
+
return notebook.context.path;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Remove drag hover class
|
|
315
|
+
*/
|
|
316
|
+
private _removeDragHoverClass(): void {
|
|
317
|
+
if (this._dragTarget) {
|
|
318
|
+
this._dragTarget.classList.remove(DRAG_HOVER_CLASS);
|
|
319
|
+
this._dragTarget = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
45
323
|
private _chatOptions: Chat.IOptions;
|
|
324
|
+
private _dragTarget: HTMLElement | null = null;
|
|
46
325
|
}
|
package/style/chat.css
CHANGED
|
@@ -201,6 +201,14 @@
|
|
|
201
201
|
|
|
202
202
|
.jp-chat-attachments {
|
|
203
203
|
display: flex;
|
|
204
|
+
gap: 4px;
|
|
205
|
+
flex-wrap: wrap;
|
|
206
|
+
min-height: 1.5em;
|
|
207
|
+
padding: 8px 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.jp-chat-attachments:empty {
|
|
211
|
+
padding: 8px 0;
|
|
204
212
|
min-height: 1.5em;
|
|
205
213
|
}
|
|
206
214
|
|
|
@@ -211,6 +219,7 @@
|
|
|
211
219
|
padding: 0 0.3em;
|
|
212
220
|
align-content: center;
|
|
213
221
|
background-color: var(--jp-border-color3);
|
|
222
|
+
flex-shrink: 0;
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
.jp-chat-attachment .jp-chat-attachment-clickable:hover {
|