@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.
@@ -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
  }}
@@ -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(input);
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
- setOpen(!!commandCompletions.length);
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) => command.name,
148
+ getOptionLabel: (command: ChatCommand | string) =>
149
+ typeof command === 'string' ? '' : command.name,
142
150
  renderOption: (
143
151
  defaultProps,
144
152
  command: ChatCommand,
@@ -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 => att.type === attachment.type && att.value === attachment.value
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 => att.type === attachment.type && att.value === attachment.value
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 (start > 0 && !WHITESPACE.has(input[start - 1])) {
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
- while (end < n && !WHITESPACE.has(input[end])) {
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 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.
86
98
  */
87
- 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;
88
128
  /**
89
- * The type of the attachment (basically 'file', 'variable', 'image')
129
+ * The type of the cell.
90
130
  */
91
- type: string;
131
+ input_type: 'raw' | 'markdown' | 'code';
92
132
  /**
93
- * 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`.
94
148
  */
95
149
  value: string;
96
150
  /**
97
- * The mimetype of the attachment, optional.
151
+ * (optional) A list of cells in the notebook.
98
152
  */
99
- 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;
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 regex = new RegExp(user.mention_name, 'g');
64
- const mention = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
65
- return content.replace(regex, mention);
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 span = `<span class="${MENTION_CLASS}">${user.mention_name}</span>`;
79
- const regex = new RegExp(span, 'g');
80
- return content.replace(regex, user.mention_name);
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 {