diff --git a/packages/jupyter-ai/src/components/chat-input.tsx b/packages/jupyter-ai/src/components/chat-input.tsx index 870863370..262642e04 100644 --- a/packages/jupyter-ai/src/components/chat-input.tsx +++ b/packages/jupyter-ai/src/components/chat-input.tsx @@ -24,11 +24,10 @@ import { ISignal } from '@lumino/signaling'; import { AiService } from '../handler'; import { SendButton, SendButtonProps } from './chat-input/send-button'; import { useActiveCellContext } from '../contexts/active-cell-context'; +import { ChatHandler } from '../chat_handler'; type ChatInputProps = { - value: string; - onChange: (newValue: string) => unknown; - onSend: (selection?: AiService.Selection) => unknown; + chatHandler: ChatHandler; focusInputSignal: ISignal; sendWithShiftEnter: boolean; sx?: SxProps; @@ -94,6 +93,7 @@ function renderSlashCommandOption( } export function ChatInput(props: ChatInputProps): JSX.Element { + const [input, setInput] = useState(''); const [slashCommandOptions, setSlashCommandOptions] = useState< SlashCommandOption[] >([]); @@ -148,24 +148,24 @@ export function ChatInput(props: ChatInputProps): JSX.Element { * chat input. Close the autocomplete when the user clears the chat input. */ useEffect(() => { - if (props.value === '/') { + if (input === '/') { setOpen(true); return; } - if (props.value === '') { + if (input === '') { setOpen(false); return; } - }, [props.value]); + }, [input]); /** * Effect: Set current slash command */ useEffect(() => { - const matchedSlashCommand = props.value.match(/^\s*\/\w+/); + const matchedSlashCommand = input.match(/^\s*\/\w+/); setCurrSlashCommand(matchedSlashCommand && matchedSlashCommand[0]); - }, [props.value]); + }, [input]); /** * Effect: ensure that the `highlighted` is never `true` when `open` is @@ -179,25 +179,27 @@ export function ChatInput(props: ChatInputProps): JSX.Element { } }, [open, highlighted]); - // TODO: unify the `onSend` implementation in `chat.tsx` and here once text - // selection is refactored. - function onSend() { - // case: /fix + function onSend(selection?: AiService.Selection) { + const prompt = input; + setInput(''); + + // if the current slash command is `/fix`, we always include a code cell + // with error output in the selection. if (currSlashCommand === '/fix') { const cellWithError = activeCell.manager.getContent(true); if (!cellWithError) { return; } - props.onSend({ - ...cellWithError, - type: 'cell-with-error' + props.chatHandler.sendMessage({ + prompt, + selection: { ...cellWithError, type: 'cell-with-error' } }); return; } - // default case - props.onSend(); + // otherwise, send a ChatRequest with the prompt and selection + props.chatHandler.sendMessage({ prompt, selection }); } function handleKeyDown(event: React.KeyboardEvent) { @@ -233,7 +235,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element { ); - const inputExists = !!props.value.trim(); + const inputExists = !!input.trim(); const sendButtonProps: SendButtonProps = { onSend, sendWithShiftEnter: props.sendWithShiftEnter, @@ -247,9 +249,9 @@ export function ChatInput(props: ChatInputProps): JSX.Element { { - props.onChange(newValue); + setInput(newValue); }} onHighlightChange={ /** @@ -311,7 +313,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element { FormHelperTextProps={{ sx: { marginLeft: 'auto', marginRight: 0 } }} - helperText={props.value.length > 2 ? helperText : ' '} + helperText={input.length > 2 ? helperText : ' '} /> )} /> diff --git a/packages/jupyter-ai/src/components/chat-input/send-button.tsx b/packages/jupyter-ai/src/components/chat-input/send-button.tsx index 6ab79a355..19204b611 100644 --- a/packages/jupyter-ai/src/components/chat-input/send-button.tsx +++ b/packages/jupyter-ai/src/components/chat-input/send-button.tsx @@ -1,10 +1,18 @@ -import React from 'react'; +import React, { useCallback, useState } from 'react'; +import { Box, Menu, MenuItem, Typography } from '@mui/material'; +import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import SendIcon from '@mui/icons-material/Send'; -import { TooltippedIconButton } from '../mui-extras/tooltipped-icon-button'; +import { TooltippedButton } from '../mui-extras/tooltipped-button'; +import { includeSelectionIcon } from '../../icons'; +import { useActiveCellContext } from '../../contexts/active-cell-context'; +import { useSelectionContext } from '../../contexts/selection-context'; +import { AiService } from '../../handler'; + +const FIX_TOOLTIP = '/fix requires an active code cell with an error'; export type SendButtonProps = { - onSend: () => unknown; + onSend: (selection?: AiService.Selection) => unknown; sendWithShiftEnter: boolean; currSlashCommand: string | null; inputExists: boolean; @@ -12,34 +20,149 @@ export type SendButtonProps = { }; export function SendButton(props: SendButtonProps): JSX.Element { + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const [textSelection] = useSelectionContext(); + const activeCell = useActiveCellContext(); + + const openMenu = useCallback((el: HTMLElement | null) => { + setMenuAnchorEl(el); + setMenuOpen(true); + }, []); + + const closeMenu = useCallback(() => { + setMenuOpen(false); + }, []); + const disabled = props.currSlashCommand === '/fix' ? !props.inputExists || !props.activeCellHasError : !props.inputExists; + const includeSelectionDisabled = !(activeCell.exists || textSelection); + + const includeSelectionTooltip = + props.currSlashCommand === '/fix' + ? FIX_TOOLTIP + : textSelection + ? `${textSelection.text.split('\n').length} lines selected` + : activeCell.exists + ? '1 active cell' + : 'No selection or active cell'; + const defaultTooltip = props.sendWithShiftEnter ? 'Send message (SHIFT+ENTER)' : 'Send message (ENTER)'; const tooltip = props.currSlashCommand === '/fix' && !props.activeCellHasError - ? '/fix requires a code cell with an error output selected' + ? FIX_TOOLTIP : !props.inputExists ? 'Message must not be empty' : defaultTooltip; + function sendWithSelection() { + // if the current slash command is `/fix`, `props.onSend()` should always + // include the code cell with error output, so the `selection` argument does + // not need to be defined. + if (props.currSlashCommand === '/fix') { + props.onSend(); + closeMenu(); + return; + } + + // otherwise, parse the text selection or active cell, with the text + // selection taking precedence. + if (textSelection?.text) { + props.onSend({ + type: 'text', + source: textSelection.text + }); + closeMenu(); + return; + } + + if (activeCell.exists) { + props.onSend({ + type: 'cell', + source: activeCell.manager.getContent(false).source + }); + closeMenu(); + return; + } + } + return ( - props.onSend()} - disabled={disabled} - tooltip={tooltip} - iconButtonProps={{ - size: 'small', - color: 'primary', - title: defaultTooltip - }} - > - - + + props.onSend()} + disabled={disabled} + tooltip={tooltip} + buttonProps={{ + size: 'small', + title: defaultTooltip, + variant: 'contained' + }} + sx={{ + minWidth: 'unset', + borderRadius: '2px 0px 0px 2px' + }} + > + + + openMenu(e.currentTarget)} + disabled={disabled} + tooltip="" + buttonProps={{ + variant: 'contained' + }} + sx={{ + minWidth: 'unset', + padding: '4px 0px', + borderRadius: '0px 2px 2px 0px', + borderLeft: '1px solid white' + }} + > + + + + sendWithSelection()} + disabled={includeSelectionDisabled} + > + + + Send message with selection + + {includeSelectionTooltip} + + + + + ); } diff --git a/packages/jupyter-ai/src/components/chat.tsx b/packages/jupyter-ai/src/components/chat.tsx index 519a2702d..41383d467 100644 --- a/packages/jupyter-ai/src/components/chat.tsx +++ b/packages/jupyter-ai/src/components/chat.tsx @@ -45,7 +45,6 @@ function ChatBody({ AiService.PendingMessage[] >([...chatHandler.history.pending_messages]); const [showWelcomeMessage, setShowWelcomeMessage] = useState(false); - const [input, setInput] = useState(''); const [sendWithShiftEnter, setSendWithShiftEnter] = useState(true); /** @@ -83,20 +82,6 @@ function ChatBody({ }; }, [chatHandler]); - // no need to append to messageGroups imperatively here. all of that is - // handled by the listeners registered in the effect hooks above. - // TODO: unify how text selection & cell selection are handled - const onSend = async (selection?: AiService.Selection) => { - const prompt = input; - setInput(''); - - // send message to backend - const messageId = await chatHandler.sendMessage({ prompt, selection }); - - // await reply from agent - await chatHandler.replyFor(messageId); - }; - const openSettingsView = () => { setShowWelcomeMessage(false); chatViewHandler(ChatView.Settings); @@ -139,9 +124,7 @@ function ChatBody({