mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +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]',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,11 @@ const EditPresetDialog = ({
|
|||
const localize = useLocalize();
|
||||
const { preset, setPreset } = useChatContext();
|
||||
const { setOption } = useSetIndexOptions(preset);
|
||||
const [onTitleChange, title] = useDebouncedInput(setOption, 'title', preset?.title);
|
||||
const [onTitleChange, title] = useDebouncedInput({
|
||||
setOption,
|
||||
optionKey: 'title',
|
||||
initialValue: preset?.title,
|
||||
});
|
||||
const [presetModalVisible, setPresetModalVisible] = useRecoilState(store.presetModalVisible);
|
||||
|
||||
const { data: availableEndpoints = [] } = useGetEndpointsQuery({
|
||||
|
|
|
|||
|
|
@ -119,7 +119,6 @@ const PresetItems: FC<{
|
|||
key={`preset-item-${preset.presetId}`}
|
||||
textClassName="text-xs max-w-[150px] sm:max-w-[200px] truncate md:max-w-full "
|
||||
title={getPresetTitle(preset)}
|
||||
disableHover={true}
|
||||
onClick={() => onSelectPreset(preset)}
|
||||
icon={
|
||||
Icon &&
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ type MenuItemProps = {
|
|||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disableHover?: boolean;
|
||||
// hoverContent?: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
|
|
@ -27,7 +26,6 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
icon,
|
||||
className = '',
|
||||
textClassName = '',
|
||||
disableHover = false,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
|
|
@ -47,10 +45,6 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
<div>
|
||||
<div className={cn('flex items-center gap-1 ')}>
|
||||
{icon && icon}
|
||||
{/* <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="icon-md shrink-0">
|
||||
<path d="M19.3975 1.35498C19.3746 1.15293 19.2037 1.00021 19.0004 1C18.7971 0.999793 18.6259 1.15217 18.6026 1.35417C18.4798 2.41894 18.1627 3.15692 17.6598 3.65983C17.1569 4.16274 16.4189 4.47983 15.3542 4.60264C15.1522 4.62593 14.9998 4.79707 15 5.00041C15.0002 5.20375 15.1529 5.37457 15.355 5.39746C16.4019 5.51605 17.1562 5.83304 17.6716 6.33906C18.1845 6.84269 18.5078 7.57998 18.6016 8.63539C18.6199 8.84195 18.7931 9.00023 19.0005 9C19.2078 8.99977 19.3806 8.84109 19.3985 8.6345C19.4883 7.59673 19.8114 6.84328 20.3273 6.32735C20.8433 5.81142 21.5967 5.48834 22.6345 5.39851C22.8411 5.38063 22.9998 5.20782 23 5.00045C23.0002 4.79308 22.842 4.61992 22.6354 4.60157C21.58 4.50782 20.8427 4.18447 20.3391 3.67157C19.833 3.15623 19.516 2.40192 19.3975 1.35498Z" fill="currentColor"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M11 3C11.4833 3 11.8974 3.34562 11.9839 3.82111C12.4637 6.46043 13.279 8.23983 14.5196 9.48039C15.7602 10.721 17.5396 11.5363 20.1789 12.0161C20.6544 12.1026 21 12.5167 21 13C21 13.4833 20.6544 13.8974 20.1789 13.9839C17.5396 14.4637 15.7602 15.279 14.5196 16.5196C13.279 17.7602 12.4637 19.5396 11.9839 22.1789C11.8974 22.6544 11.4833 23 11 23C10.5167 23 10.1026 22.6544 10.0161 22.1789C9.53625 19.5396 8.72096 17.7602 7.48039 16.5196C6.23983 15.279 4.46043 14.4637 1.82111 13.9839C1.34562 13.8974 1 13.4833 1 13C1 12.5167 1.34562 12.1026 1.82111 12.0161C4.46043 11.5363 6.23983 10.721 7.48039 9.48039C8.72096 8.23983 9.53625 6.46043 10.0161 3.82111C10.1026 3.34562 10.5167 3 11 3ZM5.66618 13C6.9247 13.5226 7.99788 14.2087 8.89461 15.1054C9.79134 16.0021 10.4774 17.0753 11 18.3338C11.5226 17.0753 12.2087 16.0021 13.1054 15.1054C14.0021 14.2087 15.0753 13.5226 16.3338 13C15.0753 12.4774 14.0021 11.7913 13.1054 10.8946C12.2087 9.99788 11.5226 8.9247 11 7.66618C10.4774 8.9247 9.79134 9.99788 8.89461 10.8946C7.99788 11.7913 6.9247 12.4774 5.66618 13Z" fill="currentColor"/>
|
||||
</svg> */}
|
||||
<div className={cn('truncate', textClassName)}>
|
||||
{title}
|
||||
<div className="text-token-text-tertiary">{description}</div>
|
||||
|
|
@ -76,45 +70,6 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
/>
|
||||
</svg>
|
||||
)}
|
||||
{!selected && !disableHover && (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md invisible block gap-x-1 group-hover:visible group-hover:flex"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* {(hoverCondition && hoverContent) && (
|
||||
hoverContent
|
||||
// <div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
|
||||
// <div className="">New Chat</div>
|
||||
// <svg
|
||||
// width="24"
|
||||
// height="24"
|
||||
// viewBox="0 0 24 24"
|
||||
// fill="none"
|
||||
// xmlns="http://www.w3.org/2000/svg"
|
||||
// className="icon-md"
|
||||
// >
|
||||
// <path
|
||||
// fillRule="evenodd"
|
||||
// clipRule="evenodd"
|
||||
// d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
|
||||
// fill="currentColor"
|
||||
// />
|
||||
// </svg>
|
||||
// </div>
|
||||
)
|
||||
} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TEditProps } from '~/common';
|
||||
import Container from '~/components/Messages/Content/Container';
|
||||
|
|
@ -94,6 +94,16 @@ const EditMessage = ({
|
|||
enterEdit(true);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
enterEdit(true);
|
||||
}
|
||||
},
|
||||
[enterEdit],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TextareaAutosize
|
||||
|
|
@ -101,6 +111,7 @@ const EditMessage = ({
|
|||
onChange={(e) => {
|
||||
setEditedText(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="message-text-editor"
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light whitespace-pre-wrap break-words dark:text-gray-20',
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ const Icon: React.FC<IconProps> = (props) => {
|
|||
<UnknownIcon
|
||||
iconURL={props.iconURL}
|
||||
endpoint={endpoint ?? ''}
|
||||
className="icon-sm"
|
||||
className="icon-md"
|
||||
context="message"
|
||||
/>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -33,16 +33,16 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
|||
|
||||
const { model, endpoint, assistant_id, endpointType, promptPrefix, instructions } =
|
||||
conversation ?? {};
|
||||
const [onPromptPrefixChange, promptPrefixValue] = useDebouncedInput(
|
||||
const [onPromptPrefixChange, promptPrefixValue] = useDebouncedInput({
|
||||
setOption,
|
||||
'promptPrefix',
|
||||
promptPrefix,
|
||||
);
|
||||
const [onInstructionsChange, instructionsValue] = useDebouncedInput(
|
||||
optionKey: 'promptPrefix',
|
||||
initialValue: promptPrefix,
|
||||
});
|
||||
const [onInstructionsChange, instructionsValue] = useDebouncedInput({
|
||||
setOption,
|
||||
'instructions',
|
||||
instructions,
|
||||
);
|
||||
optionKey: 'instructions',
|
||||
initialValue: instructions,
|
||||
});
|
||||
|
||||
const activeAssistant = useMemo(() => {
|
||||
if (assistant_id) {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ export default function MobileNav({
|
|||
type="button"
|
||||
data-testid="mobile-header-new-chat-button"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-md hover:text-gray-800 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white active:opacity-50 dark:hover:text-white"
|
||||
onClick={() => setNavVisible((prev) => !prev)}
|
||||
onClick={() =>
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="sr-only">{localize('com_nav_open_sidebar')}</span>
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useParams } from 'react-router-dom';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import {
|
||||
useMediaQuery,
|
||||
|
|
@ -21,7 +21,7 @@ import NewChat from './NewChat';
|
|||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Nav({ navVisible, setNavVisible }) {
|
||||
const Nav = ({ navVisible, setNavVisible }) => {
|
||||
const { conversationId } = useParams();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
|
||||
|
|
@ -97,7 +97,10 @@ export default function Nav({ navVisible, setNavVisible }) {
|
|||
};
|
||||
|
||||
const toggleNavVisible = () => {
|
||||
setNavVisible((prev: boolean) => !prev);
|
||||
setNavVisible((prev: boolean) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
});
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
|
|
@ -179,4 +182,6 @@ export default function Nav({ navVisible, setNavVisible }) {
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(Nav);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
useLocalize,
|
||||
useConversation,
|
||||
useNewConvo,
|
||||
useOriginNavigate,
|
||||
useLocalStorage,
|
||||
} from '~/hooks';
|
||||
import { useLocalize, useNewConvo, useLocalStorage } from '~/hooks';
|
||||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
||||
import { NewChatIcon } from '~/components/svg';
|
||||
import { getEndpointField } from '~/utils';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function NewChat({
|
||||
toggleNav,
|
||||
|
|
@ -20,8 +15,7 @@ export default function NewChat({
|
|||
subHeaders?: React.ReactNode;
|
||||
}) {
|
||||
const { newConversation: newConvo } = useNewConvo();
|
||||
const { newConversation } = useConversation();
|
||||
const navigate = useOriginNavigate();
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
|
@ -36,8 +30,7 @@ export default function NewChat({
|
|||
if (event.button === 0 && !event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
newConvo();
|
||||
newConversation();
|
||||
navigate('new');
|
||||
navigate('/c/new');
|
||||
toggleNav();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,34 +1,48 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { TSetOption } from '~/common';
|
||||
|
||||
/** A custom hook that accepts a setOption function and an option key (e.g., 'title').
|
||||
It manages a local state for the option value, a debounced setter function for that value,
|
||||
and returns the local state value, its setter, and an onChange handler suitable for inputs. */
|
||||
function useDebouncedInput(
|
||||
setOption: TSetOption,
|
||||
optionKey: string | number,
|
||||
initialValue: unknown,
|
||||
function useDebouncedInput({
|
||||
setOption,
|
||||
setter,
|
||||
optionKey,
|
||||
initialValue,
|
||||
delay = 450,
|
||||
): [
|
||||
}: {
|
||||
setOption?: TSetOption;
|
||||
setter?: SetterOrUpdater<string>;
|
||||
optionKey?: string | number;
|
||||
initialValue: unknown;
|
||||
delay?: number;
|
||||
}): [
|
||||
React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
|
||||
unknown,
|
||||
React.Dispatch<React.SetStateAction<unknown>>,
|
||||
SetterOrUpdater<string>,
|
||||
// (newValue: string) => void,
|
||||
] {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
/** A debounced function to call the passed setOption with the optionKey and new value.
|
||||
*
|
||||
Note: We use useCallback to ensure our debounced function is stable across renders. */
|
||||
const setDebouncedOption = useCallback(debounce(setOption(optionKey), delay), []);
|
||||
const setDebouncedOption = useCallback(
|
||||
debounce(setOption && optionKey ? setOption(optionKey) : setter, delay),
|
||||
[],
|
||||
);
|
||||
|
||||
/** An onChange handler that updates the local state and the debounced option */
|
||||
const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const newValue = e.target.value;
|
||||
setValue(newValue);
|
||||
setDebouncedOption(newValue);
|
||||
};
|
||||
|
||||
const onChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> = useCallback(
|
||||
(e) => {
|
||||
const newValue: unknown = e.target.value;
|
||||
setValue(newValue);
|
||||
setDebouncedOption(newValue);
|
||||
},
|
||||
[setDebouncedOption],
|
||||
);
|
||||
return [onChange, value, setValue];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import debounce from 'lodash/debounce';
|
|||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TEndpointOption } from 'librechat-data-provider';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
|
||||
import useGetSender from '~/hooks/Conversations/useGetSender';
|
||||
|
|
@ -12,6 +11,26 @@ import useLocalize from '~/hooks/useLocalize';
|
|||
|
||||
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
|
||||
|
||||
function insertTextAtCursor(element: HTMLTextAreaElement, textToInsert: string) {
|
||||
// Focus the element to ensure the insertion point is updated
|
||||
element.focus();
|
||||
|
||||
// Use the browser's built-in undoable actions if possible
|
||||
if (window.getSelection() && document.queryCommandSupported('insertText')) {
|
||||
document.execCommand('insertText', false, textToInsert);
|
||||
} else {
|
||||
console.warn('insertTextAtCursor: document.execCommand is not supported');
|
||||
const startPos = element.selectionStart;
|
||||
const endPos = element.selectionEnd;
|
||||
const beforeText = element.value.substring(0, startPos);
|
||||
const afterText = element.value.substring(endPos);
|
||||
element.value = beforeText + textToInsert + afterText;
|
||||
element.selectionStart = element.selectionEnd = startPos + textToInsert.length;
|
||||
const event = new Event('input', { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
const getAssistantName = ({
|
||||
name,
|
||||
localize,
|
||||
|
|
@ -27,19 +46,18 @@ const getAssistantName = ({
|
|||
};
|
||||
|
||||
export default function useTextarea({
|
||||
setText,
|
||||
submitMessage,
|
||||
textAreaRef,
|
||||
submitButtonRef,
|
||||
disabled = false,
|
||||
}: {
|
||||
setText: SetterOrUpdater<string>;
|
||||
submitMessage: () => void;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { conversation, isSubmitting, latestMessage, setShowBingToneSetting, setFilesLoading } =
|
||||
useChatContext();
|
||||
const isComposing = useRef(false);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const { handleFiles } = useFileHandling();
|
||||
const getSender = useGetSender();
|
||||
const localize = useLocalize();
|
||||
|
|
@ -77,7 +95,7 @@ export default function useTextarea({
|
|||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isSubmitting]);
|
||||
}, [isSubmitting, textAreaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textAreaRef.current?.value) {
|
||||
|
|
@ -118,7 +136,16 @@ export default function useTextarea({
|
|||
debouncedSetPlaceholder();
|
||||
|
||||
return () => debouncedSetPlaceholder.cancel();
|
||||
}, [conversation, disabled, latestMessage, isNotAppendable, localize, getSender, assistantName]);
|
||||
}, [
|
||||
conversation,
|
||||
disabled,
|
||||
latestMessage,
|
||||
isNotAppendable,
|
||||
localize,
|
||||
getSender,
|
||||
assistantName,
|
||||
textAreaRef,
|
||||
]);
|
||||
|
||||
const handleKeyDown = (e: KeyEvent) => {
|
||||
if (e.key === 'Enter' && isSubmitting) {
|
||||
|
|
@ -130,7 +157,7 @@ export default function useTextarea({
|
|||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing?.current) {
|
||||
submitMessage();
|
||||
submitButtonRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -138,7 +165,7 @@ export default function useTextarea({
|
|||
const target = e.target as HTMLTextAreaElement;
|
||||
|
||||
if (e.keyCode === 8 && target.value.trim() === '') {
|
||||
setText(target.value);
|
||||
textAreaRef.current?.setRangeText('', 0, textAreaRef.current?.value?.length, 'end');
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
|
|
@ -161,20 +188,13 @@ export default function useTextarea({
|
|||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const pastedData = e.clipboardData.getData('text/plain');
|
||||
const textArea = textAreaRef.current;
|
||||
|
||||
if (!textArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textArea.selectionStart;
|
||||
const end = textArea.selectionEnd;
|
||||
|
||||
const newValue =
|
||||
textArea.value.substring(0, start) + pastedData + textArea.value.substring(end);
|
||||
setText(newValue);
|
||||
const pastedData = e.clipboardData.getData('text/plain');
|
||||
insertTextAtCursor(textArea, pastedData);
|
||||
|
||||
if (e.clipboardData && e.clipboardData.files.length > 0) {
|
||||
e.preventDefault();
|
||||
|
|
@ -189,7 +209,7 @@ export default function useTextarea({
|
|||
handleFiles(timestampedFiles);
|
||||
}
|
||||
},
|
||||
[handleFiles, setFilesLoading, setText],
|
||||
[handleFiles, setFilesLoading, textAreaRef],
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -36,10 +36,6 @@ export default function Root() {
|
|||
const searchEnabledQuery = useGetSearchEnabledQuery({ enabled: isAuthenticated });
|
||||
const modelsQuery = useGetModelsQuery({ enabled: isAuthenticated && modelsQueryEnabled });
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(navVisible));
|
||||
}, [navVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelsQuery.data && location.state?.from?.pathname.includes('/chat')) {
|
||||
setModelsConfig(modelsQuery.data);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue