From b73f5a887be0b8070177daaf5800c7e9ef34e127 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet <32258950+brichet@users.noreply.github.com> Date: Mon, 23 Sep 2024 18:59:40 +0200 Subject: [PATCH] Send with selection (#82) * 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] --- .../extension-providing-chat.md | 8 +- packages/jupyter-chat/package.json | 1 + .../jupyter-chat/src/active-cell-manager.ts | 3 + .../src/components/chat-input.tsx | 71 +++--- .../src/components/chat-messages.tsx | 1 + packages/jupyter-chat/src/components/chat.tsx | 2 +- .../components/code-blocks/code-toolbar.tsx | 72 ++++-- .../src/components/input/cancel-button.tsx | 47 ++++ .../src/components/input/send-button.tsx | 210 +++++++++++++++++ .../mui-extras/tooltipped-button.tsx | 92 ++++++++ packages/jupyter-chat/src/icons.ts | 6 + packages/jupyter-chat/src/index.ts | 1 + packages/jupyter-chat/src/model.ts | 48 +++- .../jupyter-chat/src/selection-watcher.ts | 221 ++++++++++++++++++ packages/jupyter-chat/src/types.ts | 21 ++ packages/jupyter-chat/src/utils.ts | 47 ++++ .../style/icons/include-selection.svg | 5 + .../src/factory.ts | 8 +- .../src/model.ts | 2 +- .../src/token.ts | 16 +- .../src/index.ts | 46 +++- .../ui-tests/playwright.config.js | 5 + .../ui-tests/tests/code-toolbar.spec.ts | 84 ++++--- .../jupyterlab_collaborative_chat.spec.ts | 113 ++++++++- .../launcher-tile-linux.png | Bin 1484 -> 1467 bytes .../navigation-bottom-linux.png | Bin 501 -> 549 bytes .../navigation-bottom-unread-linux.png | Bin 1117 -> 1189 bytes .../not-stacked-messages-linux.png | Bin 6188 -> 6380 bytes .../stacked-messages-linux.png | Bin 4441 -> 4571 bytes .../ui-tests/tests/test-utils.ts | 23 +- .../src/handlers/websocket-handler.ts | 2 +- python/jupyterlab-ws-chat/src/index.ts | 9 +- yarn.lock | 45 ++++ 33 files changed, 1097 insertions(+), 112 deletions(-) create mode 100644 packages/jupyter-chat/src/components/input/cancel-button.tsx create mode 100644 packages/jupyter-chat/src/components/input/send-button.tsx create mode 100644 packages/jupyter-chat/src/components/mui-extras/tooltipped-button.tsx create mode 100644 packages/jupyter-chat/src/selection-watcher.ts create mode 100644 packages/jupyter-chat/src/utils.ts create mode 100644 packages/jupyter-chat/style/icons/include-selection.svg diff --git a/docs/source/developers/developing_extensions/extension-providing-chat.md b/docs/source/developers/developing_extensions/extension-providing-chat.md index 328a329..0084e85 100644 --- a/docs/source/developers/developing_extensions/extension-providing-chat.md +++ b/docs/source/developers/developing_extensions/extension-providing-chat.md @@ -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. @@ -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 { console.log(`New Message:\n${newMessage.body}`); @@ -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. ``` @@ -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 { console.log(`New Message:\n${newMessage.body}`); diff --git a/packages/jupyter-chat/package.json b/packages/jupyter-chat/package.json index d25feda..5046a10 100644 --- a/packages/jupyter-chat/package.json +++ b/packages/jupyter-chat/package.json @@ -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", diff --git a/packages/jupyter-chat/src/active-cell-manager.ts b/packages/jupyter-chat/src/active-cell-manager.ts index 0aac237..281d019 100644 --- a/packages/jupyter-chat/src/active-cell-manager.ts +++ b/packages/jupyter-chat/src/active-cell-manager.ts @@ -25,6 +25,9 @@ type CellWithErrorContent = { }; }; +/** + * The active cell interface. + */ export interface IActiveCellManager { /** * Whether the notebook is available and an active cell exists. diff --git a/packages/jupyter-chat/src/components/chat-input.tsx b/packages/jupyter-chat/src/components/chat-input.tsx index b56dcd2..929899c 100644 --- a/packages/jupyter-chat/src/components/chat-input.tsx +++ b/packages/jupyter-chat/src/components/chat-input.tsx @@ -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; @@ -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(); @@ -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); } /** @@ -203,30 +221,19 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { endAdornment: ( {props.onCancel && ( - - - + 0} + onCancel={onCancel} + /> )} - - - + 0} + onSend={onSend} + hideIncludeSelection={hideIncludeSelection} + hasButtonOnLeft={!!props.onCancel} + /> ) }} @@ -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. */ diff --git a/packages/jupyter-chat/src/components/chat-messages.tsx b/packages/jupyter-chat/src/components/chat-messages.tsx index d96603b..a706268 100644 --- a/packages/jupyter-chat/src/components/chat-messages.tsx +++ b/packages/jupyter-chat/src/components/chat-messages.tsx @@ -393,6 +393,7 @@ export function ChatMessage(props: ChatMessageProps): JSX.Element { onSend={(input: string) => updateMessage(message.id, input)} onCancel={() => cancelEdition()} model={model} + hideIncludeSelection={true} /> ) : ( { // send message to backend - model.addMessage({ body: input }); + model.sendMessage({ body: input }); }; return ( diff --git a/packages/jupyter-chat/src/components/code-blocks/code-toolbar.tsx b/packages/jupyter-chat/src/components/code-blocks/code-toolbar.tsx index 0553e02..bbb0e21 100644 --- a/packages/jupyter-chat/src/components/code-blocks/code-toolbar.tsx +++ b/packages/jupyter-chat/src/components/code-blocks/code-toolbar.tsx @@ -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'; @@ -34,25 +35,41 @@ export function CodeToolbar(props: CodeToolbarProps): JSX.Element { ); const activeCellManager = model.activeCellManager; + const selectionWatcher = model.selectionWatcher; const [toolbarBtnProps, setToolbarBtnProps] = useState({ - 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 ? ( @@ -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; }; @@ -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 ( props.activeCellManager?.replace(props.content)} + disabled={disabled} + onClick={replace} > diff --git a/packages/jupyter-chat/src/components/input/cancel-button.tsx b/packages/jupyter-chat/src/components/input/cancel-button.tsx new file mode 100644 index 0000000..f38dee6 --- /dev/null +++ b/packages/jupyter-chat/src/components/input/cancel-button.tsx @@ -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 ( + + + + ); +} diff --git a/packages/jupyter-chat/src/components/input/send-button.tsx b/packages/jupyter-chat/src/components/input/send-button.tsx new file mode 100644 index 0000000..b1f3bdc --- /dev/null +++ b/packages/jupyter-chat/src/components/input/send-button.tsx @@ -0,0 +1,210 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; +import SendIcon from '@mui/icons-material/Send'; +import { Box, Menu, MenuItem, Typography } from '@mui/material'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { IChatModel } from '../../model'; +import { TooltippedButton } from '../mui-extras/tooltipped-button'; +import { includeSelectionIcon } from '../../icons'; +import { Selection } from '../../types'; + +const SEND_BUTTON_CLASS = 'jp-chat-send-button'; +const SEND_INCLUDE_OPENER_CLASS = 'jp-chat-send-include-opener'; +const SEND_INCLUDE_LI_CLASS = 'jp-chat-send-include'; + +/** + * The send button props. + */ +export type SendButtonProps = { + model: IChatModel; + sendWithShiftEnter: boolean; + inputExists: boolean; + onSend: (selection?: Selection) => unknown; + hideIncludeSelection?: boolean; + hasButtonOnLeft?: boolean; +}; + +/** + * The send button, with optional 'include selection' menu. + */ +export function SendButton(props: SendButtonProps): JSX.Element { + const { activeCellManager, selectionWatcher } = props.model; + const hideIncludeSelection = props.hideIncludeSelection ?? false; + const hasButtonOnLeft = props.hasButtonOnLeft ?? false; + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + + const openMenu = useCallback((el: HTMLElement | null) => { + setMenuAnchorEl(el); + setMenuOpen(true); + }, []); + + const closeMenu = useCallback(() => { + setMenuOpen(false); + }, []); + + const disabled = !props.inputExists; + + const [selectionTooltip, setSelectionTooltip] = useState(''); + const [disableInclude, setDisableInclude] = useState(true); + + useEffect(() => { + /** + * Enable or disable the include selection button, and adapt the tooltip. + */ + const toggleIncludeState = () => { + setDisableInclude( + !(selectionWatcher?.selection || activeCellManager?.available) + ); + const tooltip = selectionWatcher?.selection + ? `${selectionWatcher.selection.numLines} line(s) selected` + : activeCellManager?.available + ? 'Code from 1 active cell' + : 'No selection or active cell'; + setSelectionTooltip(tooltip); + }; + + if (!hideIncludeSelection) { + selectionWatcher?.selectionChanged.connect(toggleIncludeState); + activeCellManager?.availabilityChanged.connect(toggleIncludeState); + toggleIncludeState(); + } + return () => { + selectionWatcher?.selectionChanged.disconnect(toggleIncludeState); + activeCellManager?.availabilityChanged.disconnect(toggleIncludeState); + }; + }, [activeCellManager, selectionWatcher, hideIncludeSelection]); + + const defaultTooltip = props.sendWithShiftEnter + ? 'Send message (SHIFT+ENTER)' + : 'Send message (ENTER)'; + const tooltip = defaultTooltip; + + function sendWithSelection() { + // Append the selected text if exists. + if (selectionWatcher?.selection) { + props.onSend({ + type: 'text', + source: selectionWatcher.selection.text + }); + closeMenu(); + return; + } + + // Append the active cell content if exists. + if (activeCellManager?.available) { + props.onSend({ + type: 'cell', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + source: activeCellManager.getContent(false)!.source + }); + closeMenu(); + return; + } + } + + return ( + + props.onSend()} + disabled={disabled} + tooltip={tooltip} + buttonProps={{ + size: 'small', + title: defaultTooltip, + variant: 'contained', + className: SEND_BUTTON_CLASS + }} + sx={{ + minWidth: 'unset', + borderTopLeftRadius: hasButtonOnLeft ? '0px' : '2px', + borderTopRightRadius: hideIncludeSelection ? '2px' : '0px', + borderBottomRightRadius: hideIncludeSelection ? '2px' : '0px', + borderBottomLeftRadius: hasButtonOnLeft ? '0px' : '2px' + }} + > + + + {!hideIncludeSelection && ( + <> + { + openMenu(e.currentTarget); + }} + disabled={disabled} + tooltip="" + buttonProps={{ + variant: 'contained', + onKeyDown: e => { + if (e.key !== 'Enter' && e.key !== ' ') { + return; + } + openMenu(e.currentTarget); + // stopping propagation of this event prevents the prompt from being + // sent when the dropdown button is selected and clicked via 'Enter'. + e.stopPropagation(); + }, + className: SEND_INCLUDE_OPENER_CLASS + }} + sx={{ + minWidth: 'unset', + padding: '4px 0px', + borderRadius: '0px 2px 2px 0px', + marginLeft: '1px' + }} + > + + + + { + sendWithSelection(); + // prevent sending second message with no selection + e.stopPropagation(); + }} + disabled={disableInclude} + className={SEND_INCLUDE_LI_CLASS} + > + + + + Send message with selection + + + {selectionTooltip} + + + + + + )} + + ); +} diff --git a/packages/jupyter-chat/src/components/mui-extras/tooltipped-button.tsx b/packages/jupyter-chat/src/components/mui-extras/tooltipped-button.tsx new file mode 100644 index 0000000..018f9a1 --- /dev/null +++ b/packages/jupyter-chat/src/components/mui-extras/tooltipped-button.tsx @@ -0,0 +1,92 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import React from 'react'; +import { Button, ButtonProps, SxProps, TooltipProps } from '@mui/material'; + +import { ContrastingTooltip } from './contrasting-tooltip'; + +export type TooltippedButtonProps = { + onClick: React.MouseEventHandler; + tooltip: string; + children: JSX.Element; + disabled?: boolean; + placement?: TooltipProps['placement']; + /** + * The offset of the tooltip popup. + * + * The expected syntax is defined by the Popper library: + * https://popper.js.org/docs/v2/modifiers/offset/ + */ + offset?: [number, number]; + 'aria-label'?: string; + /** + * Props passed directly to the MUI `Button` component. + */ + buttonProps?: ButtonProps; + /** + * Styles applied to the MUI `Button` component. + */ + sx?: SxProps; +}; + +/** + * A component that renders an MUI `Button` with a high-contrast tooltip + * provided by `ContrastingTooltip`. This component differs from the MUI + * defaults in the following ways: + * + * - Shows the tooltip on hover even if disabled. + * - Renders the tooltip above the button by default. + * - Renders the tooltip closer to the button by default. + * - Lowers the opacity of the Button when disabled. + * - Renders the Button with `line-height: 0` to avoid showing extra + * vertical space in SVG icons. + * + * NOTE TO DEVS: Please keep this component's features synchronized with + * features available to `TooltippedIconButton`. + */ +export function TooltippedButton(props: TooltippedButtonProps): JSX.Element { + return ( + + {/* + By default, tooltips never appear when the Button is disabled. The + official way to support this feature in MUI is to wrap the child Button + element in a `span` element. + + See: https://mui.com/material-ui/react-tooltip/#disabled-elements + */} + + + + + ); +} diff --git a/packages/jupyter-chat/src/icons.ts b/packages/jupyter-chat/src/icons.ts index e0dc6f8..0c8bb1a 100644 --- a/packages/jupyter-chat/src/icons.ts +++ b/packages/jupyter-chat/src/icons.ts @@ -8,6 +8,7 @@ import { LabIcon } from '@jupyterlab/ui-components'; import chatSvgStr from '../style/icons/chat.svg'; +import includeSelectionIconStr from '../style/icons/include-selection.svg'; import readSvgStr from '../style/icons/read.svg'; import replaceCellSvg from '../style/icons/replace-cell.svg'; @@ -25,3 +26,8 @@ export const replaceCellIcon = new LabIcon({ name: 'jupyter-ai::replace-cell', svgstr: replaceCellSvg }); + +export const includeSelectionIcon = new LabIcon({ + name: 'jupyter-chat::include', + svgstr: includeSelectionIconStr +}); diff --git a/packages/jupyter-chat/src/index.ts b/packages/jupyter-chat/src/index.ts index d741b88..9c14e49 100644 --- a/packages/jupyter-chat/src/index.ts +++ b/packages/jupyter-chat/src/index.ts @@ -8,6 +8,7 @@ export * from './model'; export * from './registry'; export * from './types'; export * from './active-cell-manager'; +export * from './selection-watcher'; export * from './widgets/chat-error'; export * from './widgets/chat-sidebar'; export * from './widgets/chat-widget'; diff --git a/packages/jupyter-chat/src/model.ts b/packages/jupyter-chat/src/model.ts index e94810a..8a2fca1 100644 --- a/packages/jupyter-chat/src/model.ts +++ b/packages/jupyter-chat/src/model.ts @@ -15,6 +15,7 @@ import { IUser } from './types'; import { IActiveCellManager } from './active-cell-manager'; +import { ISelectionWatcher } from './selection-watcher'; /** * The chat model interface. @@ -55,6 +56,11 @@ export interface IChatModel extends IDisposable { */ readonly activeCellManager: IActiveCellManager | null; + /** + * Get the selection watcher. + */ + readonly selectionWatcher: ISelectionWatcher | null; + /** * A signal emitting when the messages list is updated. */ @@ -87,7 +93,7 @@ export interface IChatModel extends IDisposable { * @param message - the message to send. * @returns whether the message has been sent or not, or nothing if not needed. */ - addMessage(message: INewMessage): Promise | boolean | void; + sendMessage(message: INewMessage): Promise | boolean | void; /** * Optional, to update a message from the chat panel. @@ -169,18 +175,10 @@ export class ChatModel implements IChatModel { this._commands = options.commands; this._activeCellManager = options.activeCellManager ?? null; - } - /** - * The chat messages list. - */ - get messages(): IChatMessage[] { - return this._messages; + this._selectionWatcher = options.selectionWatcher ?? null; } - get activeCellManager(): IActiveCellManager | null { - return this._activeCellManager; - } /** * The chat model id. */ @@ -201,6 +199,26 @@ export class ChatModel implements IChatModel { this._name = value; } + /** + * The chat messages list. + */ + get messages(): IChatMessage[] { + return this._messages; + } + /** + * Get the active cell manager. + */ + get activeCellManager(): IActiveCellManager | null { + return this._activeCellManager; + } + + /** + * Get the selection watcher. + */ + get selectionWatcher(): ISelectionWatcher | null { + return this._selectionWatcher; + } + /** * Timestamp of the last read message in local storage. */ @@ -352,7 +370,7 @@ export class ChatModel implements IChatModel { * @param message - the message to send. * @returns whether the message has been sent or not. */ - addMessage(message: INewMessage): Promise | boolean | void {} + sendMessage(message: INewMessage): Promise | boolean | void {} /** * Dispose the chat model. @@ -517,6 +535,7 @@ export class ChatModel implements IChatModel { private _isDisposed = false; private _commands?: CommandRegistry; private _activeCellManager: IActiveCellManager | null; + private _selectionWatcher: ISelectionWatcher | null; private _notificationId: string | null = null; private _messagesUpdated = new Signal(this); private _configChanged = new Signal(this); @@ -544,8 +563,13 @@ export namespace ChatModel { commands?: CommandRegistry; /** - * Active cell manager + * Active cell manager. */ activeCellManager?: IActiveCellManager | null; + + /** + * Selection watcher. + */ + selectionWatcher?: ISelectionWatcher | null; } } diff --git a/packages/jupyter-chat/src/selection-watcher.ts b/packages/jupyter-chat/src/selection-watcher.ts new file mode 100644 index 0000000..b34068c --- /dev/null +++ b/packages/jupyter-chat/src/selection-watcher.ts @@ -0,0 +1,221 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { JupyterFrontEnd } from '@jupyterlab/application'; +import { DocumentWidget } from '@jupyterlab/docregistry'; +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { Notebook } from '@jupyterlab/notebook'; + +import { find } from '@lumino/algorithm'; +import { Widget } from '@lumino/widgets'; +import { ISignal, Signal } from '@lumino/signaling'; + +import { getCellIndex, getEditor } from './utils'; + +/** + * The selection watcher namespace. + */ +export namespace SelectionWatcher { + /** + * The constructor options. + */ + export interface IOptions { + /** + * The current shell of the application. + */ + shell: JupyterFrontEnd.IShell; + } + + /** + * The selection type. + */ + export type Selection = CodeEditor.ITextSelection & { + /** + * The text within the selection as a string. + */ + text: string; + /** + * Number of lines contained by the text selection. + */ + numLines: number; + /** + * The ID of the document widget in which the selection was made. + */ + widgetId: string; + /** + * The ID of the cell in which the selection was made, if the original widget + * was a notebook. + */ + cellId?: string; + }; +} + +/** + * The selection watcher interface. + */ +export interface ISelectionWatcher { + readonly selection: SelectionWatcher.Selection | null; + readonly selectionChanged: ISignal< + ISelectionWatcher, + SelectionWatcher.Selection | null + >; + replaceSelection(selection: SelectionWatcher.Selection): void; +} + +/** + * The selection watcher, read/write selected text in a DocumentWidget. + */ +export class SelectionWatcher { + constructor(options: SelectionWatcher.IOptions) { + this._shell = options.shell; + this._shell.currentChanged?.connect((sender, args) => { + // Do not change the main area widget if the new one has no editor, for example + // a chat panel. However, the selected text is only available if the main area + // widget is visible. (to avoid confusion in inclusion/replacement). + const widget = args.newValue; + + // if there is no main area widget, set it to null. + if (widget === null) { + this._mainAreaDocumentWidget = null; + return; + } + + const editor = getEditor(widget); + if ( + widget instanceof DocumentWidget && + (editor || widget.content instanceof Notebook) + ) { + // if the new widget is a DocumentWidget and has an editor, set it. + // NOTE: special case for notebook which do not has an active cell at that stage, + // and so the editor can't be retrieved too. + this._mainAreaDocumentWidget = widget; + } else if (this._mainAreaDocumentWidget?.isDisposed) { + // if the previous document widget has been closed, set it to null. + this._mainAreaDocumentWidget = null; + } + }); + + setInterval(this._poll.bind(this), 200); + } + + get selection(): SelectionWatcher.Selection | null { + return this._selection; + } + + get selectionChanged(): ISignal { + return this._selectionChanged; + } + + replaceSelection(selection: SelectionWatcher.Selection): void { + // unfortunately shell.currentWidget doesn't update synchronously after + // shell.activateById(), which is why we have to get a reference to the + // widget manually. + const widget = find( + this._shell.widgets(), + widget => widget.id === selection.widgetId + ); + // Do not allow replacement on non visible widget (to avoid confusion). + if (!widget?.isVisible || !(widget instanceof DocumentWidget)) { + return; + } + + // activate the widget if not already active + this._shell.activateById(selection.widgetId); + + // activate notebook cell if specified + if (widget.content instanceof Notebook && selection.cellId) { + const cellIndex = getCellIndex(widget.content, selection.cellId); + if (cellIndex !== -1) { + widget.content.activeCellIndex = cellIndex; + } + } + + // get editor instance + const editor = getEditor(widget); + if (!editor) { + return; + } + + editor.model.sharedModel.updateSource( + editor.getOffsetAt(selection.start), + editor.getOffsetAt(selection.end), + selection.text + ); + const newPosition = editor.getPositionAt( + editor.getOffsetAt(selection.start) + selection.text.length + ); + editor.setSelection({ start: newPosition, end: newPosition }); + } + + protected _poll(): void { + let currSelection: SelectionWatcher.Selection | null = null; + const prevSelection = this._selection; + // Do not return selected text if the main area widget is hidden. + if (this._mainAreaDocumentWidget?.isVisible) { + currSelection = getTextSelection(this._mainAreaDocumentWidget); + } + if (prevSelection?.text !== currSelection?.text) { + this._selection = currSelection; + this._selectionChanged.emit(currSelection); + } + } + + protected _shell: JupyterFrontEnd.IShell; + protected _mainAreaDocumentWidget: Widget | null = null; + protected _selection: SelectionWatcher.Selection | null = null; + protected _selectionChanged = new Signal< + this, + SelectionWatcher.Selection | null + >(this); +} + +/** + * Gets a Selection object from a document widget. Returns `null` if unable. + */ +function getTextSelection( + widget: Widget | null +): SelectionWatcher.Selection | null { + const editor = getEditor(widget); + // widget type check is redundant but hints the type to TypeScript + if (!editor || !(widget instanceof DocumentWidget)) { + return null; + } + + let cellId: string | undefined = undefined; + if (widget.content instanceof Notebook) { + cellId = widget.content.activeCell?.model.id; + } + + const selectionObj = editor.getSelection(); + let { start, end } = selectionObj; + const startOffset = editor.getOffsetAt(start); + const endOffset = editor.getOffsetAt(end); + const text = editor.model.sharedModel + .getSource() + .substring(startOffset, endOffset); + + // Do not return a Selection object if no text is selected + if (!text) { + return null; + } + + // ensure start <= end + // required for editor.model.sharedModel.updateSource() + if (startOffset > endOffset) { + [start, end] = [end, start]; + } + + return { + ...selectionObj, + start, + end, + text, + numLines: text.split('\n').length, + widgetId: widget.id, + ...(cellId && { + cellId + }) + }; +} diff --git a/packages/jupyter-chat/src/types.ts b/packages/jupyter-chat/src/types.ts index ff193c1..8c7e56c 100644 --- a/packages/jupyter-chat/src/types.ts +++ b/packages/jupyter-chat/src/types.ts @@ -83,6 +83,27 @@ export type AutocompleteCommand = { label: string; }; +/** + * Representation of a selected text. + */ +export type TextSelection = { + type: 'text'; + source: string; +}; + +/** + * Representation of a selected cell. + */ +export type CellSelection = { + type: 'cell'; + source: string; +}; + +/** + * Selection object (text or cell). + */ +export type Selection = TextSelection | CellSelection; + /** * The properties of the autocompletion. * diff --git a/packages/jupyter-chat/src/utils.ts b/packages/jupyter-chat/src/utils.ts new file mode 100644 index 0000000..e610a03 --- /dev/null +++ b/packages/jupyter-chat/src/utils.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { CodeMirrorEditor } from '@jupyterlab/codemirror'; +import { DocumentWidget } from '@jupyterlab/docregistry'; +import { FileEditor } from '@jupyterlab/fileeditor'; +import { Notebook } from '@jupyterlab/notebook'; +import { Widget } from '@lumino/widgets'; + +/** + * Gets the editor instance used by a document widget. Returns `null` if unable. + */ +export function getEditor( + widget: Widget | null +): CodeMirrorEditor | null | undefined { + if (!(widget instanceof DocumentWidget)) { + return null; + } + + let editor: CodeEditor.IEditor | null | undefined; + const { content } = widget; + + if (content instanceof FileEditor) { + editor = content.editor; + } else if (content instanceof Notebook) { + editor = content.activeCell?.editor; + } + + if (!(editor instanceof CodeMirrorEditor)) { + return undefined; + } + + return editor; +} + +/** + * Gets the index of the cell associated with `cellId`. + */ +export function getCellIndex(notebook: Notebook, cellId: string): number { + const idx = notebook.model?.sharedModel.cells.findIndex( + cell => cell.getId() === cellId + ); + return idx === undefined ? -1 : idx; +} diff --git a/packages/jupyter-chat/style/icons/include-selection.svg b/packages/jupyter-chat/style/icons/include-selection.svg new file mode 100644 index 0000000..665ece2 --- /dev/null +++ b/packages/jupyter-chat/style/icons/include-selection.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/jupyterlab-collaborative-chat/src/factory.ts b/packages/jupyterlab-collaborative-chat/src/factory.ts index 312e094..778914c 100644 --- a/packages/jupyterlab-collaborative-chat/src/factory.ts +++ b/packages/jupyterlab-collaborative-chat/src/factory.ts @@ -7,7 +7,8 @@ import { ChatWidget, IActiveCellManager, IAutocompletionRegistry, - IConfig + IConfig, + ISelectionWatcher } from '@jupyter/chat'; import { IThemeManager } from '@jupyterlab/apputils'; import { ABCWidgetFactory, DocumentRegistry } from '@jupyterlab/docregistry'; @@ -122,6 +123,7 @@ export class CollaborativeChatModelFactory this._widgetConfig = options.widgetConfig; this._commands = options.commands; this._activeCellManager = options.activeCellManager ?? null; + this._selectionWatcher = options.selectionWatcher ?? null; } collaborative = true; @@ -195,7 +197,8 @@ export class CollaborativeChatModelFactory user: this._user, widgetConfig: this._widgetConfig, commands: this._commands, - activeCellManager: this._activeCellManager + activeCellManager: this._activeCellManager, + selectionWatcher: this._selectionWatcher }); } @@ -204,4 +207,5 @@ export class CollaborativeChatModelFactory private _widgetConfig: IWidgetConfig; private _commands?: CommandRegistry; private _activeCellManager: IActiveCellManager | null; + private _selectionWatcher: ISelectionWatcher | null; } diff --git a/packages/jupyterlab-collaborative-chat/src/model.ts b/packages/jupyterlab-collaborative-chat/src/model.ts index a920a2c..6a9e42e 100644 --- a/packages/jupyterlab-collaborative-chat/src/model.ts +++ b/packages/jupyterlab-collaborative-chat/src/model.ts @@ -118,7 +118,7 @@ export class CollaborativeChatModel // nothing to do } - addMessage(message: INewMessage): Promise | boolean | void { + sendMessage(message: INewMessage): Promise | boolean | void { const msg: IYmessage = { type: 'msg', id: UUID.uuid4(), diff --git a/packages/jupyterlab-collaborative-chat/src/token.ts b/packages/jupyterlab-collaborative-chat/src/token.ts index 45b4afd..30c26c4 100644 --- a/packages/jupyterlab-collaborative-chat/src/token.ts +++ b/packages/jupyterlab-collaborative-chat/src/token.ts @@ -3,7 +3,12 @@ * Distributed under the terms of the Modified BSD License. */ -import { IConfig, chatIcon } from '@jupyter/chat'; +import { + IConfig, + chatIcon, + IActiveCellManager, + ISelectionWatcher +} from '@jupyter/chat'; import { IWidgetTracker } from '@jupyterlab/apputils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { Token } from '@lumino/coreutils'; @@ -101,6 +106,13 @@ export const IChatPanel = new Token( /** * The active cell manager plugin. */ -export const IActiveCellManagerToken = new Token( +export const IActiveCellManagerToken = new Token( 'jupyter-collaborative-chat:IActiveCellManager' ); + +/** + * The selection watcher plugin. + */ +export const ISelectionWatcherToken = new Token( + 'jupyter-collaborative-chat:ISelectionWatcher' +); diff --git a/python/jupyterlab-collaborative-chat/src/index.ts b/python/jupyterlab-collaborative-chat/src/index.ts index 6bc023d..33e8cce 100644 --- a/python/jupyterlab-collaborative-chat/src/index.ts +++ b/python/jupyterlab-collaborative-chat/src/index.ts @@ -8,6 +8,8 @@ import { AutocompletionRegistry, IActiveCellManager, IAutocompletionRegistry, + ISelectionWatcher, + SelectionWatcher, chatIcon, readIcon } from '@jupyter/chat'; @@ -51,7 +53,8 @@ import { IChatFactory, IChatPanel, WidgetConfig, - YChat + YChat, + ISelectionWatcherToken } from 'jupyterlab-collaborative-chat'; const FACTORY = 'Chat'; @@ -62,8 +65,9 @@ const pluginIds = { autocompletionRegistry: 'jupyterlab-collaborative-chat-extension:autocompletionRegistry', chatCommands: 'jupyterlab-collaborative-chat-extension:commands', + chatPanel: 'jupyterlab-collaborative-chat-extension:chat-panel', docFactories: 'jupyterlab-collaborative-chat-extension:factory', - chatPanel: 'jupyterlab-collaborative-chat-extension:chat-panel' + selectionWatcher: 'jupyterlab-collaborative-chat-extension:selectionWatcher' }; /** @@ -92,6 +96,7 @@ const docFactories: JupyterFrontEndPlugin = { IAutocompletionRegistry, ICollaborativeDrive, ILayoutRestorer, + ISelectionWatcherToken, ISettingRegistry, IThemeManager, IToolbarWidgetRegistry, @@ -105,6 +110,7 @@ const docFactories: JupyterFrontEndPlugin = { autocompletionRegistry: IAutocompletionRegistry, drive: ICollaborativeDrive | null, restorer: ILayoutRestorer | null, + selectionWatcher: ISelectionWatcher | null, settingRegistry: ISettingRegistry | null, themeManager: IThemeManager | null, toolbarRegistry: IToolbarWidgetRegistry | null, @@ -206,7 +212,8 @@ const docFactories: JupyterFrontEndPlugin = { user, widgetConfig, commands: app.commands, - activeCellManager + activeCellManager, + selectionWatcher }); app.docRegistry.addModelFactory(modelFactory); }) @@ -271,7 +278,13 @@ const chatCommands: JupyterFrontEndPlugin = { description: 'The commands to create or open a chat', autoStart: true, requires: [ICollaborativeDrive, IChatFactory], - optional: [IActiveCellManagerToken, IChatPanel, ICommandPalette, ILauncher], + optional: [ + IActiveCellManagerToken, + IChatPanel, + ICommandPalette, + ILauncher, + ISelectionWatcherToken + ], activate: ( app: JupyterFrontEnd, drive: ICollaborativeDrive, @@ -279,7 +292,8 @@ const chatCommands: JupyterFrontEndPlugin = { activeCellManager: IActiveCellManager | null, chatPanel: ChatPanel | null, commandPalette: ICommandPalette | null, - launcher: ILauncher | null + launcher: ILauncher | null, + selectionWatcher: ISelectionWatcher | null ) => { const { commands } = app; const { tracker, widgetConfig } = factory; @@ -481,7 +495,8 @@ const chatCommands: JupyterFrontEndPlugin = { sharedModel, widgetConfig, commands: app.commands, - activeCellManager + activeCellManager, + selectionWatcher }); /** @@ -651,10 +666,27 @@ const activeCellManager: JupyterFrontEndPlugin = { } }; +/** + * Extension providing the selection watcher. + */ +const selectionWatcher: JupyterFrontEndPlugin = { + id: pluginIds.selectionWatcher, + description: 'the selection watcher plugin', + autoStart: true, + requires: [], + provides: ISelectionWatcherToken, + activate: (app: JupyterFrontEnd): ISelectionWatcher => { + return new SelectionWatcher({ + shell: app.shell + }); + } +}; + export default [ activeCellManager, autocompletionPlugin, chatCommands, + chatPanel, docFactories, - chatPanel + selectionWatcher ]; diff --git a/python/jupyterlab-collaborative-chat/ui-tests/playwright.config.js b/python/jupyterlab-collaborative-chat/ui-tests/playwright.config.js index c657f8a..d22e0df 100644 --- a/python/jupyterlab-collaborative-chat/ui-tests/playwright.config.js +++ b/python/jupyterlab-collaborative-chat/ui-tests/playwright.config.js @@ -15,5 +15,10 @@ module.exports = { url: 'http://localhost:8888/lab', timeout: 120 * 1000, reuseExistingServer: !process.env.CI + }, + use: { + contextOptions: { + permissions: ['clipboard-read', 'clipboard-write'] + } } }; diff --git a/python/jupyterlab-collaborative-chat/ui-tests/tests/code-toolbar.spec.ts b/python/jupyterlab-collaborative-chat/ui-tests/tests/code-toolbar.spec.ts index 182acaa..bd11cc9 100644 --- a/python/jupyterlab-collaborative-chat/ui-tests/tests/code-toolbar.spec.ts +++ b/python/jupyterlab-collaborative-chat/ui-tests/tests/code-toolbar.spec.ts @@ -3,14 +3,9 @@ * Distributed under the terms of the Modified BSD License. */ -import { - expect, - galata, - IJupyterLabPageFixture, - test -} from '@jupyterlab/galata'; +import { expect, galata, test } from '@jupyterlab/galata'; -import { openChat, sendMessage, USER } from './test-utils'; +import { openChat, sendMessage, splitMainArea, USER } from './test-utils'; test.use({ mockUser: USER, @@ -26,20 +21,6 @@ const FILENAME = 'toolbar.chat'; const CONTENT = 'print("This is a code cell")'; const MESSAGE = `\`\`\`\n${CONTENT}\n\`\`\``; -async function splitMainArea(page: IJupyterLabPageFixture, name: string) { - // Emulate drag and drop - const viewerHandle = page.activity.getTabLocator(name); - const viewerBBox = await viewerHandle.boundingBox(); - - await page.mouse.move( - viewerBBox!.x + 0.5 * viewerBBox!.width, - viewerBBox!.y + 0.5 * viewerBBox!.height - ); - await page.mouse.down(); - await page.mouse.move(viewerBBox!.x + 0.5 * viewerBBox!.width, 600); - await page.mouse.up(); -} - test.describe('#codeToolbar', () => { test.beforeEach(async ({ page }) => { // Create a chat file @@ -161,19 +142,22 @@ test.describe('#codeToolbar', () => { expect(await page.notebook.getCellTextInput(1)).toBe(`${CONTENT}\n`); }); - test('replace active cell', async ({ page }) => { + test('replace active cell content', async ({ page }) => { const chatPanel = await openChat(page, FILENAME); const message = chatPanel.locator('.jp-chat-message'); - const toolbarButtons = message.locator('.jp-chat-code-toolbar-item button'); + const toolbarButtons = message.locator('.jp-chat-code-toolbar-item'); const notebook = await page.notebook.createNew(); + // write content in the first cell. + const cell = await page.notebook.getCellLocator(0); + await cell?.getByRole('textbox').pressSequentially('initial content'); await sendMessage(page, FILENAME, MESSAGE); await splitMainArea(page, notebook!); - // write content in the first cell. - const cell = await page.notebook.getCellLocator(0); - await cell?.getByRole('textbox').pressSequentially('initial content'); + await expect(toolbarButtons.nth(2)).toHaveAccessibleName( + 'Replace selection (active cell)' + ); await toolbarButtons.nth(2).click(); await page.activity.activateTab(notebook!); @@ -184,10 +168,52 @@ test.describe('#codeToolbar', () => { expect(await page.notebook.getCellTextInput(0)).toBe(`${CONTENT}\n`); }); + test('replace current selection', async ({ page }) => { + const cellContent = 'a = 1\nprint(f"a={a}")'; + const chatPanel = await openChat(page, FILENAME); + const message = chatPanel.locator('.jp-chat-message'); + const toolbarButtons = message.locator('.jp-chat-code-toolbar-item'); + + const notebook = await page.notebook.createNew(); + // write content in the first cell. + const cell = (await page.notebook.getCellLocator(0))!; + await cell.getByRole('textbox').pressSequentially(cellContent); + + // wait for code mirror to be ready. + await expect(cell.locator('.cm-line')).toHaveCount(2); + await expect( + cell.locator('.cm-line').nth(1).locator('.cm-builtin') + ).toBeAttached(); + + // select the 'print' statement in the second line. + const selection = cell + ?.locator('.cm-line') + .nth(1) + .locator('.cm-builtin') + .first(); + await selection.dblclick({ position: { x: 10, y: 10 } }); + + await sendMessage(page, FILENAME, MESSAGE); + await splitMainArea(page, notebook!); + + await expect(toolbarButtons.nth(2)).toHaveAccessibleName( + 'Replace selection (1 line(s))' + ); + await toolbarButtons.nth(2).click(); + + await page.activity.activateTab(notebook!); + await page.waitForCondition( + async () => (await page.notebook.getCellTextInput(0)) !== cellContent + ); + expect(await page.notebook.getCellTextInput(0)).toBe( + `a = 1\n${CONTENT}\n(f"a={a}")` + ); + }); + test('should copy code content', async ({ page }) => { const chatPanel = await openChat(page, FILENAME); const message = chatPanel.locator('.jp-chat-message'); - const toolbarButtons = message.locator('.jp-chat-code-toolbar-item button'); + const toolbarButtons = message.locator('.jp-chat-code-toolbar-item'); const notebook = await page.notebook.createNew(); @@ -196,6 +222,10 @@ test.describe('#codeToolbar', () => { // Copy the message code content to clipboard. await toolbarButtons.last().click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + `${CONTENT}\n` + ); + await page.activity.activateTab(notebook!); const cell = await page.notebook.getCellLocator(0); await cell?.getByRole('textbox').press('Control+V'); diff --git a/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts b/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts index 629fdcc..ff71929 100644 --- a/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts +++ b/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts @@ -13,7 +13,7 @@ import { Contents, User } from '@jupyterlab/services'; import { ReadonlyJSONObject, UUID } from '@lumino/coreutils'; import { Locator } from '@playwright/test'; -import { openChat, sendMessage, USER } from './test-utils'; +import { openChat, sendMessage, splitMainArea, USER } from './test-utils'; const FILENAME = 'my-chat.chat'; const MSG_CONTENT = 'Hello World!'; @@ -261,9 +261,9 @@ test.describe('#sendMessages', () => { const input = chatPanel .locator('.jp-chat-input-container') .getByRole('combobox'); - const sendButton = chatPanel - .locator('.jp-chat-input-container') - .getByRole('button'); + const sendButton = chatPanel.locator( + '.jp-chat-input-container .jp-chat-send-button' + ); await input.pressSequentially(MSG_CONTENT); await sendButton.click(); @@ -376,6 +376,111 @@ test.describe('#sendMessages', () => { messages.locator('.jp-chat-message .jp-chat-rendermime-markdown') ).toHaveText(MSG_CONTENT + '\n'); }); + + test('should disable send with selection when there is no notebook', async ({ + page + }) => { + const chatPanel = await openChat(page, FILENAME); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const openerButton = chatPanel.locator( + '.jp-chat-input-container .jp-chat-send-include-opener' + ); + const sendWithSelection = page.locator('.jp-chat-send-include'); + + await input.pressSequentially(MSG_CONTENT); + await openerButton.click(); + await expect(sendWithSelection).toBeVisible(); + await expect(sendWithSelection).toBeDisabled(); + await expect(sendWithSelection).toContainText( + 'No selection or active cell' + ); + }); + + test('should send with cell content', async ({ page }) => { + const cellContent = 'a = 1\nprint(f"a={a}")'; + const chatPanel = await openChat(page, FILENAME); + const messages = chatPanel.locator('.jp-chat-messages-container'); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const openerButton = chatPanel.locator( + '.jp-chat-input-container .jp-chat-send-include-opener' + ); + const sendWithSelection = page.locator('.jp-chat-send-include'); + + const notebook = await page.notebook.createNew(); + // write content in the first cell. + const cell = (await page.notebook.getCellLocator(0))!; + await cell.getByRole('textbox').pressSequentially(cellContent); + + await splitMainArea(page, notebook!); + + await input.pressSequentially(MSG_CONTENT); + await openerButton.click(); + await expect(sendWithSelection).toBeVisible(); + await expect(sendWithSelection).toBeEnabled(); + await expect(sendWithSelection).toContainText('Code from 1 active cell'); + await sendWithSelection.click(); + + await expect(messages!.locator('.jp-chat-message')).toHaveCount(1); + + // It seems that the markdown renderer adds a new line, but the '\n' inserter when + // pressing Enter above is trimmed. + await expect( + messages.locator('.jp-chat-message .jp-chat-rendermime-markdown') + ).toHaveText(`${MSG_CONTENT}\n${cellContent}\n`); + }); + + test('should send with text selection', async ({ page }) => { + const cellContent = 'a = 1\nprint(f"a={a}")'; + const chatPanel = await openChat(page, FILENAME); + const messages = chatPanel.locator('.jp-chat-messages-container'); + const input = chatPanel + .locator('.jp-chat-input-container') + .getByRole('combobox'); + const openerButton = chatPanel.locator( + '.jp-chat-input-container .jp-chat-send-include-opener' + ); + const sendWithSelection = page.locator('.jp-chat-send-include'); + + const notebook = await page.notebook.createNew(); + await splitMainArea(page, notebook!); + + // write content in the first cell. + const cell = (await page.notebook.getCellLocator(0))!; + await cell.getByRole('textbox').pressSequentially(cellContent); + + // wait for code mirror to be ready. + await expect(cell.locator('.cm-line')).toHaveCount(2); + await expect( + cell.locator('.cm-line').nth(1).locator('.cm-builtin') + ).toBeAttached(); + + // select the 'print' statement in the second line. + const selection = cell + ?.locator('.cm-line') + .nth(1) + .locator('.cm-builtin') + .first(); + await selection.dblclick({ position: { x: 10, y: 10 } }); + + await input.pressSequentially(MSG_CONTENT); + await openerButton.click(); + await expect(sendWithSelection).toBeVisible(); + await expect(sendWithSelection).toBeEnabled(); + await expect(sendWithSelection).toContainText('1 line(s) selected'); + await sendWithSelection.click(); + + await expect(messages!.locator('.jp-chat-message')).toHaveCount(1); + + // It seems that the markdown renderer adds a new line, but the '\n' inserter when + // pressing Enter above is trimmed. + await expect( + messages.locator('.jp-chat-message .jp-chat-rendermime-markdown') + ).toHaveText(`${MSG_CONTENT}\nprint\n`); + }); }); test.describe('#messagesNavigation', () => { diff --git a/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/launcher-tile-linux.png b/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/launcher-tile-linux.png index 2d99dff3dfbad05997689b58cce09c34bc8e3182..a08572111e2d88cdc4ab3e5b2d0d10011679d928 100644 GIT binary patch delta 1436 zcmZ{kc~H`67{}eU11vi{N?kWo3-hS0R!PeYuSzqU%*b^-G7TMQ9d8Z?f13fRBWWg@ zni{#1u6U%tnr7(8YKWz%;Gh(qh#Q(kB8cVw_L^ySX1{;E^UU+S^E}V@Gf)4P_AU8G zfK)#p&tTkxg)*6Ad!%J&rRqk;uh+WK$Fc&VTAT7Cp}u#WAFVlfO%*sk4eBFn^b4<% z%jh#+Cg%zg=%=k!Ei>4R9+grX;Vx5)Mq{I6PsCQ9?1;P)8++m1$~-%HOuPJWZf;Je zORtzSpRkKL`a?*_eJ+=~wzwEr8%!YqBwV|nN2fp_`1Fa0!?|KwBnZiOP$+Z~Uoq_} z9zXd0q{pdKU8RC0_wC}a(>pdaQS?KS%@vsYKep_@;&1O@n)PaEu z0~B9KHG{Eh_wIB!^IU#2hsBC|5`;cfe z28UC`aLo>>atb^y_DR%gwI)V>ePEKIp`i&6A0_uJrBF6eqADsY`PKOkT`o!&7Y{wv z1bstK%WB93_G73A{(=Zx_gBnGJ#m5_<{v}<4R@UoJMB6L3XMkl`}<$MOvAkZkpk8U zYa=5g>kWB>ye)rbq^70@1myB#LL(#J&Ci#VmsiWSViCwX7YBzNR3C%E7#tj&oQz7R z?u9_CtAugzGFuzKCIEIXw0@77+2Gsdt=&#ePPw_cVPRo4!M}iHmX?-IPeO%R&xR$kB^TVZ-`1tYG$)(D1k2%`Bo${T>y5jf_nd+j;veP{1_Azgva9%2*mmG zFF{*$x^|op28S12m#|o@%*?NiR+hra>TS4$geER`T_h4&t%Taz+Aic}=jiL}W7EjG-^WLQC=X|i z#bQ^$Awbk65=n1wuUL#E5<9AHZ}h|w2t#PLlK|vwOvVFO0NxVI-itRh0B$nZ;mDCh z0)Y@0SHooH&S_g)TNw=4neDcuRj~24wh=TtwEmSqVEneFr3L(TczBrRKRYyJ^qc(q zHzjzD^Hje+5(xf}+5f}97vW-0$W8F}lnU0yM02!(!8=L;@HHW*Cw8QIwwY6t$a9>O_V5)a`wrA!=n{BKf OC_mpop9Zh1nSTKBQRoo> delta 1453 zcmZ{kYc!h&7{}Gg5krlZN~!22l%AmK+Et3Ui-w}++PX%I6)mC~UaHcwSX{EoJNGu) zX-U|dE;3SWts*paM72^$b*lt*ulwl8IQz1lvpwIQ--rM6Jm>#E4^RE2dZ`sa_I7g# z%Br5N?!y_JK|D*agU-3CK@eQKQI%uU-K=*Oa_>6kQx&ZJ**p89%eN}aqb^-oOFmAK zhi&SSAllaV9DB38H=iIC1TdldhSB zqn&U#eBfdbJswtFh;Oh4Dtz@UB1E2t%P17Jf=>UBG)i8UBa-UTN3aIHP|m|Atq>Y) zG>+D#+fEym$>lI+JQXdBGEdTR-<5>Q<1q6f57m$(yO?E4LtaWrsE?C{tLvoPrwX|o z^)%Y5+p(WWBu>~T2aDLNE7M{=-`%H^%jK@Et=%G%P5FS+-Z1NQA`uLTD`&CH8yBoy zh5EC*d5``6WZtoLwd(F|9k|GBYU*nG6apCqNhVjZ2F6thx$mA1FfV#q4W`HuxHOr|er5$T7Fh8QnS zdDN#xMIF4_&ZxdSx%DO(XyS0W_F&HeK_oA8_GP-e;!^272ue#%_HF400;u0{25s!@ zzN*33;05R-7({4NV&WBt0}}u4p0KM|mwI}<7X3hB`I-(Cf}Xdsb{^TGpFX7~_ABHy z1Pc*oXlQ74l&`Lm5{_^kkjzTCbxuGtCSHd%(w-qq~DV)AD~|L>R=~X>3d+5KJx%yfU<5wA-0(bi9LqiCkV;vBHNf4{2cw3JQpG z@1iTts&6+XMot7X{E;C<(O|Ldxd=~Bc!66bo2{qqMvQsITtao2`LXJut&XG>zB^vukS&D@nKDK|wbm9m1H`UvVUWeNjrX&H{* zS39u{FLY${*|}$RfXxlQBhq9zP^5K{x@Pq#>36a+*1e* z9~w#(i^B;Q79fVHOeQlrO^OO_qa;#S01c`~{n*%;NBu(G*9Q9f$#znHk)9DI`)4kf zn4W%9i#?o7cof<&h{@T|iA7Tm%PRezXT#scoiIcqyJH}L!n-m?k(gRqda7;{I-TCk zWTMe%Sbgd$ zO8GHcH02vIVPS4wQ(aA38t;4cD!}1Du|SrTloS{kI5}6skBN_upW1(qN~OwElp>yx z+VTQ9O7jnD9k0clsCeIT-QLj3e%71$QKatqMbX!F1PF#eQ zitaE+q-DA>KmV9oEE7bTAvDO?yI3#?W=c6MJRwNipwVbvD0W8&3pq0&H}UI`Yjz`I iNt+uh+aIt7zoz$=40#*g@++T5A9RfFw!MG(`vzLM+R2 z9EVbhG0qRc8Ijgn2oXh5KJ-DkTy|YIqx(lBr3}NcFvslt7=Kr*RZ3}rNC+W>_^;v_ z*L7XjHEm)Z0PwEG%pJ$^mq;lUhT*#w1Ls^y`B(HoF3*^qAzEvr{1_>v)|vpoZ!87? zAdkP5VX;_bmMnyr&*z1Q0{}>pB>4}V^Ywbo7z={H^aMdLo6VNXZbT}LsW0gupYkj-jMx#-;+byBJBJA_uKDz-sN)X_xs<(P)hM;vw5}bpA!TDx!>==4y zI3`gPJy(Md@O?i?5+a1yZnrI{()$}-!~c!&N;Cx3nAn< zj%mEbZntZ<+ot_>h&<2ZoEH}MLTa@dLa5nnnpb-G9zFnDxkZxMr1N=m7e%3MdbZBt5Z+s+U91Cf~<$w^!yr-6 zq^6;yqCg-70vyYUf8s5?Vwoh&>|}ONlPLLod}CWT$XW|IthG`~DWx$cO;Z5C7*{Hl zTCGM1K?voCpp3{`D};#SI3Imduh)Iw&*(mglu}U?71o%YAAg}{vq=ak5D6iK5ML@@ z@qOR-eRq%DMN0Wyi<>)1l0PD?breP4wKzEEN~zzX61BYIb_QE(ow77aDQhj3QvSr^ zkW%93YGqihR+�LM)fd!owk@#KxHX3(k2MhLlpqn0vw)TPzmq_4;%=|Rgl+tRoI)9(f0f18acs!=lX}{ku%)b=@!0~vblr|a-Ywi7hpG+o$!Jsht#bS56 z-Pvr$IltX*t=4NdD1p}67=t^V&a)MiE(ZYca5xOZ@JDbsl(Pgu;LiF#Yi$3a)jHBN zefFw%5JG|=z!-l}7tXJ>R!X_;c>NuDo<|7rJTE`w7arjmDe3%zF#rGn07*qoM6N<$ Eg8ru3H~;_u diff --git a/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/navigation-bottom-unread-linux.png b/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/navigation-bottom-unread-linux.png index b37b4ed6dced29383839cda5ef3dfe39b75a7a45..b3315f5bac22ea88665e3a4d84e03d0a37775cf3 100644 GIT binary patch delta 1155 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 B8JPe8 diff --git a/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/not-stacked-messages-linux.png b/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/not-stacked-messages-linux.png index 2b5828952f0c99a06104ea67f3bb6b226d0a09fc..b78e2c6578dc220d0dfa4773f59a4bf4a0471aff 100644 GIT binary patch literal 6380 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 yN7KFTQ8CnaL=gL1PL?% literal 6188 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 lklWzs|Dpl@pGEA9WtF-_C diff --git a/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/stacked-messages-linux.png b/python/jupyterlab-collaborative-chat/ui-tests/tests/jupyterlab_collaborative_chat.spec.ts-snapshots/stacked-messages-linux.png index f3f11862fd38bb2acf152dc24ca026a13275261b..e6673610d3ccdbf3e5074a120b1eeb3c76065b84 100644 GIT binary patch literal 4571 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 literal 4441 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 diff --git a/python/jupyterlab-collaborative-chat/ui-tests/tests/test-utils.ts b/python/jupyterlab-collaborative-chat/ui-tests/tests/test-utils.ts index 675548e..c6f326e 100644 --- a/python/jupyterlab-collaborative-chat/ui-tests/tests/test-utils.ts +++ b/python/jupyterlab-collaborative-chat/ui-tests/tests/test-utils.ts @@ -48,9 +48,26 @@ export const sendMessage = async ( const input = chatPanel .locator('.jp-chat-input-container') .getByRole('combobox'); - const sendButton = chatPanel - .locator('.jp-chat-input-container') - .getByRole('button'); + const sendButton = chatPanel.locator( + '.jp-chat-input-container .jp-chat-send-button' + ); await input.pressSequentially(content); await sendButton.click(); }; + +export const splitMainArea = async ( + page: IJupyterLabPageFixture, + name: string +) => { + // Emulate drag and drop + const viewerHandle = page.activity.getTabLocator(name); + const viewerBBox = await viewerHandle.boundingBox(); + + await page.mouse.move( + viewerBBox!.x + 0.5 * viewerBBox!.width, + viewerBBox!.y + 0.5 * viewerBBox!.height + ); + await page.mouse.down(); + await page.mouse.move(viewerBBox!.x + 0.5 * viewerBBox!.width, 600); + await page.mouse.up(); +}; diff --git a/python/jupyterlab-ws-chat/src/handlers/websocket-handler.ts b/python/jupyterlab-ws-chat/src/handlers/websocket-handler.ts index 90c62dc..b406231 100644 --- a/python/jupyterlab-ws-chat/src/handlers/websocket-handler.ts +++ b/python/jupyterlab-ws-chat/src/handlers/websocket-handler.ts @@ -85,7 +85,7 @@ export class WebSocketHandler extends ChatModel { * Sends a message across the WebSocket. Promise resolves to the message ID * when the server sends the same message back, acknowledging receipt. */ - addMessage(message: INewMessage): Promise { + sendMessage(message: INewMessage): Promise { message.id = UUID.uuid4(); return new Promise(resolve => { this._socket?.send(JSON.stringify(message)); diff --git a/python/jupyterlab-ws-chat/src/index.ts b/python/jupyterlab-ws-chat/src/index.ts index 19c66d1..59a7692 100644 --- a/python/jupyterlab-ws-chat/src/index.ts +++ b/python/jupyterlab-ws-chat/src/index.ts @@ -7,6 +7,7 @@ import { ActiveCellManager, AutocompletionRegistry, IAutocompletionRegistry, + SelectionWatcher, buildChatSidebar, buildErrorWidget } from '@jupyter/chat'; @@ -72,12 +73,18 @@ const chat: JupyterFrontEndPlugin = { shell: app.shell }); + // Create an watcher (to send selection). + const selectionWatcher = new SelectionWatcher({ + shell: app.shell + }); + /** * Initialize chat handler, open WS connection */ const chatHandler = new WebSocketHandler({ commands: app.commands, - activeCellManager + activeCellManager, + selectionWatcher }); /** diff --git a/yarn.lock b/yarn.lock index aa14c88..f4b9860 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2439,6 +2439,7 @@ __metadata: "@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/testing": ^4.2.0 @@ -2906,6 +2907,32 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/fileeditor@npm:^4.2.0": + version: 4.2.3 + resolution: "@jupyterlab/fileeditor@npm:4.2.3" + dependencies: + "@jupyter/ydoc": ^2.0.1 + "@jupyterlab/apputils": ^4.3.3 + "@jupyterlab/codeeditor": ^4.2.3 + "@jupyterlab/codemirror": ^4.2.3 + "@jupyterlab/coreutils": ^6.2.3 + "@jupyterlab/docregistry": ^4.2.3 + "@jupyterlab/documentsearch": ^4.2.3 + "@jupyterlab/lsp": ^4.2.3 + "@jupyterlab/statusbar": ^4.2.3 + "@jupyterlab/toc": ^6.2.3 + "@jupyterlab/translation": ^4.2.3 + "@jupyterlab/ui-components": ^4.2.3 + "@lumino/commands": ^2.3.0 + "@lumino/coreutils": ^2.1.2 + "@lumino/messaging": ^2.0.1 + "@lumino/widgets": ^2.3.2 + react: ^18.2.0 + regexp-match-indices: ^1.0.2 + checksum: 54470a0a71f3110640c687e8e3a0b09afad5dd5470caad04c95744732212d2aaac14db6168100d31f80244dc42d09f927d524989223832787168c3d11a9a206e + languageName: node + linkType: hard + "@jupyterlab/launcher@npm:^4.2.0": version: 4.2.3 resolution: "@jupyterlab/launcher@npm:4.2.3" @@ -12875,6 +12902,24 @@ __metadata: languageName: node linkType: hard +"regexp-match-indices@npm:^1.0.2": + version: 1.0.2 + resolution: "regexp-match-indices@npm:1.0.2" + dependencies: + regexp-tree: ^0.1.11 + checksum: 8cc779f6cf8f404ead828d09970a7d4bd66bd78d43ab9eb2b5e65f2ef2ba1ed53536f5b5fa839fb90b350365fb44b6a851c7f16289afc3f37789c113ab2a7916 + languageName: node + linkType: hard + +"regexp-tree@npm:^0.1.11": + version: 0.1.27 + resolution: "regexp-tree@npm:0.1.27" + bin: + regexp-tree: bin/regexp-tree + checksum: 129aebb34dae22d6694ab2ac328be3f99105143737528ab072ef624d599afecbcfae1f5c96a166fa9e5f64fa1ecf30b411c4691e7924c3e11bbaf1712c260c54 + languageName: node + linkType: hard + "regexp.prototype.flags@npm:^1.5.2": version: 1.5.2 resolution: "regexp.prototype.flags@npm:1.5.2"