Skip to content

Commit

Permalink
✨ feat: Quality-of-Life Chat/Edit-Message Enhancements (#5194)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
danny-avila authored Jan 7, 2025
1 parent b01c744 commit 8aa1e73
Show file tree
Hide file tree
Showing 22 changed files with 242 additions and 66 deletions.
1 change: 0 additions & 1 deletion client/src/components/Artifacts/Mermaid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
diagramPadding: 8,
htmlLabels: true,
useMaxWidth: true,
defaultRenderer: 'dagre-d3',
padding: 15,
wrappingWidth: 200,
},
Expand Down
79 changes: 56 additions & 23 deletions client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand All @@ -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);
Expand All @@ -64,6 +68,7 @@ const ChatForm = ({ index = 0 }) => {
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
textAreaRef,
submitButtonRef,
setIsScrollable,
disabled: !!(requiresKey ?? false),
});

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 && (
Expand Down
41 changes: 41 additions & 0 deletions client/src/components/Chat/Input/CollapseChat.tsx
Original file line number Diff line number Diff line change
@@ -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;
65 changes: 46 additions & 19 deletions client/src/components/Chat/Messages/Content/EditMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>
);
Expand Down
48 changes: 30 additions & 18 deletions client/src/components/Chat/Messages/HoverButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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',
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions client/src/components/Chat/Messages/MessagesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={{
Expand Down
23 changes: 22 additions & 1 deletion client/src/hooks/Input/useHandleKeyUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -115,7 +136,7 @@ const useHandleKeyUp = ({
handler();
}
},
[textAreaRef, commandHandlers],
[textAreaRef, commandHandlers, handleUpArrow],
);

return handleKeyUp;
Expand Down
Loading

0 comments on commit 8aa1e73

Please sign in to comment.