Skip to content

Commit

Permalink
implement new send button
Browse files Browse the repository at this point in the history
  • Loading branch information
dlqqq committed Jul 23, 2024
1 parent 8c942c1 commit ab8a25b
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 56 deletions.
44 changes: 23 additions & 21 deletions packages/jupyter-ai/src/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown, void>;
sendWithShiftEnter: boolean;
sx?: SxProps<Theme>;
Expand Down Expand Up @@ -94,6 +93,7 @@ function renderSlashCommandOption(
}

export function ChatInput(props: ChatInputProps): JSX.Element {
const [input, setInput] = useState('');
const [slashCommandOptions, setSlashCommandOptions] = useState<
SlashCommandOption[]
>([]);
Expand Down Expand Up @@ -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
Expand All @@ -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<HTMLDivElement>) {
Expand Down Expand Up @@ -233,7 +235,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
</span>
);

const inputExists = !!props.value.trim();
const inputExists = !!input.trim();
const sendButtonProps: SendButtonProps = {
onSend,
sendWithShiftEnter: props.sendWithShiftEnter,
Expand All @@ -247,9 +249,9 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
<Autocomplete
autoHighlight
freeSolo
inputValue={props.value}
inputValue={input}
onInputChange={(_, newValue: string) => {
props.onChange(newValue);
setInput(newValue);
}}
onHighlightChange={
/**
Expand Down Expand Up @@ -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 : ' '}
/>
)}
/>
Expand Down
155 changes: 139 additions & 16 deletions packages/jupyter-ai/src/components/chat-input/send-button.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,168 @@
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;
activeCellHasError: boolean;
};

export function SendButton(props: SendButtonProps): JSX.Element {
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(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 (
<TooltippedIconButton
onClick={() => props.onSend()}
disabled={disabled}
tooltip={tooltip}
iconButtonProps={{
size: 'small',
color: 'primary',
title: defaultTooltip
}}
>
<SendIcon />
</TooltippedIconButton>
<Box sx={{ display: 'flex', flexWrap: 'nowrap' }}>
<TooltippedButton
onClick={() => props.onSend()}
disabled={disabled}
tooltip={tooltip}
buttonProps={{
size: 'small',
title: defaultTooltip,
variant: 'contained'
}}
sx={{
minWidth: 'unset',
borderRadius: '2px 0px 0px 2px'
}}
>
<SendIcon />
</TooltippedButton>
<TooltippedButton
onClick={e => openMenu(e.currentTarget)}
disabled={disabled}
tooltip=""
buttonProps={{
variant: 'contained'
}}
sx={{
minWidth: 'unset',
padding: '4px 0px',
borderRadius: '0px 2px 2px 0px',
borderLeft: '1px solid white'
}}
>
<KeyboardArrowDown />
</TooltippedButton>
<Menu
open={menuOpen}
onClose={closeMenu}
disableAutoFocusItem
anchorEl={menuAnchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
sx={{
'& .MuiMenuItem-root': {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
'& svg': {
lineHeight: 0
}
}}
>
<MenuItem
onClick={() => sendWithSelection()}
disabled={includeSelectionDisabled}
>
<includeSelectionIcon.react />
<Box>
<Typography display="block">Send message with selection</Typography>
<Typography display="block" sx={{ opacity: 0.618 }}>
{includeSelectionTooltip}
</Typography>
</Box>
</MenuItem>
</Menu>
</Box>
);
}
19 changes: 1 addition & 18 deletions packages/jupyter-ai/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ function ChatBody({
AiService.PendingMessage[]
>([...chatHandler.history.pending_messages]);
const [showWelcomeMessage, setShowWelcomeMessage] = useState<boolean>(false);
const [input, setInput] = useState('');
const [sendWithShiftEnter, setSendWithShiftEnter] = useState(true);

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -139,9 +124,7 @@ function ChatBody({
<PendingMessages messages={pendingMessages} />
</ScrollContainer>
<ChatInput
value={input}
onChange={setInput}
onSend={onSend}
chatHandler={chatHandler}
focusInputSignal={focusInputSignal}
sx={{
paddingLeft: 4,
Expand Down
2 changes: 1 addition & 1 deletion packages/jupyter-ai/src/contexts/active-cell-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class ActiveCellManager {
* `ActiveCellContentWithError` object that describes both the active cell and
* the error output.
*/
getContent(withError: false): CellContent | null;
getContent(withError: false): CellContent;
getContent(withError: true): CellWithErrorContent | null;
getContent(withError = false): CellContent | CellWithErrorContent | null {
const sharedModel = this._activeCell?.model.sharedModel;
Expand Down

0 comments on commit ab8a25b

Please sign in to comment.