@jupyter/chat 0.15.0 → 0.17.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 })));
@@ -20,10 +20,31 @@ export class InputModel {
20
20
  * Add attachment to send with next message.
21
21
  */
22
22
  this.addAttachment = (attachment) => {
23
- 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);
24
26
  if (duplicateAttachment) {
25
27
  return;
26
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
+ }
27
48
  this._attachments.push(attachment);
28
49
  this._attachmentsChanged.emit([...this._attachments]);
29
50
  };
@@ -31,7 +52,9 @@ export class InputModel {
31
52
  * Remove attachment to be sent.
32
53
  */
33
54
  this.removeAttachment = (attachment) => {
34
- 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);
35
58
  if (attachmentIndex === -1) {
36
59
  return;
37
60
  }
package/lib/model.d.ts CHANGED
@@ -419,6 +419,10 @@ export interface IChatContext {
419
419
  * A list of all users who have connected to this chat.
420
420
  */
421
421
  readonly users: IUser[];
422
+ /**
423
+ * Current user connected with the chat panel
424
+ */
425
+ readonly user: IUser | undefined;
422
426
  }
423
427
  /**
424
428
  * An abstract base class implementing `IChatContext`. This can be extended into
@@ -430,6 +434,7 @@ export declare abstract class AbstractChatContext implements IChatContext {
430
434
  });
431
435
  get name(): string;
432
436
  get messages(): IChatMessage[];
437
+ get user(): IUser | undefined;
433
438
  /**
434
439
  * ABSTRACT: Should return a list of users who have connected to this chat.
435
440
  */
package/lib/model.js CHANGED
@@ -435,4 +435,8 @@ export class AbstractChatContext {
435
435
  get messages() {
436
436
  return [...this._model.messages];
437
437
  }
438
+ get user() {
439
+ var _a;
440
+ return (_a = this._model) === null || _a === void 0 ? void 0 : _a.user;
441
+ }
438
442
  }
package/lib/types.d.ts CHANGED
@@ -9,14 +9,14 @@ 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. This is computed via the
13
- * following procedure:
12
+ * The string to use to mention a user in the chat. This should be computed
13
+ * via the following procedure:
14
14
  *
15
15
  * 1. Let `mention_name = user.display_name || user.name || user.username`.
16
16
  *
17
17
  * 2. Replace each ' ' character with '-' in `mention_name`.
18
18
  */
19
- mention_name: string;
19
+ mention_name?: string;
20
20
  /**
21
21
  * Boolean identifying if user is a bot.
22
22
  */
@@ -77,21 +77,81 @@ export interface INewMessage {
77
77
  id?: string;
78
78
  }
79
79
  /**
80
- * 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.
81
88
  */
82
- export interface IAttachment {
89
+ export type IAttachment = IFileAttachment | INotebookAttachment;
90
+ export interface IFileAttachment {
91
+ type: 'file';
83
92
  /**
84
- * The type of the attachment (basically 'file', 'variable', 'image')
93
+ * The path to the file, relative to `ContentsManager.root_dir`.
85
94
  */
86
- 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';
87
120
  /**
88
- * 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`.
89
135
  */
90
136
  value: string;
91
137
  /**
92
- * The mimetype of the attachment, optional.
138
+ * (optional) A list of cells in the notebook.
93
139
  */
94
- 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;
95
155
  }
96
156
  /**
97
157
  * An empty interface to describe optional settings that could be fetched from server.
@@ -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.15.0",
3
+ "version": "0.17.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",
@@ -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
- * The attachments props.
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 {...props} attachment={attachment} />
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.value}
100
+ {getAttachmentDisplayName(props.attachment)}
70
101
  </span>
71
102
  {props.onRemove && (
72
103
  <TooltippedButton
@@ -47,7 +47,7 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element {
47
47
  sx={{
48
48
  paddingLeft: 4,
49
49
  paddingRight: 4,
50
- paddingTop: 1,
50
+ paddingTop: 0,
51
51
  paddingBottom: 0,
52
52
  borderTop: '1px solid var(--jp-border-color1)'
53
53
  }}
@@ -9,7 +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';
12
+ import { IAttachment, INotebookAttachment, IUser } from './types';
13
13
 
14
14
  /**
15
15
  * The chat input interface.
@@ -318,13 +318,46 @@ export class InputModel implements IInputModel {
318
318
  * Add attachment to send with next message.
319
319
  */
320
320
  addAttachment = (attachment: IAttachment): void => {
321
+ // Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
322
+ const attachmentJson = JSON.stringify(attachment);
321
323
  const duplicateAttachment = this._attachments.find(
322
- att => att.type === attachment.type && att.value === attachment.value
324
+ att => JSON.stringify(att) === attachmentJson
323
325
  );
324
326
  if (duplicateAttachment) {
325
327
  return;
326
328
  }
327
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
+
328
361
  this._attachments.push(attachment);
329
362
  this._attachmentsChanged.emit([...this._attachments]);
330
363
  };
@@ -333,8 +366,10 @@ export class InputModel implements IInputModel {
333
366
  * Remove attachment to be sent.
334
367
  */
335
368
  removeAttachment = (attachment: IAttachment): void => {
369
+ // Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
370
+ const attachmentJson = JSON.stringify(attachment);
336
371
  const attachmentIndex = this._attachments.findIndex(
337
- att => att.type === attachment.type && att.value === attachment.value
372
+ att => JSON.stringify(att) === attachmentJson
338
373
  );
339
374
  if (attachmentIndex === -1) {
340
375
  return;
package/src/model.ts CHANGED
@@ -784,6 +784,10 @@ export interface IChatContext {
784
784
  * A list of all users who have connected to this chat.
785
785
  */
786
786
  readonly users: IUser[];
787
+ /**
788
+ * Current user connected with the chat panel
789
+ */
790
+ readonly user: IUser | undefined;
787
791
  }
788
792
 
789
793
  /**
@@ -803,6 +807,10 @@ export abstract class AbstractChatContext implements IChatContext {
803
807
  return [...this._model.messages];
804
808
  }
805
809
 
810
+ get user(): IUser | undefined {
811
+ return this._model?.user;
812
+ }
813
+
806
814
  /**
807
815
  * ABSTRACT: Should return a list of users who have connected to this chat.
808
816
  */
package/src/types.ts CHANGED
@@ -14,14 +14,14 @@ 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. This is computed via the
18
- * following procedure:
17
+ * The string to use to mention a user in the chat. This should be computed
18
+ * via the following procedure:
19
19
  *
20
20
  * 1. Let `mention_name = user.display_name || user.name || user.username`.
21
21
  *
22
22
  * 2. Replace each ' ' character with '-' in `mention_name`.
23
23
  */
24
- mention_name: string;
24
+ mention_name?: string;
25
25
  /**
26
26
  * Boolean identifying if user is a bot.
27
27
  */
@@ -87,21 +87,85 @@ export interface INewMessage {
87
87
  }
88
88
 
89
89
  /**
90
- * The attachment interface.
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.
91
98
  */
92
- export interface IAttachment {
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;
93
128
  /**
94
- * The type of the attachment (basically 'file', 'variable', 'image')
129
+ * The type of the cell.
95
130
  */
96
- type: string;
131
+ input_type: 'raw' | 'markdown' | 'code';
97
132
  /**
98
- * The value, i.e. the file path, the variable name or image content.
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`.
99
148
  */
100
149
  value: string;
101
150
  /**
102
- * The mimetype of the attachment, optional.
151
+ * (optional) A list of cells in the notebook.
103
152
  */
104
- mimetype?: string;
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;
105
169
  }
106
170
 
107
171
  /**
@@ -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
@@ -19,7 +19,6 @@
19
19
  }
20
20
 
21
21
  /*
22
- *
23
22
  * Selectors must be nested in `.jp-ThemedContainer` to have a higher
24
23
  * specificity than selectors in rules provided by JupyterLab.
25
24
  *
@@ -147,7 +146,6 @@
147
146
  }
148
147
 
149
148
  /* Keyframe animations */
150
-
151
149
  @keyframes jp-chat-typing-bounce {
152
150
  0%,
153
151
  80%,
@@ -201,6 +199,14 @@
201
199
 
202
200
  .jp-chat-attachments {
203
201
  display: flex;
202
+ gap: 4px;
203
+ flex-wrap: wrap;
204
+ min-height: 1.5em;
205
+ padding: 8px 0;
206
+ }
207
+
208
+ .jp-chat-attachments:empty {
209
+ padding: 8px 0;
204
210
  min-height: 1.5em;
205
211
  }
206
212
 
@@ -211,6 +217,7 @@
211
217
  padding: 0 0.3em;
212
218
  align-content: center;
213
219
  background-color: var(--jp-border-color3);
220
+ flex-shrink: 0;
214
221
  }
215
222
 
216
223
  .jp-chat-attachment .jp-chat-attachment-clickable:hover {
package/style/input.css CHANGED
@@ -30,3 +30,45 @@
30
30
  .jp-chat-input-toolbar .jp-chat-send-include-opener {
31
31
  padding: 4px 0;
32
32
  }
33
+
34
+ .jp-chat-input-container {
35
+ position: relative;
36
+ transition: all 150ms ease;
37
+ display: flex;
38
+ flex-direction: column;
39
+ justify-content: center;
40
+ min-height: 56px;
41
+ padding: 8px 0;
42
+ }
43
+
44
+ /* stylelint-disable-next-line selector-class-pattern */
45
+ .jp-chat-input-container .MuiTextField-root {
46
+ padding-bottom: 8px;
47
+ }
48
+
49
+ .jp-chat-input-container.jp-chat-drag-hover::after {
50
+ content: '';
51
+ position: absolute;
52
+ inset: 0;
53
+ background: rgb(33 150 243 / 10%);
54
+ border: 2px dashed var(--jp-brand-color1);
55
+ border-radius: 4px;
56
+ pointer-events: none;
57
+ z-index: 1;
58
+ }
59
+
60
+ .jp-chat-input-container.jp-chat-drag-hover::before {
61
+ content: 'Drop files or cells here';
62
+ position: absolute;
63
+ top: -24px;
64
+ left: 50%;
65
+ transform: translateX(-50%);
66
+ color: var(--jp-brand-color1);
67
+ font-size: 12px;
68
+ font-weight: 500;
69
+ pointer-events: none;
70
+ background: var(--jp-layout-color0);
71
+ padding: 2px 8px;
72
+ border-radius: 3px;
73
+ white-space: nowrap;
74
+ }