diff --git a/.gitignore b/.gitignore index 13915bd9..5f5d89b4 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,6 @@ Untitled*.ipynb # Chat files *.chat + +# Ignore secrets in '.env' +.env diff --git a/packages/jupyter-chat/src/components/attachments.tsx b/packages/jupyter-chat/src/components/attachments.tsx index f9c62e5c..817ee943 100644 --- a/packages/jupyter-chat/src/components/attachments.tsx +++ b/packages/jupyter-chat/src/components/attachments.tsx @@ -4,16 +4,14 @@ */ import CloseIcon from '@mui/icons-material/Close'; -import { Box } from '@mui/material'; +import { Box, Button, Tooltip } from '@mui/material'; import React, { useContext } from 'react'; import { PathExt } from '@jupyterlab/coreutils'; import { UUID } from '@lumino/coreutils'; -import { TooltippedButton } from './mui-extras/tooltipped-button'; import { IAttachment } from '../types'; import { AttachmentOpenerContext } from '../context'; -const ATTACHMENTS_CLASS = 'jp-chat-attachments'; const ATTACHMENT_CLASS = 'jp-chat-attachment'; const ATTACHMENT_CLICKABLE_CLASS = 'jp-chat-attachment-clickable'; const REMOVE_BUTTON_CLASS = 'jp-chat-attachment-remove'; @@ -57,7 +55,15 @@ export type AttachmentsProps = { */ export function AttachmentPreviewList(props: AttachmentsProps): JSX.Element { return ( - + {props.attachments.map(attachment => ( - - attachmentOpenerRegistry?.get(props.attachment.type)?.( - props.attachment - ) - } - > - {getAttachmentDisplayName(props.attachment)} - - {props.onRemove && ( - props.onRemove!(props.attachment)} - tooltip={remove_tooltip} - buttonProps={{ - size: 'small', - title: remove_tooltip, - className: REMOVE_BUTTON_CLASS - }} + + + + attachmentOpenerRegistry?.get(props.attachment.type)?.( + props.attachment + ) + } sx={{ - minWidth: 'unset', - padding: '0', - color: 'inherit' + cursor: isClickable ? 'pointer' : 'default', + '&:hover': isClickable + ? { + textDecoration: 'underline' + } + : {} }} > - - + {getAttachmentDisplayName(props.attachment)} + + + {props.onRemove && ( + + + + + )} ); diff --git a/packages/jupyter-chat/src/components/chat.tsx b/packages/jupyter-chat/src/components/chat.tsx index 019c0acd..cda29956 100644 --- a/packages/jupyter-chat/src/components/chat.tsx +++ b/packages/jupyter-chat/src/components/chat.tsx @@ -25,6 +25,7 @@ import { IChatCommandRegistry, IMessageFooterRegistry } from '../registers'; +import { ChatArea } from '../types'; export function ChatBody(props: Chat.IChatBodyProps): JSX.Element { const { model } = props; @@ -32,6 +33,8 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element { if (!inputToolbarRegistry) { inputToolbarRegistry = InputToolbarRegistry.defaultToolbarRegistry(); } + // const horizontalPadding = props.area === 'main' ? 8 : 4; + const horizontalPadding = 4; return ( @@ -42,18 +45,20 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element { inputToolbarRegistry={inputToolbarRegistry} messageFooterRegistry={props.messageFooterRegistry} welcomeMessage={props.welcomeMessage} + area={props.area} /> ); @@ -141,6 +146,10 @@ export namespace Chat { * The welcome message. */ welcomeMessage?: string; + /** + * The area where the chat is displayed. + */ + area?: ChatArea; } /** 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 e1fa6019..e2325601 100644 --- a/packages/jupyter-chat/src/components/code-blocks/code-toolbar.tsx +++ b/packages/jupyter-chat/src/components/code-blocks/code-toolbar.tsx @@ -4,11 +4,10 @@ */ import { addAboveIcon, addBelowIcon } from '@jupyterlab/ui-components'; -import { Box } from '@mui/material'; +import { Box, IconButton, Tooltip } from '@mui/material'; import React, { useEffect, useState } from 'react'; import { CopyButton } from './copy-button'; -import { TooltippedIconButton } from '../mui-extras/tooltipped-icon-button'; import { IActiveCellManager } from '../../active-cell-manager'; import { replaceCellIcon } from '../../icons'; import { IChatModel } from '../../model'; @@ -82,8 +81,7 @@ export function CodeToolbar(props: CodeToolbarProps): JSX.Element { alignItems: 'center', padding: '2px 2px', marginBottom: '1em', - border: '1px solid var(--jp-cell-editor-border-color)', - borderTop: 'none' + border: 'none' }} className={CODE_TOOLBAR_CLASS} > @@ -116,14 +114,24 @@ function InsertAboveButton(props: ToolbarButtonProps) { : 'Insert above active cell (no active cell)'; return ( - props.activeCellManager?.insertAbove(props.content)} - disabled={!props.activeCellAvailable} - > - - + + + props.activeCellManager?.insertAbove(props.content)} + disabled={!props.activeCellAvailable} + aria-label={tooltip} + sx={{ + lineHeight: 0, + '&.Mui-disabled': { + opacity: 0.5 + } + }} + > + + + + ); } @@ -133,14 +141,24 @@ function InsertBelowButton(props: ToolbarButtonProps) { : 'Insert below active cell (no active cell)'; return ( - props.activeCellManager?.insertBelow(props.content)} - > - - + + + props.activeCellManager?.insertBelow(props.content)} + aria-label={tooltip} + sx={{ + lineHeight: 0, + '&.Mui-disabled': { + opacity: 0.5 + } + }} + > + + + + ); } @@ -169,13 +187,23 @@ function ReplaceButton(props: ToolbarButtonProps) { }; return ( - - - + + + + + + + ); } diff --git a/packages/jupyter-chat/src/components/code-blocks/copy-button.tsx b/packages/jupyter-chat/src/components/code-blocks/copy-button.tsx index 9d3ec912..6fb67d1b 100644 --- a/packages/jupyter-chat/src/components/code-blocks/copy-button.tsx +++ b/packages/jupyter-chat/src/components/code-blocks/copy-button.tsx @@ -6,8 +6,7 @@ import React, { useState, useCallback, useRef } from 'react'; import { copyIcon } from '@jupyterlab/ui-components'; - -import { TooltippedIconButton } from '../mui-extras/tooltipped-icon-button'; +import { IconButton, Tooltip } from '@mui/material'; enum CopyStatus { None, @@ -59,16 +58,26 @@ export function CopyButton(props: CopyButtonProps): JSX.Element { ); }, [copyStatus, props.value]); + const tooltip = COPYBTN_TEXT_BY_STATUS[copyStatus]; + return ( - - - + + + + + + + ); } diff --git a/packages/jupyter-chat/src/components/index.ts b/packages/jupyter-chat/src/components/index.ts index d90e3a63..b36f146b 100644 --- a/packages/jupyter-chat/src/components/index.ts +++ b/packages/jupyter-chat/src/components/index.ts @@ -9,5 +9,4 @@ export * from './code-blocks'; export * from './input'; export * from './jl-theme-provider'; export * from './messages'; -export * from './mui-extras'; export * from './scroll-container'; diff --git a/packages/jupyter-chat/src/components/input/buttons/attach-button.tsx b/packages/jupyter-chat/src/components/input/buttons/attach-button.tsx index 658a1c34..bbbdbe5a 100644 --- a/packages/jupyter-chat/src/components/input/buttons/attach-button.tsx +++ b/packages/jupyter-chat/src/components/input/buttons/attach-button.tsx @@ -52,12 +52,18 @@ export function AttachButton( tooltip={tooltip} buttonProps={{ size: 'small', - variant: 'contained', + variant: 'text', title: tooltip, className: ATTACH_BUTTON_CLASS }} + sx={{ + width: '24px', + height: '24px', + minWidth: '24px', + color: 'gray' + }} > - + ); } diff --git a/packages/jupyter-chat/src/components/input/buttons/cancel-button.tsx b/packages/jupyter-chat/src/components/input/buttons/cancel-button.tsx index 73567fd9..d2689e78 100644 --- a/packages/jupyter-chat/src/components/input/buttons/cancel-button.tsx +++ b/packages/jupyter-chat/src/components/input/buttons/cancel-button.tsx @@ -3,11 +3,11 @@ * Distributed under the terms of the Modified BSD License. */ -import CancelIcon from '@mui/icons-material/Cancel'; +import CloseIcon from '@mui/icons-material/Close'; +import { IconButton, Tooltip } from '@mui/material'; import React from 'react'; import { InputToolbarRegistry } from '../toolbar-registry'; -import { TooltippedButton } from '../../mui-extras/tooltipped-button'; const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button'; @@ -20,19 +20,24 @@ export function CancelButton( if (!props.model.cancel) { return <>; } - const tooltip = 'Cancel edition'; + const tooltip = 'Cancel editing'; return ( - - - + + + + + + + ); } diff --git a/packages/jupyter-chat/src/components/input/buttons/index.ts b/packages/jupyter-chat/src/components/input/buttons/index.ts index 7c3adfc0..3b86311d 100644 --- a/packages/jupyter-chat/src/components/input/buttons/index.ts +++ b/packages/jupyter-chat/src/components/input/buttons/index.ts @@ -5,4 +5,6 @@ export { AttachButton } from './attach-button'; export { CancelButton } from './cancel-button'; +export { SaveEditButton } from './save-edit-button'; export { SendButton } from './send-button'; +export { StopButton } from './stop-button'; diff --git a/packages/jupyter-chat/src/components/input/buttons/save-edit-button.tsx b/packages/jupyter-chat/src/components/input/buttons/save-edit-button.tsx new file mode 100644 index 00000000..74eb8c04 --- /dev/null +++ b/packages/jupyter-chat/src/components/input/buttons/save-edit-button.tsx @@ -0,0 +1,75 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import CheckIcon from '@mui/icons-material/Check'; +import { IconButton, Tooltip } from '@mui/material'; +import React, { useEffect, useState } from 'react'; + +import { InputToolbarRegistry } from '../toolbar-registry'; + +const SAVE_EDIT_BUTTON_CLASS = 'jp-chat-save-edit-button'; + +/** + * The save edit button. + */ +export function SaveEditButton( + props: InputToolbarRegistry.IToolbarItemProps +): JSX.Element { + const { model, chatCommandRegistry, edit } = props; + + // Don't show this button when not in edit mode + if (!edit) { + return <>; + } + + const [disabled, setDisabled] = useState(false); + const tooltip = 'Save edits'; + + useEffect(() => { + const inputChanged = () => { + const inputExist = !!model.value.trim() || model.attachments.length; + setDisabled(!inputExist); + }; + + model.valueChanged.connect(inputChanged); + model.attachmentsChanged?.connect(inputChanged); + + inputChanged(); + + return () => { + model.valueChanged.disconnect(inputChanged); + model.attachmentsChanged?.disconnect(inputChanged); + }; + }, [model]); + + async function save() { + await chatCommandRegistry?.onSubmit(model); + model.send(model.value); + } + + return ( + + + + + + + + ); +} diff --git a/packages/jupyter-chat/src/components/input/buttons/send-button.tsx b/packages/jupyter-chat/src/components/input/buttons/send-button.tsx index d5044463..96108191 100644 --- a/packages/jupyter-chat/src/components/input/buttons/send-button.tsx +++ b/packages/jupyter-chat/src/components/input/buttons/send-button.tsx @@ -3,47 +3,31 @@ * 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 ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import { Button, Tooltip } from '@mui/material'; +import React, { useEffect, useState } from 'react'; import { InputToolbarRegistry } from '../toolbar-registry'; -import { TooltippedButton } from '../../mui-extras/tooltipped-button'; -import { includeSelectionIcon } from '../../../icons'; import { IInputModel, InputModel } from '../../../input-model'; 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, with optional 'include selection' menu. + * The send button. */ export function SendButton( props: InputToolbarRegistry.IToolbarItemProps ): JSX.Element { - const { model, chatCommandRegistry } = props; - const { activeCellManager, selectionWatcher } = model; - const hideIncludeSelection = !activeCellManager || !selectionWatcher; + const { model, chatModel, chatCommandRegistry, edit } = props; + + // Don't show this button when in edit mode + if (edit) { + return <>; + } - const [menuAnchorEl, setMenuAnchorEl] = useState(null); - const [menuOpen, setMenuOpen] = useState(false); const [disabled, setDisabled] = useState(false); const [tooltip, setTooltip] = useState(''); - const openMenu = useCallback((el: HTMLElement | null) => { - setMenuAnchorEl(el); - setMenuOpen(true); - }, []); - - const closeMenu = useCallback(() => { - setMenuOpen(false); - }, []); - - const [selectionTooltip, setSelectionTooltip] = useState(''); - const [disableInclude, setDisableInclude] = useState(true); - useEffect(() => { const inputChanged = () => { const inputExist = !!model.value.trim() || model.attachments.length; @@ -71,153 +55,52 @@ export function SendButton( }; }, [model]); - 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]); - async function send() { + // Run all command providers await chatCommandRegistry?.onSubmit(model); - model.send(model.value); - } - - async function sendWithSelection() { - let source = ''; - - // Run all chat command providers - await chatCommandRegistry?.onSubmit(model); - - let language: string | undefined; - if (selectionWatcher?.selection) { - // Append the selected text if exists. - source = selectionWatcher.selection.text; - language = selectionWatcher.selection.language; - } else if (activeCellManager?.available) { - // Append the active cell content if exists. - const content = activeCellManager.getContent(false); - source = content!.source; - language = content?.language; - } - let content = model.value; - if (source) { - content += ` -\`\`\`${language ?? ''} -${source} -\`\`\` -`; - } - model.send(content); - closeMenu(); + // send message through chat model + await chatModel?.sendMessage({ + body: model.value + }); + // clear input model value & re-focus model.value = ''; + model.focus(); } return ( - <> - - - - {!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 - }} - > - - - - { - 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/input/buttons/stop-button.tsx b/packages/jupyter-chat/src/components/input/buttons/stop-button.tsx new file mode 100644 index 00000000..4e0f9e16 --- /dev/null +++ b/packages/jupyter-chat/src/components/input/buttons/stop-button.tsx @@ -0,0 +1,88 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import StopIcon from '@mui/icons-material/Stop'; +import { Button, Tooltip } from '@mui/material'; +import React, { useEffect, useState } from 'react'; + +import { InputToolbarRegistry } from '../toolbar-registry'; + +const STOP_BUTTON_CLASS = 'jp-chat-stop-button'; + +/** + * The stop button. + */ +export function StopButton( + props: InputToolbarRegistry.IToolbarItemProps +): JSX.Element { + const { chatModel } = props; + const [disabled, setDisabled] = useState(true); + const tooltip = 'Stop generating'; + + useEffect(() => { + if (!chatModel) { + setDisabled(true); + return; + } + + const checkWriters = () => { + // Check if there's at least one AI agent writer (bot) + const hasAIWriter = chatModel.writers.some(writer => writer.user.bot); + setDisabled(!hasAIWriter); + }; + + // Check initially + checkWriters(); + + // Listen to writers changes + chatModel.writersChanged?.connect(checkWriters); + + return () => { + chatModel.writersChanged?.disconnect(checkWriters); + }; + }, [chatModel]); + + function stop() { + // TODO: Implement stop functionality + // This will need to be implemented based on how the chat model handles stopping AI responses + console.log('Stop button clicked'); + } + + return ( + + + + + + ); +} diff --git a/packages/jupyter-chat/src/components/input/chat-input.tsx b/packages/jupyter-chat/src/components/input/chat-input.tsx index 66489477..cc4a6de1 100644 --- a/packages/jupyter-chat/src/components/input/chat-input.tsx +++ b/packages/jupyter-chat/src/components/input/chat-input.tsx @@ -9,8 +9,7 @@ import { Box, SxProps, TextField, - Theme, - Toolbar + Theme } from '@mui/material'; import clsx from 'clsx'; import React, { useEffect, useRef, useState } from 'react'; @@ -23,11 +22,12 @@ import { } from '.'; import { IInputModel, InputModel } from '../../input-model'; import { IChatCommandRegistry } from '../../registers'; -import { IAttachment } from '../../types'; +import { IAttachment, ChatArea } from '../../types'; +import { IChatModel } from '../../model'; +import { InputWritingIndicator } from './writing-indicator'; const INPUT_BOX_CLASS = 'jp-chat-input-container'; const INPUT_TEXTFIELD_CLASS = 'jp-chat-input-textfield'; -const INPUT_COMPONENT_CLASS = 'jp-chat-input-component'; const INPUT_TOOLBAR_CLASS = 'jp-chat-input-toolbar'; export function ChatInput(props: ChatInput.IProps): JSX.Element { @@ -46,6 +46,17 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { const [toolbarElements, setToolbarElements] = useState< InputToolbarRegistry.IToolbarItem[] >([]); + const [isFocused, setIsFocused] = useState(false); + const [writers, setWriters] = useState([]); + + /** + * Auto-focus the input when the component is first mounted. + */ + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); /** * Handle the changes on the model that affect the input. @@ -99,6 +110,30 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { }; }, [toolbarRegistry]); + /** + * Handle the changes in the writers list. + */ + useEffect(() => { + if (!props.chatModel) { + return; + } + + const updateWriters = (_: IChatModel, writers: IChatModel.IWriter[]) => { + // Show all writers for now - AI generating responses will have messageID + setWriters(writers); + }; + + // Set initial writers state + const initialWriters = props.chatModel.writers; + setWriters(initialWriters); + + props.chatModel.writersChanged?.connect(updateWriters); + + return () => { + props.chatModel?.writersChanged?.disconnect(updateWriters); + }; + }, [props.chatModel]); + const inputExists = !!input.trim(); /** @@ -127,13 +162,14 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { /** * IMPORTANT: This statement ensures that when the chat commands menu is - * open with a highlighted command, the "Enter" key should run that command + * open, the "Enter" key should select the command (handled by Autocomplete) * instead of sending the message. * * This is done by returning early and letting the event propagate to the - * `Autocomplete` component. + * `Autocomplete` component, which will select the auto-highlighted option + * thanks to autoSelect: true. */ - if (chatCommands.menu.highlighted) { + if (chatCommands.menu.open) { return; } @@ -167,16 +203,7 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { } } - // Set the helper text based on whether Shift+Enter is used for sending. - const helperText = sendWithShiftEnter ? ( - - Press Shift+Enter to send message - - ) : ( - - Press Shift+Enter to add a new line - - ); + const horizontalPadding = props.area === 'sidebar' ? 1.5 : 2; return ( - - ( - - (model.cursorIndex = inputRef.current?.selectionStart ?? null) - } - slotProps={{ - input: { - ...params.InputProps, - className: INPUT_COMPONENT_CLASS - } + > + {attachments.length > 0 && ( + 2 ? helperText : ' '} - /> + > + + )} - inputValue={input} - onInputChange={( - _, - newValue: string, - reason: AutocompleteInputChangeReason - ) => { - // Do not update the value if the reason is 'reset', which should occur only - // if an autocompletion command has been selected. In this case, the value is - // set in the 'onChange()' callback of the autocompletion (to avoid conflicts). - if (reason !== 'reset') { + ( + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onSelect={() => + (model.cursorIndex = inputRef.current?.selectionStart ?? null) + } + sx={{ + padding: 1.5, + margin: 0, + backgroundColor: 'var(--jp-layout-color0)', + transition: 'background-color 0.2s ease', + '& .MuiInputBase-root': { + padding: 0, + margin: 0, + '&:before': { + display: 'none' + }, + '&:after': { + display: 'none' + } + }, + '& .MuiInputBase-input': { + overflowWrap: 'break-word', + wordBreak: 'break-word' + } + }} + InputProps={{ + ...params.InputProps, + disableUnderline: true + }} + FormHelperTextProps={{ + sx: { display: 'none' } + }} + /> + )} + inputValue={input} + onInputChange={( + _, + newValue: string, + reason: AutocompleteInputChangeReason + ) => { + // Skip value updates when an autocomplete option is selected. + // The 'onChange' callback handles the replacement via replaceCurrentWord. + // 'selectOption' - user selected an option (newValue is just the option label) + // 'reset' - autocomplete is resetting after selection + // 'blur' - when user blurs the input (newValue is set to empty string) + if ( + reason === 'selectOption' || + reason === 'reset' || + reason === 'blur' + ) { + return; + } model.value = newValue; - } - }} - /> - - {toolbarElements.map(item => ( - - ))} - + }} + /> + + {toolbarElements.map((item, index) => ( + + ))} + + + ); } @@ -285,5 +373,18 @@ export namespace ChatInput { * Chat command registry. */ chatCommandRegistry?: IChatCommandRegistry; + /** + * The area where the chat is displayed. + */ + area?: ChatArea; + /** + * The chat model. + */ + chatModel?: IChatModel; + /** + * Whether the input is in edit mode (editing an existing message). + * Defaults to false (new message mode). + */ + edit?: boolean; } } diff --git a/packages/jupyter-chat/src/components/input/index.ts b/packages/jupyter-chat/src/components/input/index.ts index 16673c26..802f3f69 100644 --- a/packages/jupyter-chat/src/components/input/index.ts +++ b/packages/jupyter-chat/src/components/input/index.ts @@ -7,3 +7,4 @@ export * from './buttons'; export * from './chat-input'; export * from './toolbar-registry'; export * from './use-chat-commands'; +export * from './writing-indicator'; diff --git a/packages/jupyter-chat/src/components/input/toolbar-registry.tsx b/packages/jupyter-chat/src/components/input/toolbar-registry.tsx index 84638abd..34271c58 100644 --- a/packages/jupyter-chat/src/components/input/toolbar-registry.tsx +++ b/packages/jupyter-chat/src/components/input/toolbar-registry.tsx @@ -6,9 +6,15 @@ import { Token } from '@lumino/coreutils'; import { ISignal, Signal } from '@lumino/signaling'; import * as React from 'react'; -import { AttachButton, CancelButton, SendButton } from './buttons'; +import { + AttachButton, + CancelButton, + SaveEditButton, + SendButton +} from './buttons'; import { IInputModel } from '../../input-model'; import { IChatCommandRegistry } from '../../registers'; +import { IChatModel } from '../../model'; /** * The toolbar registry interface. @@ -144,6 +150,15 @@ export namespace InputToolbarRegistry { * `onSubmit()` on all command providers before sending the message. */ chatCommandRegistry?: IChatCommandRegistry; + /** + * The chat model. Provides access to messages, writers, and other chat state. + */ + chatModel?: IChatModel; + /** + * Whether the input is in edit mode (editing an existing message). + * Defaults to false (new message mode). + */ + edit?: boolean; } /** @@ -152,10 +167,19 @@ export namespace InputToolbarRegistry { export function defaultToolbarRegistry(): InputToolbarRegistry { const registry = new InputToolbarRegistry(); + // TODO: Re-enable stop button once logic is fully implemented + // registry.addItem('stop', { + // element: StopButton, + // position: 90 + // }); registry.addItem('send', { element: SendButton, position: 100 }); + registry.addItem('saveEdit', { + element: SaveEditButton, + position: 95 + }); registry.addItem('attach', { element: AttachButton, position: 20 diff --git a/packages/jupyter-chat/src/components/input/use-chat-commands.tsx b/packages/jupyter-chat/src/components/input/use-chat-commands.tsx index 7cc82348..621ced2d 100644 --- a/packages/jupyter-chat/src/components/input/use-chat-commands.tsx +++ b/packages/jupyter-chat/src/components/input/use-chat-commands.tsx @@ -8,7 +8,7 @@ import type { AutocompleteChangeReason, AutocompleteProps as GenericAutocompleteProps } from '@mui/material'; -import { Box } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import React, { useEffect, useState } from 'react'; import { ChatCommand, IChatCommandRegistry } from '../../registers'; @@ -166,15 +166,34 @@ export function useChatCommands( ); return ( - + {commandIcon} -

{command.name}

+ + {command.name} + {command.description && ( <> - -

+ {command.description} -

+ )}
@@ -185,6 +204,7 @@ export function useChatCommands( filterOptions: (commands: ChatCommand[]) => commands, value: null, autoHighlight: true, + autoSelect: true, freeSolo: true, disableClearable: true, onChange, diff --git a/packages/jupyter-chat/src/components/input/writing-indicator.tsx b/packages/jupyter-chat/src/components/input/writing-indicator.tsx new file mode 100644 index 00000000..825e697f --- /dev/null +++ b/packages/jupyter-chat/src/components/input/writing-indicator.tsx @@ -0,0 +1,83 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Box, Typography } from '@mui/material'; +import React from 'react'; + +import { IChatModel } from '../../model'; + +/** + * Classname on the root element. Used in E2E tests. + */ +const WRITERS_ELEMENT_CLASSNAME = 'jp-chat-writers'; + +/** + * The input writing indicator component props. + */ +export interface IInputWritingIndicatorProps { + /** + * The list of users currently writing. + */ + writers: IChatModel.IWriter[]; +} + +/** + * Format the writers list into a readable string. + * Examples: "Alice is typing...", "Alice and Bob are typing...", "Alice, Bob, and Carol are typing..." + */ +function formatWritersText(writers: IChatModel.IWriter[]): string { + if (writers.length === 0) { + return ''; + } + + const names = writers.map( + w => w.user.display_name ?? w.user.name ?? w.user.username ?? 'Unknown' + ); + + if (names.length === 1) { + return `${names[0]} is typing...`; + } else if (names.length === 2) { + return `${names[0]} and ${names[1]} are typing...`; + } else { + const allButLast = names.slice(0, -1).join(', '); + const last = names[names.length - 1]; + return `${allButLast}, and ${last} are typing...`; + } +} + +/** + * The input writing indicator component, displaying typing status in the chat input area. + */ +export function InputWritingIndicator( + props: IInputWritingIndicatorProps +): JSX.Element { + const { writers } = props; + + // Always render the container to reserve space, even if no writers + const writersText = writers.length > 0 ? formatWritersText(writers) : ''; + + return ( + + 0 ? 'visible' : 'hidden' + }} + > + {writersText || '\u00A0'} + + + ); +} diff --git a/packages/jupyter-chat/src/components/messages/header.tsx b/packages/jupyter-chat/src/components/messages/header.tsx index 13a1c34b..a1f26d31 100644 --- a/packages/jupyter-chat/src/components/messages/header.tsx +++ b/packages/jupyter-chat/src/components/messages/header.tsx @@ -20,12 +20,20 @@ type ChatMessageHeaderProps = { * The chat message. */ message: IChatMessage; + /** + * Whether this message is from the current user. + */ + isCurrentUser?: boolean; }; /** * The message header component. */ export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element { + // Don't render header for stacked messages or current user messages + if (props.message.stacked || props.isCurrentUser) { + return <>; + } const [datetime, setDatetime] = useState>({}); const message = props.message; const sender = message.sender; diff --git a/packages/jupyter-chat/src/components/messages/index.ts b/packages/jupyter-chat/src/components/messages/index.ts index c6a32fa9..e99abf23 100644 --- a/packages/jupyter-chat/src/components/messages/index.ts +++ b/packages/jupyter-chat/src/components/messages/index.ts @@ -11,4 +11,3 @@ export * from './messages'; export * from './navigation'; export * from './toolbar'; export * from './welcome'; -export * from './writers'; diff --git a/packages/jupyter-chat/src/components/messages/message.tsx b/packages/jupyter-chat/src/components/messages/message.tsx index 4d5fdd99..ad4c3470 100644 --- a/packages/jupyter-chat/src/components/messages/message.tsx +++ b/packages/jupyter-chat/src/components/messages/message.tsx @@ -134,6 +134,7 @@ export const ChatMessage = forwardRef( model={model.getEditionModel(message.id)!} chatCommandRegistry={props.chatCommandRegistry} toolbarRegistry={props.inputToolbarRegistry} + edit={true} /> ) : ( (model.messages); const refMsgBox = useRef(null); - const [currentWriters, setCurrentWriters] = useState([]); const [allRendered, setAllRendered] = useState(false); // The list of message DOM and their rendered promises. @@ -84,7 +86,6 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element { } fetchHistory(); - setCurrentWriters([]); }, [model]); /** @@ -95,16 +96,10 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element { setMessages([...model.messages]); } - function handleWritersChange(_: IChatModel, writers: IChatModel.IWriter[]) { - setCurrentWriters(writers.map(writer => writer.user)); - } - model.messagesUpdated.connect(handleChatEvents); - model.writersChanged?.connect(handleWritersChange); return function cleanup() { model.messagesUpdated.disconnect(handleChatEvents); - model.writersChanged?.disconnect(handleChatEvents); }; }, [model]); @@ -170,6 +165,7 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element { }; }, [messages, allRendered]); + const horizontalPadding = props.area === 'main' ? 8 : 4; return ( <> @@ -179,39 +175,67 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element { content={props.welcomeMessage} /> )} - - {messages.map((message, i) => { - renderedPromise.current[i] = new PromiseDelegate(); - return ( - // extra div needed to ensure each bubble is on a new line - - - (listRef.current[i] = el)} - /> - {props.messageFooterRegistry && ( - + {messages + .filter(message => !message.deleted) + .map((message, i) => { + renderedPromise.current[i] = new PromiseDelegate(); + const isCurrentUser = + model.user !== undefined && + model.user.username === message.sender.username; + return ( + // extra div needed to ensure each bubble is on a new line + + + (listRef.current[i] = el)} /> - )} - - ); - })} + {props.messageFooterRegistry && ( + + )} + + ); + })} - ); diff --git a/packages/jupyter-chat/src/components/messages/toolbar.tsx b/packages/jupyter-chat/src/components/messages/toolbar.tsx index 45ef2a70..b7fe0b9d 100644 --- a/packages/jupyter-chat/src/components/messages/toolbar.tsx +++ b/packages/jupyter-chat/src/components/messages/toolbar.tsx @@ -3,11 +3,9 @@ * Distributed under the terms of the Modified BSD License. */ -import { - ToolbarButtonComponent, - deleteIcon, - editIcon -} from '@jupyterlab/ui-components'; +// import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { Box, IconButton, Tooltip } from '@mui/material'; import React from 'react'; const TOOLBAR_CLASS = 'jp-chat-toolbar'; @@ -18,27 +16,59 @@ const TOOLBAR_CLASS = 'jp-chat-toolbar'; export function MessageToolbar(props: MessageToolbar.IProps): JSX.Element { const buttons: JSX.Element[] = []; - if (props.edit !== undefined) { - const editButton = ToolbarButtonComponent({ - icon: editIcon, - onClick: props.edit, - tooltip: 'Edit' - }); - buttons.push(editButton); - } + // if (props.edit !== undefined) { + // const editButton = ( + // + // + // + // + // + // + // + // ); + // buttons.push(editButton); + // } if (props.delete !== undefined) { - const deleteButton = ToolbarButtonComponent({ - icon: deleteIcon, - onClick: props.delete, - tooltip: 'Delete' - }); + const deleteButton = ( + + + + + + + + ); buttons.push(deleteButton); } return ( -
- {buttons.map(toolbarButton => toolbarButton)} -
+ + {buttons} + ); } diff --git a/packages/jupyter-chat/src/components/messages/writers.tsx b/packages/jupyter-chat/src/components/messages/writers.tsx deleted file mode 100644 index b5d5cd92..00000000 --- a/packages/jupyter-chat/src/components/messages/writers.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) Jupyter Development Team. - * Distributed under the terms of the Modified BSD License. - */ - -import { Box, Typography } from '@mui/material'; -import React, { useMemo } from 'react'; - -import { Avatar } from '../avatar'; -import { IUser } from '../../types'; - -const WRITERS_CLASS = 'jp-chat-writers'; - -/** - * The writers component props. - */ -type writersProps = { - /** - * The list of users currently writing. - */ - writers: IUser[]; -}; - -/** - * Animated typing indicator component - */ -const TypingIndicator = (): JSX.Element => ( - - - - - -); - -/** - * The writers component, displaying the current writers. - */ -export function WritingUsersList(props: writersProps): JSX.Element | null { - const { writers } = props; - - // Don't render if no writers - if (writers.length === 0) { - return null; - } - - const writersText = writers.length > 1 ? ' are writing' : ' is writing'; - - const writingUsers: JSX.Element[] = useMemo( - () => - writers.map((writer, index) => ( - - - - {writer.display_name ?? - writer.name ?? - (writer.username || 'User undefined')} - - {index < writers.length - 1 && ( - - {index < writers.length - 2 ? ', ' : ' and '} - - )} - - )), - [writers] - ); - - return ( - - - {writingUsers} - - - {writersText} - - - - - - ); -} diff --git a/packages/jupyter-chat/src/types.ts b/packages/jupyter-chat/src/types.ts index cdd395f9..808b4a51 100644 --- a/packages/jupyter-chat/src/types.ts +++ b/packages/jupyter-chat/src/types.ts @@ -172,3 +172,8 @@ export interface IAttachmentSelection { * An empty interface to describe optional settings that could be fetched from server. */ export interface ISettings {} /* eslint-disable-line @typescript-eslint/no-empty-object-type */ + +/** + * The area where the chat is displayed. + */ +export type ChatArea = 'sidebar' | 'main'; diff --git a/packages/jupyter-chat/src/widgets/multichat-panel.tsx b/packages/jupyter-chat/src/widgets/multichat-panel.tsx index 582121a8..515e4306 100644 --- a/packages/jupyter-chat/src/widgets/multichat-panel.tsx +++ b/packages/jupyter-chat/src/widgets/multichat-panel.tsx @@ -155,7 +155,8 @@ export class MultiChatPanel extends SidePanel { attachmentOpenerRegistry: this._attachmentOpenerRegistry, inputToolbarRegistry, messageFooterRegistry: this._messageFooterRegistry, - welcomeMessage: this._welcomeMessage + welcomeMessage: this._welcomeMessage, + area: 'sidebar' }); const section = new ChatSection({ diff --git a/packages/jupyter-chat/style/chat.css b/packages/jupyter-chat/style/chat.css index bf07bded..0d11567a 100644 --- a/packages/jupyter-chat/style/chat.css +++ b/packages/jupyter-chat/style/chat.css @@ -2,20 +2,18 @@ * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ -.jp-chat-message:not(.jp-chat-message-stacked) { - padding: 1em 1em 0; -} -.jp-chat-message:not(:first-child, .jp-chat-message-stacked) { - border-top: 1px solid var(--jp-border-color2); +.jp-chat-rendered-markdown { + position: relative; } -.jp-chat-message.jp-chat-message-stacked { - padding: 0 1em; +.jp-chat-rendered-markdown hr { + color: #00000026; + background-color: transparent; } -.jp-chat-rendered-markdown { - position: relative; +.jp-chat-rendered-markdown .jp-RenderedHTMLCommon > :last-child { + margin-bottom: 0; } /* @@ -38,7 +36,7 @@ overflow-x: auto; white-space: pre; margin: 0; - padding: 4px 2px 0 6px; + padding: 4px 6px; border: var(--jp-border-width) solid var(--jp-cell-editor-border-color); } @@ -53,7 +51,7 @@ } .jp-chat-toolbar { - display: none; + visibility: hidden; position: absolute; right: 2px; top: 2px; @@ -67,107 +65,13 @@ } .jp-chat-rendered-markdown:hover .jp-chat-toolbar { - display: inherit; + visibility: visible; } .jp-chat-toolbar > .jp-ToolbarButtonComponent { margin-top: 0; } -.jp-chat-writers { - display: flex; - flex-wrap: wrap; - position: sticky; - bottom: 0; - padding: 8px; - background-color: var(--jp-layout-color0); - border-top: 1px solid var(--jp-border-color2); - z-index: 1; -} - -.jp-chat-writers-content { - display: flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; -} - -.jp-chat-writer-item { - display: flex; - align-items: center; - gap: 6px; -} - -.jp-chat-writer-name { - color: var(--jp-ui-font-color1); - font-weight: 500; -} - -.jp-chat-writer-separator { - color: var(--jp-ui-font-color2); -} - -.jp-chat-writing-status { - display: flex; - align-items: center; - gap: 8px; -} - -.jp-chat-writing-text { - color: var(--jp-ui-font-color2); -} - -/* Animated typing indicator */ -.jp-chat-typing-indicator { - display: flex; - align-items: center; - gap: 2px; - padding: 2px 4px; -} - -.jp-chat-typing-dot { - width: 4px; - height: 4px; - border-radius: 50%; - background-color: var(--jp-brand-color1); - animation: jp-chat-typing-bounce 1.4s infinite ease-in-out; -} - -.jp-chat-typing-dot:nth-child(1) { - animation-delay: -0.32s; -} - -.jp-chat-typing-dot:nth-child(2) { - animation-delay: -0.16s; -} - -.jp-chat-typing-dot:nth-child(3) { - animation-delay: 0s; -} - -/* Keyframe animations */ -@keyframes jp-chat-typing-bounce { - 0%, - 80%, - 100% { - transform: scale(0.8); - opacity: 0.5; - } - - 40% { - transform: scale(1.2); - opacity: 1; - } -} - -.jp-chat-writers > div { - display: flex; - align-items: center; - gap: 0.2em; - white-space: pre; - padding-left: 0.5em; -} - .jp-chat-navigation { position: absolute; right: 10px; @@ -197,40 +101,8 @@ bottom: 120px; } -.jp-chat-attachments { - display: flex; - gap: 4px; - flex-wrap: wrap; - min-height: 1.5em; - padding: 4px 0; -} - -.jp-chat-attachment { - border: solid 1px; - border-radius: 10px; - margin: 0 0.2em; - padding: 0 0.3em; - align-content: center; - background-color: var(--jp-border-color3); - flex-shrink: 0; -} - -.jp-chat-attachment .jp-chat-attachment-clickable:hover { - cursor: pointer; -} - -.jp-chat-command-name { - font-weight: normal; - margin: 5px; -} - -.jp-chat-command-description { - color: gray; - margin: 5px; -} - .jp-chat-mention { - border-radius: 10px; - padding: 0 0.2em; - background-color: var(--jp-brand-color4); + border-radius: 4px; + padding: 2px 0; + font-weight: bold; } diff --git a/packages/jupyter-chat/style/input.css b/packages/jupyter-chat/style/input.css index f00673de..307b3b58 100644 --- a/packages/jupyter-chat/style/input.css +++ b/packages/jupyter-chat/style/input.css @@ -42,64 +42,6 @@ white-space: nowrap; } -/* - * INPUT TEXT FIELD - */ -.jp-chat-input-component { - border: 1px solid var(--jp-ui-font-color3); - border-radius: 4px; -} - -.jp-chat-input-textfield .jp-chat-input-component::before, -.jp-chat-input-textfield .jp-chat-input-component::after { - border-bottom: unset !important; -} - -/* Use the textfield label below the input. */ -.jp-chat-input-textfield .jp-chat-input-component { - padding-top: 8px !important; - padding-bottom: 15px !important; - background-color: unset !important; -} - -.jp-chat-input-textfield label { - top: unset; - left: unset; - bottom: 0; - right: 0; - color: var(--jp-ui-font-color2) !important; -} - -/* - * INPUT TOOLBAR - */ -.jp-chat-input-toolbar { - gap: 1px; - align-self: flex-end; - min-height: unset !important; - margin-bottom: 4px; -} - -.jp-chat-input-toolbar .jp-chat-tooltipped-wrap button { - border-radius: 0; - min-width: unset; -} - -.jp-chat-input-toolbar .jp-chat-tooltipped-wrap:first-child button { - border-top-left-radius: 2px; - border-bottom-left-radius: 2px; -} - -.jp-chat-input-toolbar .jp-chat-tooltipped-wrap:last-child button { - border-top-right-radius: 2px; - border-bottom-right-radius: 2px; -} - -.jp-chat-input-toolbar .jp-chat-attach-button, -.jp-chat-input-toolbar .jp-chat-cancel-button { - padding: 4px; -} - .jp-chat-input-toolbar .jp-chat-send-include-opener { padding: 4px 0; } diff --git a/packages/jupyterlab-chat/src/factory.ts b/packages/jupyterlab-chat/src/factory.ts index 2badb370..22d250f9 100644 --- a/packages/jupyterlab-chat/src/factory.ts +++ b/packages/jupyterlab-chat/src/factory.ts @@ -4,6 +4,7 @@ */ import { + ChatArea, ChatWidget, IActiveCellManager, IAttachmentOpenerRegistry, @@ -99,6 +100,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< context.attachmentOpenerRegistry = this._attachmentOpenerRegistry; context.messageFooterRegistry = this._messageFooterRegistry; context.welcomeMessage = this._welcomeMessage; + context.area = 'main'; if (this._inputToolbarFactory) { context.inputToolbarRegistry = this._inputToolbarFactory.create(); } @@ -137,6 +139,7 @@ export namespace ChatWidgetFactory { inputToolbarRegistry?: IInputToolbarRegistry; messageFooterRegistry?: IMessageFooterRegistry; welcomeMessage?: string; + area?: ChatArea; } export interface IOptions diff --git a/ui-tests/tests/code-toolbar.spec.ts b/ui-tests/tests/code-toolbar.spec.ts index 4817cd69..e947060b 100644 --- a/ui-tests/tests/code-toolbar.spec.ts +++ b/ui-tests/tests/code-toolbar.spec.ts @@ -58,7 +58,7 @@ test.describe('#codeToolbar', () => { test('buttons should be disabled without notebook', 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'); await sendMessage(page, FILENAME, MESSAGE); await expect(toolbarButtons).toHaveCount(4); @@ -73,7 +73,7 @@ test.describe('#codeToolbar', () => { }) => { 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'); await page.notebook.createNew(); @@ -88,7 +88,7 @@ test.describe('#codeToolbar', () => { }) => { 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(); @@ -102,7 +102,7 @@ test.describe('#codeToolbar', () => { test('insert code above', 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(); @@ -123,7 +123,7 @@ test.describe('#codeToolbar', () => { test('insert code below', 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(); diff --git a/ui-tests/tests/input-toolbar.spec.ts b/ui-tests/tests/input-toolbar.spec.ts index 870157c1..ae5cba8c 100644 --- a/ui-tests/tests/input-toolbar.spec.ts +++ b/ui-tests/tests/input-toolbar.spec.ts @@ -53,7 +53,7 @@ test.describe('#inputToolbar', () => { '.jp-chat-input-container .jp-chat-input-toolbar' ); await expect(inputToolbar).toBeVisible(); - await expect(inputToolbar.locator('button')).toHaveCount(2); + await expect(inputToolbar.locator('button')).toHaveCount(1); expect(inputToolbar.locator('.jp-chat-attach-button')).not.toBeAttached(); // The side panel chat input should contain the 'attach' button. @@ -62,7 +62,7 @@ test.describe('#inputToolbar', () => { '.jp-chat-input-container .jp-chat-input-toolbar' ); await expect(inputToolbarSide).toBeVisible(); - await expect(inputToolbarSide.locator('button')).toHaveCount(3); + await expect(inputToolbarSide.locator('button')).toHaveCount(2); expect(inputToolbarSide.locator('.jp-chat-attach-button')).toBeAttached(); }); }); diff --git a/ui-tests/tests/message-toolbar.spec.ts b/ui-tests/tests/message-toolbar.spec.ts index bcc1e7c7..08712577 100644 --- a/ui-tests/tests/message-toolbar.spec.ts +++ b/ui-tests/tests/message-toolbar.spec.ts @@ -18,8 +18,6 @@ test.use({ }); test.describe('#messageToolbar', () => { - const additionnalContent = ' Messages can be edited'; - const msg = { type: 'msg', id: UUID.uuid4(), @@ -61,74 +59,6 @@ test.describe('#messageToolbar', () => { await expect(message.locator('.jp-chat-toolbar')).toBeVisible(); }); - test('should update the message', async ({ page }) => { - const chatPanel = await openChat(page, FILENAME); - const message = chatPanel - .locator('.jp-chat-messages-container .jp-chat-message') - .first(); - const messageContent = message.locator('.jp-chat-rendered-markdown'); - - // Should display the message toolbar - await messageContent.hover({ position: { x: 5, y: 5 } }); - await messageContent.locator('.jp-chat-toolbar jp-button').first().click(); - - await expect(messageContent).not.toBeVisible(); - - const editInput = chatPanel - .locator('.jp-chat-messages-container .jp-chat-input-container') - .getByRole('combobox'); - - await expect(editInput).toBeVisible(); - await editInput.focus(); - await editInput.press('End'); - await editInput.pressSequentially(additionnalContent); - await editInput.press('Enter'); - - // It seems that the markdown renderer adds a new line. - await expect(messageContent).toHaveText( - MSG_CONTENT + additionnalContent + '\n' - ); - expect( - await message.locator('.jp-chat-message-header').textContent() - ).toContain('(edited)'); - }); - - test('should cancel message edition', async ({ page }) => { - const chatPanel = await openChat(page, FILENAME); - const message = chatPanel - .locator('.jp-chat-messages-container .jp-chat-message') - .first(); - const messageContent = message.locator('.jp-chat-rendered-markdown'); - - // Should display the message toolbar - await messageContent.hover({ position: { x: 5, y: 5 } }); - await messageContent.locator('.jp-chat-toolbar jp-button').first().click(); - - await expect(messageContent).not.toBeVisible(); - - const editInput = chatPanel - .locator('.jp-chat-messages-container .jp-chat-input-container') - .getByRole('combobox'); - - await expect(editInput).toBeVisible(); - await editInput.focus(); - await editInput.press('End'); - await editInput.pressSequentially(additionnalContent); - - const cancelButton = chatPanel - .locator('.jp-chat-messages-container .jp-chat-input-container') - .getByTitle('Cancel edition'); - await expect(cancelButton).toBeVisible(); - await cancelButton.click(); - await expect(editInput).not.toBeVisible(); - - // It seems that the markdown renderer adds a new line. - await expect(messageContent).toHaveText(MSG_CONTENT + '\n'); - expect( - await message.locator('.jp-chat-message-header').textContent() - ).not.toContain('(edited)'); - }); - test('should set the message as deleted', async ({ page }) => { const chatPanel = await openChat(page, FILENAME); const message = chatPanel @@ -138,11 +68,8 @@ test.describe('#messageToolbar', () => { // Should display the message toolbar await messageContent.hover({ position: { x: 5, y: 5 } }); - await messageContent.locator('.jp-chat-toolbar jp-button').last().click(); + await messageContent.locator('.jp-chat-toolbar button').last().click(); await expect(messageContent).not.toBeVisible(); - expect( - await message.locator('.jp-chat-message-header').textContent() - ).toContain('(message deleted)'); }); }); diff --git a/ui-tests/tests/raw-time.spec.ts b/ui-tests/tests/raw-time.spec.ts deleted file mode 100644 index 99e90f86..00000000 --- a/ui-tests/tests/raw-time.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) Jupyter Development Team. - * Distributed under the terms of the Modified BSD License. - */ - -import { expect, galata, test } from '@jupyterlab/galata'; -import { UUID } from '@lumino/coreutils'; - -import { openChat, sendMessage, USER } from './test-utils'; - -const FILENAME = 'my-chat.chat'; -const MSG_CONTENT = 'Hello World!'; -const USERNAME = USER.identity.username; - -test.use({ - mockUser: USER, - mockSettings: { ...galata.DEFAULT_SETTINGS } -}); - -test.describe('#raw_time', () => { - const msg_raw_time = { - type: 'msg', - id: UUID.uuid4(), - sender: USERNAME, - body: MSG_CONTENT, - time: 1714116341, - raw_time: true - }; - const msg_verif = { - type: 'msg', - id: UUID.uuid4(), - sender: USERNAME, - body: MSG_CONTENT, - time: 1714116341, - raw_time: false - }; - const chatContent = { - messages: [msg_raw_time, msg_verif], - users: {} - }; - chatContent.users[USERNAME] = USER.identity; - - test.beforeEach(async ({ page }) => { - // Create a chat file with content - await page.filebrowser.contents.uploadContent( - JSON.stringify(chatContent), - 'text', - FILENAME - ); - }); - - test.afterEach(async ({ page }) => { - if (await page.filebrowser.contents.fileExists(FILENAME)) { - await page.filebrowser.contents.deleteFile(FILENAME); - } - }); - - test('message timestamp should be raw according to file content', async ({ - page - }) => { - const chatPanel = await openChat(page, FILENAME); - const messages = chatPanel.locator( - '.jp-chat-messages-container .jp-chat-message' - ); - - const raw_time = messages.locator('.jp-chat-message-time').first(); - expect(await raw_time.getAttribute('title')).toBe('Unverified time'); - expect(await raw_time.textContent()).toMatch(/\*$/); - - const verified_time = messages.locator('.jp-chat-message-time').last(); - expect(await verified_time.getAttribute('title')).toBe(''); - expect(await verified_time.textContent()).toMatch(/[^\*]$/); - }); - - test('time for new message should not be raw', async ({ page }) => { - const chatPanel = await openChat(page, FILENAME); - const messages = chatPanel.locator( - '.jp-chat-messages-container .jp-chat-message' - ); - - // Send a new message - await sendMessage(page, FILENAME, MSG_CONTENT); - - expect(messages).toHaveCount(3); - await expect( - messages.locator('.jp-chat-message-time').last() - ).toHaveAttribute('title', ''); - }); -}); diff --git a/ui-tests/tests/send-message.spec.ts b/ui-tests/tests/send-message.spec.ts index 83e5a265..2e5a79d3 100644 --- a/ui-tests/tests/send-message.spec.ts +++ b/ui-tests/tests/send-message.spec.ts @@ -142,155 +142,4 @@ test.describe('#sendMessages', () => { messages.locator('.jp-chat-message .jp-chat-rendered-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 code 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. - const rendered = messages.locator( - '.jp-chat-message .jp-chat-rendered-markdown' - ); - await expect(rendered).toHaveText(`${MSG_CONTENT}\n${cellContent}\n`); - - // Code should have python language class. - await expect(rendered.locator('code')).toHaveClass('language-python'); - }); - - test('should send with markdown cell content', async ({ page }) => { - const cellContent = 'markdown content'; - 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 after changing it to markdown. - const cell = (await page.notebook.getCellLocator(0))!; - await page.notebook.setCellType(0, 'markdown'); - 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. - const rendered = messages.locator( - '.jp-chat-message .jp-chat-rendered-markdown' - ); - await expect(rendered).toHaveText(`${MSG_CONTENT}\n${cellContent}\n`); - - // Code should not have python language class since it come from a markdown cell. - await expect(rendered.locator('code')).toHaveClass(''); - }); - - 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. - const rendered = messages.locator( - '.jp-chat-message .jp-chat-rendered-markdown' - ); - await expect(rendered).toHaveText(`${MSG_CONTENT}\nprint\n`); - - // Code should have python or ipython language class. - await expect(rendered.locator('code')).toHaveClass(/language-[i]?python/); - }); }); diff --git a/ui-tests/tests/ui-config.spec.ts b/ui-tests/tests/ui-config.spec.ts index 979c1ea6..d26c2c06 100644 --- a/ui-tests/tests/ui-config.spec.ts +++ b/ui-tests/tests/ui-config.spec.ts @@ -210,41 +210,7 @@ test.describe('#typingNotification', () => { await guestInput.press('a'); await expect(writers).toBeAttached(); const start = Date.now(); - await expect(writers).toHaveText(/jovyan_2 is writing/); - await expect(writers).not.toBeAttached(); - - // Message should disappear after 1s, but this delay include the awareness update. - expect(Date.now() - start).toBeLessThanOrEqual(2000); - }); - - test('should display typing user editing a message', async ({ page }) => { - const chatPanel = await openChat(page, FILENAME); - const writers = chatPanel.locator('.jp-chat-writers'); - - const guestChatPanel = await openChat(guestPage, FILENAME); - - await sendMessage(guestPage, FILENAME, 'test'); - await expect(writers).not.toBeAttached(); - const message = guestChatPanel - .locator('.jp-chat-messages-container .jp-chat-message') - .first(); - const messageContent = message.locator('.jp-chat-rendered-markdown'); - - // Should display the message toolbar - await messageContent.hover({ position: { x: 5, y: 5 } }); - await messageContent.locator('.jp-chat-toolbar jp-button').first().click(); - - const editInput = guestChatPanel - .locator('.jp-chat-messages-container .jp-chat-input-container') - .getByRole('combobox'); - - await editInput.focus(); - - await editInput.press('a'); - await expect(writers).toBeAttached(); - const start = Date.now(); - await expect(writers).toHaveText(/jovyan_2 is writing/); - await expect(writers).not.toBeAttached(); + await expect(writers).toHaveText(/jovyan_2 is typing/); // Message should disappear after 1s, but this delay include the awareness update. expect(Date.now() - start).toBeLessThanOrEqual(2000); @@ -276,14 +242,17 @@ test.describe('#typingNotification', () => { await guestInput.press('a'); - let visible = true; + let hasContent = true; try { - await page.waitForCondition(() => writers.isVisible(), 3000); + await page.waitForCondition( + async () => !!(await writers.textContent())?.trim(), + 3000 + ); } catch { - visible = false; + hasContent = false; } - if (visible) { + if (hasContent) { throw Error('The typing notification should not be attached.'); } }); @@ -339,12 +308,11 @@ test.describe('#typingNotification', () => { await guest2Input.press('a'); await expect(writers).toBeAttached(); - const regexp = /JP(jovyan_[2|3]) and JP(jovyan_[2|3]) are writing/; + const regexp = /jovyan_[2|3] and jovyan_[2|3] are typing/; await expect(writers).toHaveText(regexp); const result = regexp.exec((await writers.textContent()) ?? ''); expect(result?.[1] !== undefined); expect(result?.[1] !== result?.[2]); - await expect(writers).not.toBeAttached(); }); }); diff --git a/ui-tests/tests/ui-config.spec.ts-snapshots/not-stacked-messages-linux.png b/ui-tests/tests/ui-config.spec.ts-snapshots/not-stacked-messages-linux.png index 377dea00..dfa47efc 100644 Binary files a/ui-tests/tests/ui-config.spec.ts-snapshots/not-stacked-messages-linux.png and b/ui-tests/tests/ui-config.spec.ts-snapshots/not-stacked-messages-linux.png differ diff --git a/ui-tests/tests/ui-config.spec.ts-snapshots/stacked-messages-linux.png b/ui-tests/tests/ui-config.spec.ts-snapshots/stacked-messages-linux.png index 8f791839..dfa47efc 100644 Binary files a/ui-tests/tests/ui-config.spec.ts-snapshots/stacked-messages-linux.png and b/ui-tests/tests/ui-config.spec.ts-snapshots/stacked-messages-linux.png differ diff --git a/ui-tests/tests/unread.spec.ts-snapshots/navigation-top-linux.png b/ui-tests/tests/unread.spec.ts-snapshots/navigation-top-linux.png index d386a81f..85a55557 100644 Binary files a/ui-tests/tests/unread.spec.ts-snapshots/navigation-top-linux.png and b/ui-tests/tests/unread.spec.ts-snapshots/navigation-top-linux.png differ