@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.
@@ -1,8 +1,5 @@
1
1
  /// <reference types="react" />
2
2
  import { IAttachment } from '../types';
3
- /**
4
- * The attachments props.
5
- */
6
3
  export type AttachmentsProps = {
7
4
  attachments: IAttachment[];
8
5
  onRemove?: (attachment: IAttachment) => void;
@@ -2,21 +2,46 @@
2
2
  * Copyright (c) Jupyter Development Team.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
- // import { IDocumentManager } from '@jupyterlab/docmanager';
6
5
  import CloseIcon from '@mui/icons-material/Close';
7
6
  import { Box } from '@mui/material';
8
7
  import React, { useContext } from 'react';
8
+ import { PathExt } from '@jupyterlab/coreutils';
9
+ import { UUID } from '@lumino/coreutils';
9
10
  import { TooltippedButton } from './mui-extras/tooltipped-button';
10
11
  import { AttachmentOpenerContext } from '../context';
11
12
  const ATTACHMENTS_CLASS = 'jp-chat-attachments';
12
13
  const ATTACHMENT_CLASS = 'jp-chat-attachment';
13
14
  const ATTACHMENT_CLICKABLE_CLASS = 'jp-chat-attachment-clickable';
14
15
  const REMOVE_BUTTON_CLASS = 'jp-chat-attachment-remove';
16
+ /**
17
+ * Generate a user-friendly display name for an attachment
18
+ */
19
+ function getAttachmentDisplayName(attachment) {
20
+ var _a;
21
+ if (attachment.type === 'notebook') {
22
+ // Extract notebook filename with extension
23
+ const notebookName = PathExt.basename(attachment.value) || 'Unknown notebook';
24
+ // Show info about attached cells if there are any
25
+ if (((_a = attachment.cells) === null || _a === void 0 ? void 0 : _a.length) === 1) {
26
+ return `${notebookName}: ${attachment.cells[0].input_type} cell`;
27
+ }
28
+ else if (attachment.cells && attachment.cells.length > 1) {
29
+ return `${notebookName}: ${attachment.cells.length} cells`;
30
+ }
31
+ return notebookName;
32
+ }
33
+ if (attachment.type === 'file') {
34
+ // Extract filename with extension
35
+ const fileName = PathExt.basename(attachment.value) || 'Unknown file';
36
+ return fileName;
37
+ }
38
+ return attachment.value || 'Unknown attachment';
39
+ }
15
40
  /**
16
41
  * The Attachments component.
17
42
  */
18
43
  export function AttachmentPreviewList(props) {
19
- return (React.createElement(Box, { className: ATTACHMENTS_CLASS }, props.attachments.map(attachment => (React.createElement(AttachmentPreview, { ...props, attachment: attachment })))));
44
+ return (React.createElement(Box, { className: ATTACHMENTS_CLASS }, props.attachments.map(attachment => (React.createElement(AttachmentPreview, { key: `${PathExt.basename(attachment.value)}-${UUID.uuid4()}`, ...props, attachment: attachment })))));
20
45
  }
21
46
  /**
22
47
  * The Attachment component.
@@ -30,7 +55,7 @@ export function AttachmentPreview(props) {
30
55
  : '', onClick: () => {
31
56
  var _a;
32
57
  return (_a = attachmentOpenerRegistry === null || attachmentOpenerRegistry === void 0 ? void 0 : attachmentOpenerRegistry.get(props.attachment.type)) === null || _a === void 0 ? void 0 : _a(props.attachment);
33
- } }, props.attachment.value),
58
+ } }, getAttachmentDisplayName(props.attachment)),
34
59
  props.onRemove && (React.createElement(TooltippedButton, { onClick: () => props.onRemove(props.attachment), tooltip: remove_tooltip, buttonProps: {
35
60
  size: 'small',
36
61
  title: remove_tooltip,
@@ -22,7 +22,7 @@ export function ChatBody(props) {
22
22
  React.createElement(ChatInput, { sx: {
23
23
  paddingLeft: 4,
24
24
  paddingRight: 4,
25
- paddingTop: 1,
25
+ paddingTop: 0,
26
26
  paddingBottom: 0,
27
27
  borderTop: '1px solid var(--jp-border-color1)'
28
28
  }, model: model.input, chatCommandRegistry: props.chatCommandRegistry, toolbarRegistry: inputToolbarRegistry })));
@@ -118,7 +118,7 @@ export function ChatInput(props) {
118
118
  (!sendWithShiftEnter && !event.shiftKey)) {
119
119
  // Run all command providers
120
120
  await ((_a = props.chatCommandRegistry) === null || _a === void 0 ? void 0 : _a.onSubmit(model));
121
- model.send(input);
121
+ model.send(model.value);
122
122
  event.stopPropagation();
123
123
  event.preventDefault();
124
124
  }
@@ -14,7 +14,9 @@ import React, { useEffect, useState } from 'react';
14
14
  export function useChatCommands(inputModel, chatCommandRegistry) {
15
15
  // whether an option is highlighted in the chat commands menu
16
16
  const [highlighted, setHighlighted] = useState(false);
17
- // whether the chat commands menu is open
17
+ // whether the chat commands menu is open.
18
+ // NOTE: every `setOpen(false)` call should be followed by a
19
+ // `setHighlighted(false)` call.
18
20
  const [open, setOpen] = useState(false);
19
21
  // current list of chat commands matched by the current word.
20
22
  // the current word is the space-separated word at the user's cursor.
@@ -55,7 +57,13 @@ export function useChatCommands(inputModel, chatCommandRegistry) {
55
57
  }
56
58
  // Otherwise, open/close the menu based on the presence of command
57
59
  // completions and set the menu entries.
58
- setOpen(!!commandCompletions.length);
60
+ if (commandCompletions.length) {
61
+ setOpen(true);
62
+ }
63
+ else {
64
+ setOpen(false);
65
+ setHighlighted(false);
66
+ }
59
67
  setCommands(commandCompletions);
60
68
  }
61
69
  inputModel.currentWordChanged.connect(getCommands);
@@ -91,7 +99,7 @@ export function useChatCommands(inputModel, chatCommandRegistry) {
91
99
  autocompleteProps: {
92
100
  open,
93
101
  options: commands,
94
- getOptionLabel: (command) => command.name,
102
+ getOptionLabel: (command) => typeof command === 'string' ? '' : command.name,
95
103
  renderOption: (defaultProps, command, __, ___) => {
96
104
  const { key, ...listItemProps } = defaultProps;
97
105
  const commandIcon = React.isValidElement(command.icon) ? (command.icon) : (React.createElement("span", null, command.icon instanceof LabIcon ? (React.createElement(command.icon.react, null)) : (command.icon)));
@@ -3,7 +3,6 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  import { Signal } from '@lumino/signaling';
6
- const WHITESPACE = new Set([' ', '\n', '\t']);
7
6
  /**
8
7
  * The input model.
9
8
  */
@@ -21,10 +20,31 @@ export class InputModel {
21
20
  * Add attachment to send with next message.
22
21
  */
23
22
  this.addAttachment = (attachment) => {
24
- const duplicateAttachment = this._attachments.find(att => att.type === attachment.type && att.value === attachment.value);
23
+ // Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
24
+ const attachmentJson = JSON.stringify(attachment);
25
+ const duplicateAttachment = this._attachments.find(att => JSON.stringify(att) === attachmentJson);
25
26
  if (duplicateAttachment) {
26
27
  return;
27
28
  }
29
+ // Merge cells from same notebook into the same attachment
30
+ if (attachment.type === 'notebook' && attachment.cells) {
31
+ const existingNotebookIndex = this._attachments.findIndex(att => att.type === 'notebook' && att.value === attachment.value);
32
+ if (existingNotebookIndex !== -1) {
33
+ const existingAttachment = this._attachments[existingNotebookIndex];
34
+ const existingCells = existingAttachment.cells || [];
35
+ // Filter out duplicate cells
36
+ const newCells = attachment.cells.filter(newCell => !existingCells.some(existingCell => existingCell.id === newCell.id));
37
+ if (!newCells.length) {
38
+ return;
39
+ }
40
+ this._attachments[existingNotebookIndex] = {
41
+ ...existingAttachment,
42
+ cells: [...existingCells, ...newCells]
43
+ };
44
+ this._attachmentsChanged.emit([...this._attachments]);
45
+ return;
46
+ }
47
+ }
28
48
  this._attachments.push(attachment);
29
49
  this._attachmentsChanged.emit([...this._attachments]);
30
50
  };
@@ -32,7 +52,9 @@ export class InputModel {
32
52
  * Remove attachment to be sent.
33
53
  */
34
54
  this.removeAttachment = (attachment) => {
35
- const attachmentIndex = this._attachments.findIndex(att => att.type === attachment.type && att.value === attachment.value);
55
+ // Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
56
+ const attachmentJson = JSON.stringify(attachment);
57
+ const attachmentIndex = this._attachments.findIndex(att => JSON.stringify(att) === attachmentJson);
36
58
  if (attachmentIndex === -1) {
37
59
  return;
38
60
  }
@@ -254,14 +276,42 @@ export class InputModel {
254
276
  }
255
277
  var Private;
256
278
  (function (Private) {
279
+ const WHITESPACE = new Set([' ', '\n', '\t']);
280
+ /**
281
+ * Returns the start index (inclusive) & end index (exclusive) that contain
282
+ * the current word. The start & end index can be passed to `String.slice()`
283
+ * to extract the current word. The returned range never includes any
284
+ * whitespace character unless it is escaped by a backslash `\`.
285
+ *
286
+ * NOTE: the escape sequence handling here is naive and non-recursive. This
287
+ * function considers the space in "`\\ `" as escaped, even though "`\\ `"
288
+ * defines a backslash followed by an _unescaped_ space in most languages.
289
+ */
257
290
  function getCurrentWordBoundaries(input, cursorIndex) {
258
291
  let start = cursorIndex;
259
292
  let end = cursorIndex;
260
293
  const n = input.length;
261
- while (start > 0 && !WHITESPACE.has(input[start - 1])) {
294
+ while (
295
+ // terminate when `input[start - 1]` is whitespace
296
+ // i.e. `input[start]` is never whitespace after exiting
297
+ (start - 1 >= 0 && !WHITESPACE.has(input[start - 1])) ||
298
+ // unless it is preceded by a backslash
299
+ (start - 2 >= 0 &&
300
+ input[start - 2] === '\\' &&
301
+ WHITESPACE.has(input[start - 1]))) {
262
302
  start--;
263
303
  }
264
- while (end < n && !WHITESPACE.has(input[end])) {
304
+ // `end` is an exclusive index unlike `start`, hence the different `while`
305
+ // condition here
306
+ while (
307
+ // terminate when `input[end]` is whitespace
308
+ // i.e. `input[end]` may be whitespace after exiting
309
+ (end < n && !WHITESPACE.has(input[end])) ||
310
+ // unless it is preceded by a backslash
311
+ (end < n &&
312
+ end - 1 >= 0 &&
313
+ input[end - 1] === '\\' &&
314
+ WHITESPACE.has(input[end]))) {
265
315
  end++;
266
316
  }
267
317
  return [start, end];
package/lib/types.d.ts CHANGED
@@ -9,7 +9,12 @@ export interface IUser {
9
9
  color?: string;
10
10
  avatar_url?: string;
11
11
  /**
12
- * The string to use to mention a user in the chat.
12
+ * The string to use to mention a user in the chat. This should be computed
13
+ * via the following procedure:
14
+ *
15
+ * 1. Let `mention_name = user.display_name || user.name || user.username`.
16
+ *
17
+ * 2. Replace each ' ' character with '-' in `mention_name`.
13
18
  */
14
19
  mention_name?: string;
15
20
  /**
@@ -72,21 +77,81 @@ export interface INewMessage {
72
77
  id?: string;
73
78
  }
74
79
  /**
75
- * The attachment interface.
80
+ * The attachment type. Jupyter Chat allows for two types of attachments
81
+ * currently:
82
+ *
83
+ * 1. File attachments (`IFileAttachment`)
84
+ * 2. Notebook attachments (`INotebookAttachment`)
85
+ *
86
+ * The `type` field is always defined on every attachment, so it can be used to
87
+ * distinguish different attachment types.
76
88
  */
77
- export interface IAttachment {
89
+ export type IAttachment = IFileAttachment | INotebookAttachment;
90
+ export interface IFileAttachment {
91
+ type: 'file';
78
92
  /**
79
- * The type of the attachment (basically 'file', 'variable', 'image')
93
+ * The path to the file, relative to `ContentsManager.root_dir`.
80
94
  */
81
- type: string;
95
+ value: string;
96
+ /**
97
+ * (optional) The MIME type of the attachment.
98
+ */
99
+ mimetype?: string;
100
+ /**
101
+ * (optional) A selection range within the file. See `IAttachmentSelection`
102
+ * for more info.
103
+ */
104
+ selection?: IAttachmentSelection;
105
+ }
106
+ /**
107
+ * Model of a single cell within a notebook attachment.
108
+ *
109
+ * The corresponding backend model is `NotebookCell`.
110
+ */
111
+ export interface INotebookAttachmentCell {
112
+ /**
113
+ * The ID of the cell within the notebook.
114
+ */
115
+ id: string;
116
+ /**
117
+ * The type of the cell.
118
+ */
119
+ input_type: 'raw' | 'markdown' | 'code';
82
120
  /**
83
- * The value, i.e. the file path, the variable name or image content.
121
+ * (optional) A selection range within the cell. See `IAttachmentSelection` for
122
+ * more info.
123
+ */
124
+ selection?: IAttachmentSelection;
125
+ }
126
+ /**
127
+ * Model of a notebook attachment.
128
+ *
129
+ * The corresponding backend model is `NotebookAttachment`.
130
+ */
131
+ export interface INotebookAttachment {
132
+ type: 'notebook';
133
+ /**
134
+ * The local path of the notebook, relative to `ContentsManager.root_dir`.
84
135
  */
85
136
  value: string;
86
137
  /**
87
- * The mimetype of the attachment, optional.
138
+ * (optional) A list of cells in the notebook.
88
139
  */
89
- mimetype?: string;
140
+ cells?: INotebookAttachmentCell[];
141
+ }
142
+ export interface IAttachmentSelection {
143
+ /**
144
+ * The line number & column number of where the selection begins (inclusive).
145
+ */
146
+ start: [number, number];
147
+ /**
148
+ * The line number & column number of where the selection ends (inclusive).
149
+ */
150
+ end: [number, number];
151
+ /**
152
+ * The initial content of the selection.
153
+ */
154
+ content: string;
90
155
  }
91
156
  /**
92
157
  * An empty interface to describe optional settings that could be fetched from server.
package/lib/utils.js CHANGED
@@ -46,9 +46,10 @@ export function replaceMentionToSpan(content, user) {
46
46
  if (!user.mention_name) {
47
47
  return content;
48
48
  }
49
- const regex = new RegExp(user.mention_name, 'g');
50
- const mention = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
51
- return content.replace(regex, mention);
49
+ const mention = '@' + user.mention_name;
50
+ const regex = new RegExp(mention, 'g');
51
+ const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
52
+ return content.replace(regex, mentionEl);
52
53
  }
53
54
  /**
54
55
  * Replace a span to a mentioned to user string (@someone).
@@ -60,7 +61,8 @@ export function replaceSpanToMention(content, user) {
60
61
  if (!user.mention_name) {
61
62
  return content;
62
63
  }
63
- const span = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
64
- const regex = new RegExp(span, 'g');
65
- return content.replace(regex, user.mention_name);
64
+ const mention = '@' + user.mention_name;
65
+ const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
66
+ const regex = new RegExp(mentionEl, 'g');
67
+ return content.replace(regex, mention);
66
68
  }
@@ -1,5 +1,6 @@
1
1
  import { ReactWidget } from '@jupyterlab/apputils';
2
2
  import React from 'react';
3
+ import { Message } from '@lumino/messaging';
3
4
  import { Chat, IInputToolbarRegistry } from '../components';
4
5
  import { IChatModel } from '../model';
5
6
  export declare class ChatWidget extends ReactWidget {
@@ -13,5 +14,46 @@ export declare class ChatWidget extends ReactWidget {
13
14
  */
14
15
  get inputToolbarRegistry(): IInputToolbarRegistry | undefined;
15
16
  render(): React.JSX.Element;
17
+ /**
18
+ * Handle DOM events for drag and drop
19
+ */
20
+ handleEvent(event: Event): void;
21
+ /**
22
+ * A message handler invoked on an `'after-attach'` message.
23
+ */
24
+ protected onAfterAttach(msg: Message): void;
25
+ /**
26
+ * A message handler invoked on a `'before-detach'` message.
27
+ */
28
+ protected onBeforeDetach(msg: Message): void;
29
+ /**
30
+ * Handle drag over events
31
+ */
32
+ private _handleDrag;
33
+ /**
34
+ * Check if we can handle the drop
35
+ */
36
+ private _canHandleDrop;
37
+ /**
38
+ * Handle drop events
39
+ */
40
+ private _handleDrop;
41
+ /**
42
+ * Process dropped files
43
+ */
44
+ private _processFileDrop;
45
+ /**
46
+ * Process dropped cells
47
+ */
48
+ private _processCellDrop;
49
+ /**
50
+ * Find the notebook path for a cell by searching through active and open notebooks
51
+ */
52
+ private _findNotebookPath;
53
+ /**
54
+ * Remove drag hover class
55
+ */
56
+ private _removeDragHoverClass;
16
57
  private _chatOptions;
58
+ private _dragTarget;
17
59
  }
@@ -3,12 +3,22 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  import { ReactWidget } from '@jupyterlab/apputils';
6
+ import { isCode, isMarkdown, isRaw } from '@jupyterlab/nbformat';
6
7
  import React from 'react';
8
+ import { Drag } from '@lumino/dragdrop';
7
9
  import { Chat } from '../components';
8
10
  import { chatIcon } from '../icons';
11
+ // MIME type constant for file browser drag events
12
+ const FILE_BROWSER_MIME = 'application/x-jupyter-icontentsrich';
13
+ // MIME type constant for Notebook cell drag events
14
+ const NOTEBOOK_CELL_MIME = 'application/vnd.jupyter.cells';
15
+ // CSS class constants
16
+ const INPUT_CONTAINER_CLASS = 'jp-chat-input-container';
17
+ const DRAG_HOVER_CLASS = 'jp-chat-drag-hover';
9
18
  export class ChatWidget extends ReactWidget {
10
19
  constructor(options) {
11
20
  super();
21
+ this._dragTarget = null;
12
22
  this.title.icon = chatIcon;
13
23
  this.title.caption = 'Jupyter Chat'; // TODO: i18n
14
24
  this._chatOptions = options;
@@ -32,4 +42,225 @@ export class ChatWidget extends ReactWidget {
32
42
  // the case of collaborative document.
33
43
  return React.createElement(Chat, { ...this._chatOptions, model: this._chatOptions.model });
34
44
  }
45
+ /**
46
+ * Handle DOM events for drag and drop
47
+ */
48
+ handleEvent(event) {
49
+ // only handle drag-and-drop events
50
+ if (!(event instanceof Drag.Event)) {
51
+ return;
52
+ }
53
+ switch (event.type) {
54
+ case 'lm-dragenter':
55
+ // see `Drag.Event` documentation for context
56
+ event.preventDefault();
57
+ event.stopPropagation();
58
+ break;
59
+ case 'lm-dragover':
60
+ this._handleDrag(event);
61
+ break;
62
+ case 'lm-drop':
63
+ this._handleDrop(event);
64
+ break;
65
+ case 'lm-dragleave': {
66
+ // Remove hover class on leaving the widget
67
+ const targetElement = event.relatedTarget;
68
+ if (!targetElement || !this.node.contains(targetElement)) {
69
+ this._removeDragHoverClass();
70
+ }
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * A message handler invoked on an `'after-attach'` message.
77
+ */
78
+ onAfterAttach(msg) {
79
+ super.onAfterAttach(msg);
80
+ this.node.addEventListener('lm-dragover', this, true);
81
+ this.node.addEventListener('lm-dragenter', this, true);
82
+ this.node.addEventListener('lm-drop', this, true);
83
+ this.node.addEventListener('lm-dragleave', this, true);
84
+ }
85
+ /**
86
+ * A message handler invoked on a `'before-detach'` message.
87
+ */
88
+ onBeforeDetach(msg) {
89
+ this.node.removeEventListener('lm-dragover', this, true);
90
+ this.node.removeEventListener('lm-dragenter', this, true);
91
+ this.node.removeEventListener('lm-drop', this, true);
92
+ this.node.removeEventListener('lm-dragleave', this, true);
93
+ super.onBeforeDetach(msg);
94
+ }
95
+ /**
96
+ * Handle drag over events
97
+ */
98
+ _handleDrag(event) {
99
+ const inputContainer = this.node.querySelector(`.${INPUT_CONTAINER_CLASS}`);
100
+ const target = event.target;
101
+ const isOverInput = (inputContainer === null || inputContainer === void 0 ? void 0 : inputContainer.contains(target)) || inputContainer === target;
102
+ if (!isOverInput) {
103
+ this._removeDragHoverClass();
104
+ return;
105
+ }
106
+ if (!this._canHandleDrop(event)) {
107
+ return;
108
+ }
109
+ event.preventDefault();
110
+ event.stopPropagation();
111
+ event.dropAction = 'move';
112
+ if (inputContainer &&
113
+ !inputContainer.classList.contains(DRAG_HOVER_CLASS)) {
114
+ inputContainer.classList.add(DRAG_HOVER_CLASS);
115
+ this._dragTarget = inputContainer;
116
+ }
117
+ }
118
+ /**
119
+ * Check if we can handle the drop
120
+ */
121
+ _canHandleDrop(event) {
122
+ const types = event.mimeData.types();
123
+ return (types.includes(NOTEBOOK_CELL_MIME) || types.includes(FILE_BROWSER_MIME));
124
+ }
125
+ /**
126
+ * Handle drop events
127
+ */
128
+ _handleDrop(event) {
129
+ if (!this._canHandleDrop(event)) {
130
+ return;
131
+ }
132
+ event.preventDefault();
133
+ event.stopPropagation();
134
+ event.dropAction = 'move';
135
+ this._removeDragHoverClass();
136
+ try {
137
+ if (event.mimeData.hasData(NOTEBOOK_CELL_MIME)) {
138
+ this._processCellDrop(event);
139
+ }
140
+ else if (event.mimeData.hasData(FILE_BROWSER_MIME)) {
141
+ this._processFileDrop(event);
142
+ }
143
+ }
144
+ catch (error) {
145
+ console.error('Error processing drop:', error);
146
+ }
147
+ }
148
+ /**
149
+ * Process dropped files
150
+ */
151
+ _processFileDrop(event) {
152
+ var _a, _b, _c;
153
+ const data = event.mimeData.getData(FILE_BROWSER_MIME);
154
+ if (!((_a = data === null || data === void 0 ? void 0 : data.model) === null || _a === void 0 ? void 0 : _a.path)) {
155
+ console.warn('Invalid file browser data in drop event');
156
+ return;
157
+ }
158
+ const attachment = {
159
+ type: 'file',
160
+ value: data.model.path,
161
+ mimetype: data.model.mimetype
162
+ };
163
+ (_c = (_b = this.model.input).addAttachment) === null || _c === void 0 ? void 0 : _c.call(_b, attachment);
164
+ }
165
+ /**
166
+ * Process dropped cells
167
+ */
168
+ _processCellDrop(event) {
169
+ var _a, _b, _c;
170
+ try {
171
+ const cellData = event.mimeData.getData(NOTEBOOK_CELL_MIME);
172
+ // Cells might come as array or single object
173
+ const cells = Array.isArray(cellData) ? cellData : [cellData];
174
+ // 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
175
+ if (!((_a = cells[0]) === null || _a === void 0 ? void 0 : _a.id)) {
176
+ console.warn('No valid cells to process');
177
+ return;
178
+ }
179
+ const notebookPath = this._findNotebookPath(String(cells[0].id));
180
+ if (!notebookPath) {
181
+ console.warn(`Cannot find notebook for dragged cells from ${cells[0].id}`);
182
+ return;
183
+ }
184
+ const validCells = [];
185
+ for (const cell of cells) {
186
+ if (!cell.id) {
187
+ console.warn('Dropped cell missing required ID, skipping');
188
+ continue;
189
+ }
190
+ // Use type guards to validate cell type
191
+ let cellType;
192
+ if (isCode(cell)) {
193
+ cellType = 'code';
194
+ }
195
+ else if (isMarkdown(cell)) {
196
+ cellType = 'markdown';
197
+ }
198
+ else if (isRaw(cell)) {
199
+ cellType = 'raw';
200
+ }
201
+ else {
202
+ console.warn(`Unknown cell type: ${cell.cell_type}, skipping`);
203
+ continue;
204
+ }
205
+ const notebookCell = {
206
+ id: cell.id,
207
+ input_type: cellType
208
+ };
209
+ validCells.push(notebookCell);
210
+ }
211
+ // Create single attachment with all cells from the notebook
212
+ if (validCells.length) {
213
+ const attachment = {
214
+ type: 'notebook',
215
+ value: notebookPath,
216
+ cells: validCells
217
+ };
218
+ (_c = (_b = this.model.input).addAttachment) === null || _c === void 0 ? void 0 : _c.call(_b, attachment);
219
+ }
220
+ }
221
+ catch (error) {
222
+ console.error('Failed to process cell drop: ', error);
223
+ }
224
+ }
225
+ /**
226
+ * Find the notebook path for a cell by searching through active and open notebooks
227
+ */
228
+ _findNotebookPath(cellId) {
229
+ if (!this.model.input.activeCellManager) {
230
+ return null;
231
+ }
232
+ const activeCellManager = this.model.input
233
+ .activeCellManager;
234
+ const notebookTracker = activeCellManager._notebookTracker;
235
+ if (!notebookTracker) {
236
+ return null;
237
+ }
238
+ if (notebookTracker.currentWidget) {
239
+ const currentNotebook = notebookTracker.currentWidget;
240
+ const cells = currentNotebook.content.widgets;
241
+ const cellWidget = cells.find((c) => c.model.id === cellId);
242
+ if (cellWidget) {
243
+ return currentNotebook.context.path;
244
+ }
245
+ }
246
+ // If not in current notebook, check all open notebooks
247
+ const widgets = notebookTracker.widgets || [];
248
+ for (const notebook of widgets) {
249
+ const cells = notebook.content.widgets;
250
+ const cellWidget = cells.find((c) => c.model.id === cellId);
251
+ if (cellWidget) {
252
+ return notebook.context.path;
253
+ }
254
+ }
255
+ return null;
256
+ }
257
+ /**
258
+ * Remove drag hover class
259
+ */
260
+ _removeDragHoverClass() {
261
+ if (this._dragTarget) {
262
+ this._dragTarget.classList.remove(DRAG_HOVER_CLASS);
263
+ this._dragTarget = null;
264
+ }
265
+ }
35
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "A package that provides UI components that can be used to create a chat in a Jupyterlab extension.",
5
5
  "keywords": [
6
6
  "jupyter",