Skip to content

Commit

Permalink
Send with selection (#82)
Browse files Browse the repository at this point in the history
* Rename addMessage to sendMessage in the chat model, for concistency

* Add the selection watcher object, and modify the send button to allow adding selection

* Add the selection watcher to the collaborative chat (work only with the side panel widget)

* Add a selection watcher to the websocket chat

* Automatic application of license header

* Change the state of include button on signal

* Hide the 'include selection' menu when editing a message or if the tools are not available

* Invert the logic to hide the 'include selection' menu

* Adopt the same button style for the cancel button

* Automatic application of license header

* Handle the selected text if the chat is a main area widget

* Add the ability to replace the current selection on visible editor

* Fixes ui-tests

* lint

* Fix the selection watcher for a new notebook file

* Add tests

* update snapshots

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
brichet and github-actions[bot] authored Sep 23, 2024
1 parent ca4e37b commit b73f5a8
Show file tree
Hide file tree
Showing 33 changed files with 1,097 additions and 112 deletions.
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

0 comments on commit b73f5a8

Please sign in to comment.