Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Send with selection #82

Merged
merged 17 commits into from
Sep 23, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ A model is provided by the package, and already includes all the required method
interact with the UI part of the chat.

The extension has to provide a class extending the `@jupyter/chat` model, implementing
at least the `addMessage()` method.
at least the `sendMessage()` method.

This method is called when a user sends a message using the input of the chat. It should
contain the code that will dispatch the message through the messaging technology.
Expand All @@ -55,7 +55,7 @@ the message list.
import { ChatModel, IChatMessage, INewMessage } from '@jupyter/chat';

class MyModel extends ChatModel {
addMessage(
sendMessage(
newMessage: INewMessage
): Promise<boolean | void> | boolean | void {
console.log(`New Message:\n${newMessage.body}`);
Expand Down Expand Up @@ -88,7 +88,7 @@ When a user sends a message, it is logged in the console and added to the messag
```{tip}
In this example, no messages are sent to other potential users.

An exchange system must be included and use the `addMessage()` and `messageAdded()`
An exchange system must be included and use the `sendMessage()` and `messageAdded()`
methods to correctly manage message transmission and reception.
```

Expand All @@ -107,7 +107,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import { UUID } from '@lumino/coreutils';

class MyModel extends ChatModel {
addMessage(
sendMessage(
newMessage: INewMessage
): Promise<boolean | void> | boolean | void {
console.log(`New Message:\n${newMessage.body}`);
Expand Down
1 change: 1 addition & 0 deletions packages/jupyter-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@jupyter/react-components": "^0.15.2",
"@jupyterlab/application": "^4.2.0",
"@jupyterlab/apputils": "^4.3.0",
"@jupyterlab/fileeditor": "^4.2.0",
"@jupyterlab/notebook": "^4.2.0",
"@jupyterlab/rendermime": "^4.2.0",
"@jupyterlab/ui-components": "^4.2.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/jupyter-chat/src/active-cell-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ type CellWithErrorContent = {
};
};

/**
* The active cell interface.
*/
export interface IActiveCellManager {
/**
* Whether the notebook is available and an active cell exists.
Expand Down
71 changes: 41 additions & 30 deletions packages/jupyter-chat/src/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,25 @@ import React, { useEffect, useRef, useState } from 'react';
import {
Autocomplete,
Box,
IconButton,
InputAdornment,
SxProps,
TextField,
Theme
} from '@mui/material';
import { Send, Cancel } from '@mui/icons-material';
import clsx from 'clsx';

import { CancelButton } from './input/cancel-button';
import { SendButton } from './input/send-button';
import { IChatModel } from '../model';
import { IAutocompletionRegistry } from '../registry';
import {
AutocompleteCommand,
IAutocompletionCommandsProps,
IConfig
IConfig,
Selection
} from '../types';

const INPUT_BOX_CLASS = 'jp-chat-input-container';
const SEND_BUTTON_CLASS = 'jp-chat-send-button';
const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button';

export function ChatInput(props: ChatInput.IProps): JSX.Element {
const { autocompletionName, autocompletionRegistry, model } = props;
Expand All @@ -36,6 +36,13 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
model.config.sendWithShiftEnter ?? false
);

// Display the include selection menu if it is not explicitly hidden, and if at least
// one of the tool to check for text or cell selection is enabled.
let hideIncludeSelection = props.hideIncludeSelection ?? false;
if (model.activeCellManager === null && model.selectionWatcher === null) {
hideIncludeSelection = true;
}

// store reference to the input element to enable focusing it easily
const inputRef = useRef<HTMLInputElement>();

Expand Down Expand Up @@ -138,10 +145,21 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {

/**
* Triggered when sending the message.
*
* Add code block if cell or text is selected.
*/
function onSend() {
function onSend(selection?: Selection) {
let content = input;
if (selection) {
content += `

\`\`\`
${selection.source}
\`\`\`
`;
}
props.onSend(content);
setInput('');
props.onSend(input);
}

/**
Expand Down Expand Up @@ -203,30 +221,19 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
endAdornment: (
<InputAdornment position="end">
{props.onCancel && (
<IconButton
size="small"
color="primary"
onClick={onCancel}
title={'Cancel edition'}
className={clsx(CANCEL_BUTTON_CLASS)}
>
<Cancel />
</IconButton>
<CancelButton
inputExists={input.length > 0}
onCancel={onCancel}
/>
)}
<IconButton
size="small"
color="primary"
onClick={onSend}
disabled={
props.onCancel
? input === props.value
: !input.trim().length
}
title={`Send message ${sendWithShiftEnter ? '(SHIFT+ENTER)' : '(ENTER)'}`}
className={clsx(SEND_BUTTON_CLASS)}
>
<Send />
</IconButton>
<SendButton
model={model}
sendWithShiftEnter={sendWithShiftEnter}
inputExists={input.length > 0}
onSend={onSend}
hideIncludeSelection={hideIncludeSelection}
hasButtonOnLeft={!!props.onCancel}
/>
</InputAdornment>
)
}}
Expand Down Expand Up @@ -294,6 +301,10 @@ export namespace ChatInput {
* The function to be called to cancel editing.
*/
onCancel?: () => unknown;
/**
* Whether to allow or not including selection.
*/
hideIncludeSelection?: boolean;
/**
* Custom mui/material styles.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/jupyter-chat/src/components/chat-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element {
onSend={(input: string) => updateMessage(message.id, input)}
onCancel={() => cancelEdition()}
model={model}
hideIncludeSelection={true}
/>
) : (
<RendermimeMarkdown
Expand Down
2 changes: 1 addition & 1 deletion packages/jupyter-chat/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element {
// handled by the listeners registered in the effect hooks above.
const onSend = async (input: string) => {
// send message to backend
model.addMessage({ body: input });
model.sendMessage({ body: input });
};

return (
Expand Down
72 changes: 55 additions & 17 deletions packages/jupyter-chat/src/components/code-blocks/code-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TooltippedIconButton } from '../mui-extras/tooltipped-icon-button';
import { IActiveCellManager } from '../../active-cell-manager';
import { replaceCellIcon } from '../../icons';
import { IChatModel } from '../../model';
import { ISelectionWatcher } from '../../selection-watcher';

const CODE_TOOLBAR_CLASS = 'jp-chat-code-toolbar';
const CODE_TOOLBAR_ITEM_CLASS = 'jp-chat-code-toolbar-item';
Expand All @@ -34,25 +35,41 @@ export function CodeToolbar(props: CodeToolbarProps): JSX.Element {
);

const activeCellManager = model.activeCellManager;
const selectionWatcher = model.selectionWatcher;

const [toolbarBtnProps, setToolbarBtnProps] = useState<ToolbarButtonProps>({
content: content,
activeCellManager: activeCellManager,
activeCellAvailable: activeCellManager?.available ?? false
content,
activeCellManager,
selectionWatcher,
activeCellAvailable: !!activeCellManager?.available,
selectionExists: !!selectionWatcher?.selection
});

useEffect(() => {
activeCellManager?.availabilityChanged.connect(() => {
const toggleToolbar = () => {
setToolbarEnable(model.config.enableCodeToolbar ?? true);
};

const selectionStatusChange = () => {
setToolbarBtnProps({
content,
activeCellManager: activeCellManager,
activeCellAvailable: activeCellManager.available
activeCellManager,
selectionWatcher,
activeCellAvailable: !!activeCellManager?.available,
selectionExists: !!selectionWatcher?.selection
});
});

model.configChanged.connect((_, config) => {
setToolbarEnable(config.enableCodeToolbar ?? true);
});
};

activeCellManager?.availabilityChanged.connect(selectionStatusChange);
selectionWatcher?.selectionChanged.connect(selectionStatusChange);
model.configChanged.connect(toggleToolbar);

selectionStatusChange();
return () => {
activeCellManager?.availabilityChanged.disconnect(selectionStatusChange);
selectionWatcher?.selectionChanged.disconnect(selectionStatusChange);
model.configChanged.disconnect(toggleToolbar);
};
}, [model]);

return activeCellManager === null || !toolbarEnable ? (
Expand Down Expand Up @@ -86,8 +103,10 @@ export function CodeToolbar(props: CodeToolbarProps): JSX.Element {

type ToolbarButtonProps = {
content: string;
activeCellAvailable?: boolean;
activeCellManager: IActiveCellManager | null;
activeCellAvailable?: boolean;
selectionWatcher: ISelectionWatcher | null;
selectionExists?: boolean;
className?: string;
};

Expand Down Expand Up @@ -126,16 +145,35 @@ function InsertBelowButton(props: ToolbarButtonProps) {
}

function ReplaceButton(props: ToolbarButtonProps) {
const tooltip = props.activeCellAvailable
? 'Replace active cell'
: 'Replace active cell (no active cell)';
const tooltip = props.selectionExists
? `Replace selection (${props.selectionWatcher?.selection?.numLines} line(s))`
: props.activeCellAvailable
? 'Replace selection (active cell)'
: 'Replace selection (no selection)';

const disabled = !props.activeCellAvailable && !props.selectionExists;

const replace = () => {
if (props.selectionExists) {
const selection = props.selectionWatcher?.selection;
if (!selection) {
return;
}
props.selectionWatcher?.replaceSelection({
...selection,
text: props.content
});
} else if (props.activeCellAvailable) {
props.activeCellManager?.replace(props.content);
}
};

return (
<TooltippedIconButton
className={props.className}
tooltip={tooltip}
disabled={!props.activeCellAvailable}
onClick={() => props.activeCellManager?.replace(props.content)}
disabled={disabled}
onClick={replace}
>
<replaceCellIcon.react height="16px" width="16px" />
</TooltippedIconButton>
Expand Down
47 changes: 47 additions & 0 deletions packages/jupyter-chat/src/components/input/cancel-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright (c) Jupyter Development Team.
* Distributed under the terms of the Modified BSD License.
*/

import CancelIcon from '@mui/icons-material/Cancel';
import React from 'react';
import { TooltippedButton } from '../mui-extras/tooltipped-button';

const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button';

/**
* The cancel button props.
*/
export type CancelButtonProps = {
inputExists: boolean;
onCancel: () => void;
};

/**
* The cancel button.
*/
export function CancelButton(props: CancelButtonProps): JSX.Element {
const tooltip = 'Cancel edition';
const disabled = !props.inputExists;
return (
<TooltippedButton
onClick={props.onCancel}
disabled={disabled}
tooltip={tooltip}
buttonProps={{
size: 'small',
variant: 'contained',
title: tooltip,
className: CANCEL_BUTTON_CLASS
}}
sx={{
minWidth: 'unset',
padding: '4px',
borderRadius: '2px 0px 0px 2px',
marginRight: '1px'
}}
>
<CancelIcon />
</TooltippedButton>
);
}
Loading
Loading