Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions packages/jupyter-chat/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,20 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element {
messageFooterRegistry={props.messageFooterRegistry}
welcomeMessage={props.welcomeMessage}
/>
<ChatInput
sx={{
paddingLeft: 4,
paddingRight: 4,
paddingTop: 0,
paddingBottom: 0,
borderTop: '1px solid var(--jp-border-color1)'
}}
model={model.input}
chatCommandRegistry={props.chatCommandRegistry}
toolbarRegistry={inputToolbarRegistry}
/>
<div className="jp-chat-input-container" data-input-id={model.input.id}>
<ChatInput
sx={{
paddingLeft: 4,
paddingRight: 4,
paddingTop: 0,
paddingBottom: 0,
borderTop: '1px solid var(--jp-border-color1)'
}}
model={model.input}
chatCommandRegistry={props.chatCommandRegistry}
toolbarRegistry={inputToolbarRegistry}
/>
</div>
</AttachmentOpenerContext.Provider>
);
}
Expand Down
17 changes: 11 additions & 6 deletions packages/jupyter-chat/src/components/messages/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,17 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
) : (
<div ref={ref} data-index={props.index}>
{edit && canEdit && model.getEditionModel(message.id) ? (
<ChatInput
onCancel={() => cancelEdition()}
model={model.getEditionModel(message.id)!}
chatCommandRegistry={props.chatCommandRegistry}
toolbarRegistry={props.inputToolbarRegistry}
/>
<div
className="jp-chat-input-container"
data-input-id={model.getEditionModel(message.id)!.id}
>
<ChatInput
onCancel={() => cancelEdition()}
model={model.getEditionModel(message.id)!}
chatCommandRegistry={props.chatCommandRegistry}
toolbarRegistry={props.inputToolbarRegistry}
/>
</div>
) : (
<MessageRenderer
rmRegistry={rmRegistry}
Expand Down
49 changes: 49 additions & 0 deletions packages/jupyter-chat/src/input-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ export interface IInputModel extends IDisposable {
*/
clearAttachments(): void;

/**
* Unique identifier for the input (needed for drag-and-drop).
*/
id: string;

/**
* A signal emitting when the attachment list has changed.
*/
Expand Down Expand Up @@ -146,6 +151,16 @@ export interface IInputModel extends IDisposable {
*/
clearMentions(): void;

/**
* Register the input model (for drag-and-drop support).
*/
registerInput(inputModel: IInputModel): IInputModel;

/**
* Get the input model by id.
*/
getInput(id: string): IInputModel | undefined;

/**
* A signal emitting when disposing of the model.
*/
Expand All @@ -157,6 +172,7 @@ export interface IInputModel extends IDisposable {
*/
export class InputModel implements IInputModel {
constructor(options: InputModel.IOptions) {
this.id = options.id ?? `input-${Private.generateUniqueId()}`;
this._onSend = options.onSend;
this._chatContext = options.chatContext;
this._value = options.value || '';
Expand Down Expand Up @@ -196,6 +212,11 @@ export class InputModel implements IInputModel {
*/
cancel: (() => void) | undefined;

/**
* Unique identifier for the input (needed for drag-and-drop).
*/
public id: string;

/**
* The entire input value.
*/
Expand Down Expand Up @@ -446,6 +467,21 @@ export class InputModel implements IInputModel {
this._mentions = [];
};

/**
* Register the input model.
*/
registerInput(inputModel: IInputModel): IInputModel {
this._inputMap.set(inputModel.id, inputModel);
inputModel.onDisposed.connect(() => {
this._inputMap.delete(inputModel.id);
});
return inputModel;
}

getInput(id: string): IInputModel | undefined {
return this._inputMap.get(id);
}

/**
* Dispose the input model.
*/
Expand Down Expand Up @@ -482,6 +518,7 @@ export class InputModel implements IInputModel {
private _selectionWatcher: ISelectionWatcher | null;
private _documentManager: IDocumentManager | null;
private _config: InputModel.IConfig;
private _inputMap: Map<string, IInputModel> = new Map();
private _valueChanged = new Signal<IInputModel, string>(this);
private _cursorIndexChanged = new Signal<IInputModel, number | null>(this);
private _currentWordChanged = new Signal<IInputModel, string | null>(this);
Expand Down Expand Up @@ -532,6 +569,12 @@ export namespace InputModel {
*/
cursorIndex?: number;

/**
* Optional unique identifier for this input model.
* If not provided, one will be generated automatically.
*/
id?: string;

/**
* The configuration for the input component.
*/
Expand Down Expand Up @@ -612,6 +655,12 @@ namespace Private {
return [start, end];
}

let _counter = 0;
export function generateUniqueId(): string {
_counter += 1;
return `${_counter}`;
}

/**
* Gets the current (space-separated) word around the user's cursor. The current
* word is used to generate a list of matching chat commands.
Expand Down
54 changes: 42 additions & 12 deletions packages/jupyter-chat/src/widgets/chat-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
INotebookAttachmentCell
} from '../types';
import { ActiveCellManager } from '../active-cell-manager';
import { IInputModel } from '../input-model';

// MIME type constant for file browser drag events
const FILE_BROWSER_MIME = 'application/x-jupyter-icontentsrich';
Expand Down Expand Up @@ -121,12 +122,19 @@ export class ChatWidget extends ReactWidget {
* Handle drag over events
*/
private _handleDrag(event: Drag.Event): void {
const inputContainer = this.node.querySelector(`.${INPUT_CONTAINER_CLASS}`);
const inputContainers = this.node.querySelectorAll<HTMLElement>(
`.${INPUT_CONTAINER_CLASS}`
);
const target = event.target as HTMLElement;
const isOverInput =
inputContainer?.contains(target) || inputContainer === target;
let overInput: HTMLElement | null = null;
for (const container of inputContainers) {
if (container.contains(target)) {
overInput = container;
break;
}
}

if (!isOverInput) {
if (!overInput) {
this._removeDragHoverClass();
return;
}
Expand All @@ -139,12 +147,9 @@ export class ChatWidget extends ReactWidget {
event.stopPropagation();
event.dropAction = 'move';

if (
inputContainer &&
!inputContainer.classList.contains(DRAG_HOVER_CLASS)
) {
inputContainer.classList.add(DRAG_HOVER_CLASS);
this._dragTarget = inputContainer as HTMLElement;
if (!overInput.classList.contains(DRAG_HOVER_CLASS)) {
overInput.classList.add(DRAG_HOVER_CLASS);
this._dragTarget = overInput;
}
}

Expand Down Expand Up @@ -183,6 +188,29 @@ export class ChatWidget extends ReactWidget {
}
}

/**
* Get the input model associated with the event target and input ids.
*/
private _getInputFromEvent(event: Drag.Event): IInputModel | null {
let element = event.target as HTMLElement | null;

while (element) {
if (
element.classList.contains(INPUT_CONTAINER_CLASS) &&
element.dataset.inputId
) {
const inputId = element.dataset.inputId;
const inputModel =
this.model.input.getInput(inputId) ??
(inputId === this.model.input.id ? this.model.input : null);
return inputModel;
}
element = element.parentElement;
}

return null;
}

/**
* Process dropped files
*/
Expand All @@ -201,7 +229,8 @@ export class ChatWidget extends ReactWidget {
value: data.model.path,
mimetype: data.model.mimetype
};
this.model.input.addAttachment?.(attachment);
const inputModel = this._getInputFromEvent(event);
inputModel?.addAttachment?.(attachment);
}

/**
Expand Down Expand Up @@ -263,7 +292,8 @@ export class ChatWidget extends ReactWidget {
value: notebookPath,
cells: validCells
};
this.model.input.addAttachment?.(attachment);
const inputModel = this._getInputFromEvent(event);
inputModel?.addAttachment?.(attachment);
}
} catch (error) {
console.error('Failed to process cell drop: ', error);
Expand Down
5 changes: 5 additions & 0 deletions packages/jupyterlab-chat/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ export class LabChatModel
this.onInputChanged(value, edition.id);
};

edition.model.valueChanged.connect((_, value) => {
this.onInputChanged(value, edition.id);
});

this.input.registerInput(edition.model);
edition.model.valueChanged.connect(_onInputChanged);
}
};
Expand Down
Loading