Skip to content

Commit 76ca878

Browse files
committed
dragndrop cells, files into chat as attachments
1 parent 1e80ce3 commit 76ca878

File tree

3 files changed

+280
-4
lines changed

3 files changed

+280
-4
lines changed

packages/jupyter-chat/src/components/attachments.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
* Distributed under the terms of the Modified BSD License.
44
*/
55

6-
// import { IDocumentManager } from '@jupyterlab/docmanager';
76
import CloseIcon from '@mui/icons-material/Close';
87
import { Box } from '@mui/material';
98
import React, { useContext } from 'react';
@@ -18,8 +17,30 @@ const ATTACHMENT_CLICKABLE_CLASS = 'jp-chat-attachment-clickable';
1817
const REMOVE_BUTTON_CLASS = 'jp-chat-attachment-remove';
1918

2019
/**
21-
* The attachments props.
20+
* Generate a user-friendly display name for an attachment
2221
*/
22+
function getAttachmentDisplayName(attachment: IAttachment): string {
23+
if (attachment.type === 'cell') {
24+
// Extract notebook filename with extension
25+
const notebook =
26+
attachment.notebookPath.split('/').pop() ||
27+
attachment.notebookPath ||
28+
'Unknown notebook';
29+
30+
return `${notebook}: ${attachment.cellType} cell`;
31+
}
32+
33+
if (attachment.type === 'file') {
34+
// Extract filename with extension
35+
const fileName =
36+
attachment.value.split('/').pop() || attachment.value || 'Unknown file';
37+
38+
return fileName;
39+
}
40+
41+
return (attachment as any).value || 'Unknown attachment';
42+
}
43+
2344
export type AttachmentsProps = {
2445
attachments: IAttachment[];
2546
onRemove?: (attachment: IAttachment) => void;
@@ -32,7 +53,11 @@ export function AttachmentPreviewList(props: AttachmentsProps): JSX.Element {
3253
return (
3354
<Box className={ATTACHMENTS_CLASS}>
3455
{props.attachments.map(attachment => (
35-
<AttachmentPreview {...props} attachment={attachment} />
56+
<AttachmentPreview
57+
key={`${attachment.type}-${attachment.value}`}
58+
{...props}
59+
attachment={attachment}
60+
/>
3661
))}
3762
</Box>
3863
);
@@ -66,7 +91,7 @@ export function AttachmentPreview(props: AttachmentProps): JSX.Element {
6691
)
6792
}
6893
>
69-
{props.attachment.value}
94+
{getAttachmentDisplayName(props.attachment)}
7095
</span>
7196
{props.onRemove && (
7297
<TooltippedButton

packages/jupyter-chat/src/widgets/chat-widget.tsx

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@
44
*/
55

66
import { ReactWidget } from '@jupyterlab/apputils';
7+
import { Cell } from '@jupyterlab/cells';
78
import React from 'react';
9+
import { Message } from '@lumino/messaging';
10+
import { IDragEvent } from '@lumino/dragdrop';
811

912
import { Chat, IInputToolbarRegistry } from '../components';
1013
import { chatIcon } from '../icons';
1114
import { IChatModel } from '../model';
15+
import { IFileAttachment, ICellAttachment } from '../types';
16+
import { ActiveCellManager } from '../active-cell-manager';
17+
18+
// MIME type constant for file browser drag events
19+
const FILE_BROWSER_MIME = 'application/x-jupyter-icontentsrich';
20+
21+
// MIME type constant for Notebook cell drag events
22+
const NOTEBOOK_CELL_MIME = 'application/vnd.jupyter.cells';
1223

1324
export class ChatWidget extends ReactWidget {
1425
constructor(options: Chat.IOptions) {
@@ -42,5 +53,215 @@ export class ChatWidget extends ReactWidget {
4253
return <Chat {...this._chatOptions} model={this._chatOptions.model} />;
4354
}
4455

56+
/**
57+
* Handle DOM events for drag and drop
58+
*/
59+
handleEvent(event: Event): void {
60+
switch (event.type) {
61+
case 'lm-dragenter':
62+
event.preventDefault();
63+
event.stopPropagation();
64+
break;
65+
case 'lm-dragover':
66+
this._handleDrag(event as IDragEvent);
67+
break;
68+
case 'lm-drop':
69+
this._handleDrop(event as IDragEvent);
70+
break;
71+
case 'lm-dragleave': {
72+
// Remove hover class on leaving the widget
73+
const targetElement = (event as DragEvent).relatedTarget;
74+
if (!targetElement || !this.node.contains(targetElement as Node)) {
75+
this._removeDragHoverClass();
76+
}
77+
break;
78+
}
79+
}
80+
}
81+
82+
/**
83+
* A message handler invoked on an `'after-attach'` message.
84+
*/
85+
protected onAfterAttach(msg: Message): void {
86+
super.onAfterAttach(msg);
87+
this.node.addEventListener('lm-dragover', this, true);
88+
this.node.addEventListener('lm-dragenter', this, true);
89+
this.node.addEventListener('lm-drop', this, true);
90+
this.node.addEventListener('lm-dragleave', this, true);
91+
}
92+
93+
/**
94+
* A message handler invoked on a `'before-detach'` message.
95+
*/
96+
protected onBeforeDetach(msg: Message): void {
97+
this.node.removeEventListener('lm-dragover', this, true);
98+
this.node.removeEventListener('lm-dragenter', this, true);
99+
this.node.removeEventListener('lm-drop', this, true);
100+
this.node.removeEventListener('lm-dragleave', this, true);
101+
super.onBeforeDetach(msg);
102+
}
103+
104+
/**
105+
* Handle drag over events
106+
*/
107+
private _handleDrag(event: IDragEvent): void {
108+
const inputContainer = this.node.querySelector('.jp-chat-input-container');
109+
const target = event.target as HTMLElement;
110+
const isOverInput =
111+
inputContainer?.contains(target) || inputContainer === target;
112+
113+
if (!isOverInput) {
114+
this._removeDragHoverClass();
115+
return;
116+
}
117+
118+
if (!this._canHandleDrop(event)) {
119+
return;
120+
}
121+
122+
event.preventDefault();
123+
event.stopPropagation();
124+
event.dropAction = 'move';
125+
126+
if (
127+
inputContainer &&
128+
!inputContainer.classList.contains('jp-chat-drag-hover')
129+
) {
130+
inputContainer.classList.add('jp-chat-drag-hover');
131+
this._dragTarget = inputContainer as HTMLElement;
132+
}
133+
}
134+
135+
/**
136+
* Check if we can handle the drop
137+
*/
138+
private _canHandleDrop(event: IDragEvent): boolean {
139+
const types = event.mimeData.types();
140+
return (
141+
types.includes(NOTEBOOK_CELL_MIME) || types.includes(FILE_BROWSER_MIME)
142+
);
143+
}
144+
145+
/**
146+
* Handle drop events
147+
*/
148+
private _handleDrop(event: IDragEvent): void {
149+
if (!this._canHandleDrop(event)) {
150+
return;
151+
}
152+
153+
event.preventDefault();
154+
event.stopPropagation();
155+
event.dropAction = 'move';
156+
157+
this._removeDragHoverClass();
158+
159+
try {
160+
if (event.mimeData.hasData(NOTEBOOK_CELL_MIME)) {
161+
this._processCellDrop(event);
162+
} else if (event.mimeData.hasData(FILE_BROWSER_MIME)) {
163+
this._processFileDrop(event);
164+
}
165+
} catch (error) {
166+
console.error('Error processing drop:', error);
167+
}
168+
}
169+
170+
/**
171+
* Process dropped files
172+
*/
173+
private _processFileDrop(event: IDragEvent): void {
174+
const data = event.mimeData.getData(FILE_BROWSER_MIME) as any;
175+
176+
if (data?.model?.path) {
177+
const attachment: IFileAttachment = {
178+
type: 'file',
179+
value: data.model.path,
180+
mimeType: data.model.mimetype || undefined
181+
};
182+
this.model.input.addAttachment?.(attachment);
183+
}
184+
}
185+
186+
/**
187+
* Process dropped cells
188+
*/
189+
private _processCellDrop(event: IDragEvent): void {
190+
const cellData = event.mimeData.getData(NOTEBOOK_CELL_MIME) as any;
191+
192+
// Cells might come as array or single object
193+
const cells = Array.isArray(cellData) ? cellData : [cellData];
194+
195+
for (const cell of cells) {
196+
if (cell?.id) {
197+
const cellInfo = this._findNotebookAndCellInfo(cell);
198+
199+
if (cellInfo) {
200+
const attachment: ICellAttachment = {
201+
type: 'cell',
202+
value: cell.id,
203+
cellType: cell.cell_type || 'code',
204+
notebookPath: cellInfo.notebookPath
205+
};
206+
this.model.input.addAttachment?.(attachment);
207+
}
208+
}
209+
}
210+
}
211+
212+
/**
213+
* Find the notebook path for a cell by searching through active and open notebooks
214+
*/
215+
private _findNotebookAndCellInfo(
216+
cell: Cell
217+
): { notebookPath: string } | null {
218+
if (this.model.input.activeCellManager) {
219+
const activeCellManager = this.model.input
220+
.activeCellManager as ActiveCellManager;
221+
const notebookTracker = (activeCellManager as any)._notebookTracker;
222+
223+
if (notebookTracker?.currentWidget) {
224+
const currentNotebook = notebookTracker.currentWidget;
225+
const cells = currentNotebook.content.widgets;
226+
const cellWidget = cells.find((c: Cell) => c.model.id === cell.id);
227+
228+
if (cellWidget) {
229+
return {
230+
notebookPath: currentNotebook.context.path
231+
};
232+
}
233+
}
234+
235+
// If not in current notebook, check all open notebooks
236+
if (notebookTracker) {
237+
const widgets = notebookTracker.widgets || [];
238+
for (const notebook of widgets) {
239+
const cells = notebook.content.widgets;
240+
const cellWidget = cells.find((c: Cell) => c.model.id === cell.id);
241+
242+
if (cellWidget) {
243+
return {
244+
notebookPath: notebook.context.path
245+
};
246+
}
247+
}
248+
}
249+
}
250+
251+
console.warn('Could not find notebook path for cell:', cell.id);
252+
return null;
253+
}
254+
255+
/**
256+
* Remove drag hover class
257+
*/
258+
private _removeDragHoverClass(): void {
259+
if (this._dragTarget) {
260+
this._dragTarget.classList.remove('jp-chat-drag-hover');
261+
this._dragTarget = null;
262+
}
263+
}
264+
45265
private _chatOptions: Chat.IOptions;
266+
private _dragTarget: HTMLElement | null = null;
46267
}

packages/jupyter-chat/style/input.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,33 @@
3030
.jp-chat-input-toolbar .jp-chat-send-include-opener {
3131
padding: 4px 0;
3232
}
33+
34+
/* Drag and drop styles */
35+
.jp-chat-input-container {
36+
position: relative;
37+
transition: all 150ms ease;
38+
}
39+
40+
.jp-chat-input-container.jp-chat-drag-hover {
41+
background: rgba(33, 150, 243, 0.1);
42+
border: 2px dashed var(--jp-brand-color1);
43+
border-radius: 4px;
44+
padding: 8px;
45+
}
46+
47+
/* Visual indicator for drop zone */
48+
.jp-chat-input-container.jp-chat-drag-hover::before {
49+
content: 'Drop files or cells here';
50+
position: absolute;
51+
top: -24px;
52+
left: 50%;
53+
transform: translateX(-50%);
54+
color: var(--jp-brand-color1);
55+
font-size: 12px;
56+
font-weight: 500;
57+
pointer-events: none;
58+
background: var(--jp-layout-color0);
59+
padding: 2px 8px;
60+
border-radius: 3px;
61+
white-space: nowrap;
62+
}

0 commit comments

Comments
 (0)