✍️ refactor(Textarea): Optimize Text Input & Enhance UX (#2058)

* refactor(useDebouncedInput): make object as input arg and accept setter

* refactor(ChatForm/Textarea): consolidate textarea/form logic to one component, use react-hook-form, programmatically click send button instead of passing submitMessage, forwardRef and memoize SendButton

* refactor(Textarea): use Controller field value to avoid manual update of ref

* chore: remove forms provider

* chore: memoize AttachFile

* refactor(ChatForm/SendButton): only re-render SendButton when there is text input

* chore: make iconURL bigger

* chore: optimize Root/Nav

* refactor(SendButton): memoize disabled prop based on text

* chore: memoize Nav and ChatForm

* chore: remove textarea ref text on submission

* feat(EditMessage): Make Esc exit the edit mode and dismiss changes when editing a message

* style(MenuItem): Display the ☑️  icon only on the selected model
This commit is contained in:
Danny Avila 2024-03-11 09:18:10 -04:00 committed by GitHub
parent f489aee518
commit f307488dd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 244 additions and 225 deletions

View file

@ -1,17 +1,30 @@
import { useRecoilState } from 'recoil';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useRef } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { useForm } from 'react-hook-form';
import {
supportsFiles,
mergeFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useRequiresKey, useTextarea } from '~/hooks';
import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusOutlines } from '~/utils';
import { useChatContext } from '~/Providers';
import { useRequiresKey } from '~/hooks';
import AttachFile from './Files/AttachFile';
import StopButton from './StopButton';
import SendButton from './SendButton';
import FileRow from './Files/FileRow';
import Textarea from './Textarea';
import store from '~/store';
export default function ChatForm({ index = 0 }) {
const [text, setText] = useRecoilState(store.textByIndex(index));
const ChatForm = ({ index = 0 }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const { requiresKey } = useRequiresKey();
const { handlePaste, handleKeyUp, handleKeyDown, handleCompositionStart, handleCompositionEnd } =
useTextarea({ textAreaRef, submitButtonRef, disabled: !!requiresKey });
const {
ask,
@ -24,21 +37,34 @@ export default function ChatForm({ index = 0 }) {
setFilesLoading,
} = useChatContext();
const submitMessage = () => {
ask({ text });
setText('');
};
const methods = useForm<{ text: string }>({
defaultValues: { text: '' },
});
const submitMessage = useCallback(
(data?: { text: string }) => {
if (!data) {
return console.warn('No data provided to submitMessage');
}
ask({ text: data.text });
methods.reset();
textAreaRef.current?.setRangeText('', 0, data.text.length, 'end');
},
[ask, methods],
);
const { requiresKey } = useRequiresKey();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint;
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
return (
<form
onSubmit={(e) => {
e.preventDefault();
submitMessage();
}}
onSubmit={methods.handleSubmit((data) => submitMessage(data))}
className="stretch mx-2 flex flex-row gap-3 last:mb-2 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-2xl xl:max-w-3xl"
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
@ -55,14 +81,36 @@ export default function ChatForm({ index = 0 }) {
)}
/>
{endpoint && (
<Textarea
value={text}
disabled={requiresKey}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
setText={setText}
submitMessage={submitMessage}
endpoint={_endpoint}
endpointType={endpointType}
<TextareaAutosize
{...methods.register('text', {
required: true,
onChange: (e) => {
methods.setValue('text', e.target.value);
},
})}
autoFocus
ref={(e) => {
textAreaRef.current = e;
}}
disabled={!!requiresKey}
onPaste={handlePaste}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id="prompt-textarea"
tabIndex={0}
data-testid="text-input"
style={{ height: 44, overflowY: 'auto' }}
rows={1}
className={cn(
supportsFiles[endpointType ?? endpoint ?? ''] && !endpointFileConfig?.disabled
? ' pl-10 md:pl-[55px]'
: 'pl-3 md:pl-4',
'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 md:pr-12 ',
removeFocusOutlines,
'max-h-[65vh] md:max-h-[85vh]',
)}
/>
)}
<AttachFile
@ -74,7 +122,11 @@ export default function ChatForm({ index = 0 }) {
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton text={text} disabled={filesLoading || isSubmitting || requiresKey} />
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={!!(filesLoading || isSubmitting || requiresKey)}
/>
)
)}
</div>
@ -82,4 +134,6 @@ export default function ChatForm({ index = 0 }) {
</div>
</form>
);
}
};
export default memo(ChatForm);