feat: Quality-of-Life Chat/Edit-Message Enhancements (#5194)

* fix: rendering error for mermaid flowchart syntax

* feat: add submit button ref and enable submit on Ctrl+Enter in EditMessage component

* feat: add save button and keyboard shortcuts for saving and canceling in EditMessage component

* feat: collapse chat on max height

* refactor: implement scrollable detection for textarea on key down events and initial render

* feat: add regenerate button for error handling in HoverButtons, closes #3658

* feat: add functionality to edit latest user message with the up arrow key when the input is empty
This commit is contained in:
Danny Avila 2025-01-06 22:47:24 -05:00 committed by GitHub
parent b01c744eb8
commit 8aa1e731ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 242 additions and 66 deletions

View file

@ -47,7 +47,6 @@ const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
diagramPadding: 8,
htmlLabels: true,
useMaxWidth: true,
defaultRenderer: 'dagre-d3',
padding: 15,
wrappingWidth: 200,
},

View file

@ -1,4 +1,4 @@
import { memo, useRef, useMemo, useEffect } from 'react';
import { memo, useRef, useMemo, useEffect, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
supportsFiles,
@ -20,14 +20,15 @@ import {
useQueryParams,
useSubmitMessage,
} from '~/hooks';
import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
import FileFormWrapper from './Files/FileFormWrapper';
import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common';
import CollapseChat from './CollapseChat';
import StreamAudio from './StreamAudio';
import StopButton from './StopButton';
import SendButton from './SendButton';
@ -39,6 +40,9 @@ const ChatForm = ({ index = 0 }) => {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
useQueryParams({ textAreaRef });
const [isCollapsed, setIsCollapsed] = useState(false);
const [isScrollable, setIsScrollable] = useState(false);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
@ -64,6 +68,7 @@ const ChatForm = ({ index = 0 }) => {
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
textAreaRef,
submitButtonRef,
setIsScrollable,
disabled: !!(requiresKey ?? false),
});
@ -129,11 +134,19 @@ const ChatForm = ({ index = 0 }) => {
}
}, [isSearching, disableInputs]);
useEffect(() => {
if (textAreaRef.current) {
checkIfScrollable(textAreaRef.current);
}
}, []);
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
const baseClasses =
'md:py-3.5 m-0 w-full resize-none bg-surface-tertiary py-[13px] placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] max-h-[65vh] md:max-h-[75vh]';
const baseClasses = cn(
'md:py-3.5 m-0 w-full resize-none bg-surface-tertiary py-[13px] placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[65vh] md:max-h-[75vh]',
);
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
const speechClass = isRTL
@ -172,25 +185,45 @@ const ChatForm = ({ index = 0 }) => {
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}>
{endpoint && (
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
textAreaRef.current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
style={{ height: 44, overflowY: 'auto' }}
rows={1}
className={cn(baseClasses, speechClass, removeFocusRings)}
/>
<>
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isScrollable}
setIsCollapsed={setIsCollapsed}
/>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
textAreaRef.current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onHeightChange={() => {
if (textAreaRef.current) {
const scrollable = checkIfScrollable(textAreaRef.current);
setIsScrollable(scrollable);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => isCollapsed && setIsCollapsed(false)}
onClick={() => isCollapsed && setIsCollapsed(false)}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
speechClass,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
</>
)}
</FileFormWrapper>
{SpeechToText && (

View file

@ -0,0 +1,41 @@
import React from 'react';
import { Minimize2 } from 'lucide-react';
import { TooltipAnchor } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const CollapseChat = ({
isScrollable,
isCollapsed,
setIsCollapsed,
}: {
isScrollable: boolean;
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const localize = useLocalize();
if (!isScrollable) {
return null;
}
if (isCollapsed) {
return null;
}
return (
<TooltipAnchor
role="button"
description={localize('com_ui_collapse_chat')}
aria-label={localize('com_ui_collapse_chat')}
onClick={() => setIsCollapsed(true)}
className={cn(
'absolute right-2 top-2 z-10 size-[35px] rounded-full p-2 transition-colors',
'hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
)}
>
<Minimize2 className="h-full w-full" />
</TooltipAnchor>
);
};
export default CollapseChat;

View file

@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
import type { TEditProps } from '~/common';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { TextareaAutosize } from '~/components/ui';
import { TextareaAutosize, TooltipAnchor } from '~/components/ui';
import { cn, removeFocusRings } from '~/utils';
import { useLocalize } from '~/hooks';
import Container from './Container';
@ -21,6 +21,8 @@ const EditMessage = ({
setSiblingIdx,
}: TEditProps) => {
const { addedIndex } = useAddedChatContext();
const saveButtonRef = useRef<HTMLButtonElement | null>(null);
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
const { getMessages, setMessages, conversation } = useChatContext();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
@ -127,6 +129,14 @@ const EditMessage = ({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitButtonRef.current?.click();
}
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
saveButtonRef.current?.click();
}
if (e.key === 'Escape') {
e.preventDefault();
enterEdit(true);
@ -165,25 +175,42 @@ const EditMessage = ({
/>
</div>
<div className="mt-2 flex w-full justify-center text-center">
<button
className="btn btn-primary relative mr-2"
disabled={
isSubmitting || (endpoint === EModelEndpoint.google && !message.isCreatedByUser)
<TooltipAnchor
description="Ctrl + Enter / ⌘ + Enter"
render={
<button
ref={submitButtonRef}
className="btn btn-primary relative mr-2"
disabled={
isSubmitting || (endpoint === EModelEndpoint.google && !message.isCreatedByUser)
}
onClick={handleSubmit(resubmitMessage)}
>
{localize('com_ui_save_submit')}
</button>
}
onClick={handleSubmit(resubmitMessage)}
>
{localize('com_ui_save_submit')}
</button>
<button
className="btn btn-secondary relative mr-2"
disabled={isSubmitting}
onClick={handleSubmit(updateMessage)}
>
{localize('com_ui_save')}
</button>
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
{localize('com_ui_cancel')}
</button>
/>
<TooltipAnchor
description="Shift + Enter"
render={
<button
ref={saveButtonRef}
className="btn btn-secondary relative mr-2"
disabled={isSubmitting}
onClick={handleSubmit(updateMessage)}
>
{localize('com_ui_save')}
</button>
}
/>
<TooltipAnchor
description="Esc"
render={
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
{localize('com_ui_cancel')}
</button>
}
/>
</div>
</Container>
);

View file

@ -60,8 +60,34 @@ export default function HoverButtons({
const { isCreatedByUser, error } = message;
if (error) {
return null;
const renderRegenerate = () => {
if (!regenerateEnabled) {
return null;
}
return (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
>
<RegenerateIcon
className="hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400"
size="19"
/>
</button>
);
};
if (error === true) {
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
{renderRegenerate()}
</div>
);
}
const onEdit = () => {
@ -84,6 +110,7 @@ export default function HoverButtons({
)}
{isEditableEndpoint && (
<button
id={`edit-${message.messageId}`}
className={cn(
'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
@ -113,22 +140,7 @@ export default function HoverButtons({
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
</button>
{regenerateEnabled ? (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
>
<RegenerateIcon
className="hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400"
size="19"
/>
</button>
) : null}
{renderRegenerate()}
<Fork
isLast={isLast}
messageId={message.messageId}

View file

@ -36,6 +36,7 @@ export default function MessagesView({
<div className="flex-1 overflow-hidden overflow-y-auto">
<div className="relative h-full">
<div
className="scrollbar-gutter-stable"
onScroll={debouncedHandleScroll}
ref={scrollableRef}
style={{

View file

@ -54,6 +54,7 @@ const useHandleKeyUp = ({
permissionType: PermissionTypes.MULTI_CONVO,
permission: Permissions.USE,
});
const latestMessage = useRecoilValue(store.latestMessageFamily(index));
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
// Get the current state of command toggles
@ -94,12 +95,32 @@ const useHandleKeyUp = ({
[handleAtCommand, handlePlusCommand, handlePromptsCommand],
);
const handleUpArrow = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!latestMessage) {
return;
}
const element = document.getElementById(`edit-${latestMessage.parentMessageId}`);
if (!element) {
return;
}
event.preventDefault();
element.click();
},
[latestMessage],
);
/**
* Main key up handler.
*/
const handleKeyUp = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const text = textAreaRef.current?.value;
if (event.key === 'ArrowUp' && text?.length === 0) {
handleUpArrow(event);
return;
}
if (typeof text !== 'string' || text.length === 0) {
return;
}
@ -115,7 +136,7 @@ const useHandleKeyUp = ({
handler();
}
},
[textAreaRef, commandHandlers],
[textAreaRef, commandHandlers, handleUpArrow],
);
return handleKeyUp;

View file

@ -4,7 +4,13 @@ import { useRecoilValue, useRecoilState } from 'recoil';
import { Constants } from 'librechat-data-provider';
import type { TEndpointOption } from 'librechat-data-provider';
import type { KeyboardEvent } from 'react';
import { forceResize, insertTextAtCursor, getEntityName, getEntity } from '~/utils';
import {
forceResize,
insertTextAtCursor,
getEntityName,
getEntity,
checkIfScrollable,
} from '~/utils';
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
import { useAgentsMapContext } from '~/Providers/AgentsMapContext';
import useGetSender from '~/hooks/Conversations/useGetSender';
@ -20,10 +26,12 @@ type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
export default function useTextarea({
textAreaRef,
submitButtonRef,
setIsScrollable,
disabled = false,
}: {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
submitButtonRef: React.RefObject<HTMLButtonElement>;
setIsScrollable: React.Dispatch<React.SetStateAction<boolean>>;
disabled?: boolean;
}) {
const localize = useLocalize();
@ -170,6 +178,10 @@ export default function useTextarea({
const handleKeyDown = useCallback(
(e: KeyEvent) => {
if (textAreaRef.current && checkIfScrollable(textAreaRef.current)) {
const scrollable = checkIfScrollable(textAreaRef.current);
scrollable && setIsScrollable(scrollable);
}
if (e.key === 'Enter' && isSubmitting) {
return;
}
@ -209,7 +221,15 @@ export default function useTextarea({
submitButtonRef.current?.click();
}
},
[isSubmitting, checkHealth, filesLoading, enterToSend, textAreaRef, submitButtonRef],
[
isSubmitting,
checkHealth,
filesLoading,
enterToSend,
setIsScrollable,
textAreaRef,
submitButtonRef,
],
);
const handleCompositionStart = () => {

View file

@ -913,4 +913,5 @@ export default {
com_endpoint_ai: 'الذكاء الاصطناعي',
com_endpoint_message_new: 'الرسالة {0} أو اكتب "@" للتبديل إلى الذكاء الاصطناعي',
com_nav_maximize_chat_space: 'تكبير مساحة الدردشة',
com_ui_collapse_chat: 'طي الدردشة',
};

View file

@ -945,4 +945,5 @@ export default {
com_ui_bookmarks_add: 'Lesezeichen hinzufügen',
com_endpoint_message_new: 'Nachricht {0} oder "@" eingeben, um KI zu wechseln',
com_nav_maximize_chat_space: 'Chat-Bereich maximieren',
com_ui_collapse_chat: 'Chat einklappen',
};

View file

@ -3,6 +3,7 @@
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets present in this file
export default {
com_ui_collapse_chat: 'Collapse Chat',
com_ui_enter_api_key: 'Enter API Key',
com_ui_librechat_code_api_title: 'Run AI Code',
com_ui_librechat_code_api_subtitle: 'Secure. Multi-language. Input/Output Files.',

View file

@ -1201,4 +1201,5 @@ export default {
com_endpoint_message_new: 'Mensaje {0} o escriba "@" para cambiar de IA',
com_nav_maximize_chat_space: 'Maximizar espacio del chat',
com_endpoint_ai: 'IA',
com_ui_collapse_chat: 'Contraer Chat',
};

View file

@ -963,4 +963,5 @@ export default {
com_nav_maximize_chat_space: 'Maximiser l\'espace de discussion',
com_endpoint_message_new: 'Message {0} ou tapez "@" pour changer d\'IA',
com_ui_page: 'Page',
com_ui_collapse_chat: 'Réduire la discussion',
};

View file

@ -957,4 +957,5 @@ export default {
com_endpoint_ai: 'IA',
com_nav_maximize_chat_space: 'Massimizza spazio chat',
com_ui_page: 'Pagina',
com_ui_collapse_chat: 'Comprimi Chat',
};

View file

@ -911,4 +911,5 @@ export default {
com_endpoint_ai: 'AI',
com_endpoint_message_new: 'メッセージ {0} または「@」を入力してAIを切り替え',
com_nav_maximize_chat_space: 'チャット画面を最大化',
com_ui_collapse_chat: 'チャットを折りたたむ',
};

View file

@ -1149,4 +1149,5 @@ export default {
com_endpoint_ai: '인공지능',
com_nav_maximize_chat_space: '채팅창 최대화',
com_endpoint_message_new: '메시지 {0} 또는 "@"를 입력하여 AI 전환',
com_ui_collapse_chat: '채팅 접기',
};

View file

@ -1175,4 +1175,5 @@ export default {
com_endpoint_message_new: 'Сообщение {0} или введите "@" для смены ИИ',
com_nav_maximize_chat_space: 'Развернуть чат',
com_ui_bookmarks_add: 'Добавить закладку',
com_ui_collapse_chat: 'Свернуть чат',
};

View file

@ -903,4 +903,5 @@ export default {
com_ui_page: '页面',
com_nav_maximize_chat_space: '最大化聊天窗口',
com_endpoint_message_new: '发送消息 {0} 或输入"@"切换AI',
com_ui_collapse_chat: '收起聊天',
};

View file

@ -880,4 +880,5 @@ export default {
com_nav_maximize_chat_space: '最大化聊天視窗',
com_endpoint_ai: 'AI',
com_endpoint_message_new: '輸入訊息 {0} 或輸入 "@" 以切換 AI',
com_ui_collapse_chat: '收合對話',
};

View file

@ -131,7 +131,7 @@ const standardDependencies = {
const mermaidDependencies = Object.assign(
{
mermaid: '^11.0.2',
mermaid: '^11.4.1',
'react-zoom-pan-pinch': '^3.6.1',
},
standardDependencies,

View file

@ -50,7 +50,6 @@ const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
diagramPadding: 8,
htmlLabels: true,
useMaxWidth: true,
defaultRenderer: "dagre-d3",
padding: 15,
wrappingWidth: 200,
},

View file

@ -73,3 +73,15 @@ export function removeCharIfLast(textarea: HTMLTextAreaElement, charToRemove: st
textarea.focus();
}
/**
* Check if the textarea is scrollable.
* @param element
* @returns
*/
export const checkIfScrollable = (element: HTMLTextAreaElement | null) => {
if (!element) {
return false;
}
return element.scrollHeight > element.clientHeight;
};