mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-07 02:58:50 +01:00
✍️ 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:
parent
f489aee518
commit
f307488dd4
16 changed files with 244 additions and 225 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
supportsFiles,
|
||||
|
|
@ -9,7 +10,7 @@ import { AttachmentIcon } from '~/components/svg';
|
|||
import { FileUpload } from '~/components/ui';
|
||||
import { useFileHandling } from '~/hooks';
|
||||
|
||||
export default function AttachFile({
|
||||
const AttachFile = ({
|
||||
endpoint,
|
||||
endpointType,
|
||||
disabled = false,
|
||||
|
|
@ -17,7 +18,7 @@ export default function AttachFile({
|
|||
endpoint: EModelEndpoint | '';
|
||||
endpointType?: EModelEndpoint;
|
||||
disabled?: boolean | null;
|
||||
}) {
|
||||
}) => {
|
||||
const { handleFileChange } = useFileHandling();
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
|
|
@ -45,4 +46,6 @@ export default function AttachFile({
|
|||
</FileUpload>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default React.memo(AttachFile);
|
||||
|
|
|
|||
|
|
@ -1,32 +1,51 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import type { Control } from 'react-hook-form';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
|
||||
import { SendIcon } from '~/components/svg';
|
||||
import { cn } from '~/utils';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function SendButton({ text, disabled }) {
|
||||
const localize = useLocalize();
|
||||
type SendButtonProps = {
|
||||
disabled: boolean;
|
||||
control: Control<{ text: string }>;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
disabled={!text || disabled}
|
||||
className={cn(
|
||||
'absolute bottom-1.5 right-2 rounded-lg border border-black p-0.5 text-white transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white md:bottom-3 md:right-3',
|
||||
)}
|
||||
data-testid="send-button"
|
||||
type="submit"
|
||||
>
|
||||
<span className="" data-state="closed">
|
||||
<SendIcon size={24} />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={10}>
|
||||
{localize('com_nav_send_message')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
const SubmitButton = React.memo(
|
||||
forwardRef((props: { disabled: boolean }, ref: React.ForwardedRef<HTMLButtonElement>) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={props.disabled}
|
||||
className={cn(
|
||||
'absolute bottom-1.5 right-2 rounded-lg border border-black p-0.5 text-white transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white md:bottom-3 md:right-3',
|
||||
)}
|
||||
data-testid="send-button"
|
||||
type="submit"
|
||||
>
|
||||
<span className="" data-state="closed">
|
||||
<SendIcon size={24} />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={10}>
|
||||
{localize('com_nav_send_message')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const SendButton = React.memo(
|
||||
forwardRef((props: SendButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
|
||||
const data = useWatch({ control: props.control });
|
||||
return <SubmitButton ref={ref} disabled={props.disabled || !data?.text} />;
|
||||
}),
|
||||
);
|
||||
|
||||
export default SendButton;
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import {
|
||||
supportsFiles,
|
||||
fileConfig as defaultFileConfig,
|
||||
mergeFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useTextarea } from '~/hooks';
|
||||
|
||||
export default function Textarea({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
setText,
|
||||
submitMessage,
|
||||
endpoint,
|
||||
endpointType,
|
||||
}) {
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const {
|
||||
textAreaRef,
|
||||
handlePaste,
|
||||
handleKeyUp,
|
||||
handleKeyDown,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
} = useTextarea({ setText, submitMessage, disabled });
|
||||
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
|
||||
return (
|
||||
<TextareaAutosize
|
||||
ref={textAreaRef}
|
||||
autoFocus
|
||||
value={value}
|
||||
disabled={!!disabled}
|
||||
onChange={onChange}
|
||||
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]',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue