mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations (#7100)
* refactor: improve ChatView layout by keeping ChatForm mounted * feat: implement focusChat functionality for new conversations and navigations * refactor: reset artifacts when navigating to prevent any from rendering in a conversation when none exist; edge case, artifacts get created by search route (TODO: use a different artifact renderer for Search markdown)
This commit is contained in:
parent
6826c0ed43
commit
fc30482f65
8 changed files with 68 additions and 41 deletions
|
|
@ -2,6 +2,7 @@ import { memo, useCallback } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Constants } from 'librechat-data-provider';
|
||||||
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
|
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
import type { ChatFormValues } from '~/common';
|
import type { ChatFormValues } from '~/common';
|
||||||
|
|
@ -11,8 +12,8 @@ import ConversationStarters from './Input/ConversationStarters';
|
||||||
import MessagesView from './Messages/MessagesView';
|
import MessagesView from './Messages/MessagesView';
|
||||||
import { Spinner } from '~/components/svg';
|
import { Spinner } from '~/components/svg';
|
||||||
import Presentation from './Presentation';
|
import Presentation from './Presentation';
|
||||||
|
import { buildTree, cn } from '~/utils';
|
||||||
import ChatForm from './Input/ChatForm';
|
import ChatForm from './Input/ChatForm';
|
||||||
import { buildTree } from '~/utils';
|
|
||||||
import Landing from './Landing';
|
import Landing from './Landing';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
|
|
@ -48,9 +49,11 @@ function ChatView({ index = 0 }: { index?: number }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
let content: JSX.Element | null | undefined;
|
let content: JSX.Element | null | undefined;
|
||||||
const isLandingPage = !messagesTree || messagesTree.length === 0;
|
const isLandingPage =
|
||||||
|
(!messagesTree || messagesTree.length === 0) &&
|
||||||
|
(conversationId === Constants.NEW_CONVO || !conversationId);
|
||||||
|
|
||||||
if (isLoading && conversationId !== 'new') {
|
if (isLoading && conversationId !== Constants.NEW_CONVO) {
|
||||||
content = (
|
content = (
|
||||||
<div className="relative flex-1 overflow-hidden overflow-y-auto">
|
<div className="relative flex-1 overflow-hidden overflow-y-auto">
|
||||||
<div className="relative flex h-full items-center justify-center">
|
<div className="relative flex h-full items-center justify-center">
|
||||||
|
|
@ -71,27 +74,28 @@ function ChatView({ index = 0 }: { index?: number }) {
|
||||||
<Presentation>
|
<Presentation>
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-full w-full flex-col">
|
||||||
{!isLoading && <Header />}
|
{!isLoading && <Header />}
|
||||||
|
<>
|
||||||
{isLandingPage ? (
|
<div
|
||||||
<>
|
className={cn(
|
||||||
<div className="flex flex-1 flex-col items-center justify-end sm:justify-center">
|
'flex flex-col',
|
||||||
{content}
|
isLandingPage
|
||||||
<div className="w-full max-w-3xl transition-all duration-200 xl:max-w-4xl">
|
? 'flex-1 items-center justify-end sm:justify-center'
|
||||||
<ChatForm index={index} />
|
: 'h-full overflow-y-auto',
|
||||||
<ConversationStarters />
|
)}
|
||||||
</div>
|
>
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col overflow-y-auto">
|
|
||||||
{content}
|
{content}
|
||||||
<div className="w-full">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full',
|
||||||
|
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ChatForm index={index} />
|
<ChatForm index={index} />
|
||||||
<Footer />
|
{isLandingPage ? <ConversationStarters /> : <Footer />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{isLandingPage && <Footer />}
|
||||||
|
</>
|
||||||
</div>
|
</div>
|
||||||
</Presentation>
|
</Presentation>
|
||||||
</AddedChatContext.Provider>
|
</AddedChatContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
useHandleKeyUp,
|
useHandleKeyUp,
|
||||||
useQueryParams,
|
useQueryParams,
|
||||||
useSubmitMessage,
|
useSubmitMessage,
|
||||||
|
useFocusChatEffect,
|
||||||
} from '~/hooks';
|
} from '~/hooks';
|
||||||
import { mainTextareaId, BadgeItem } from '~/common';
|
import { mainTextareaId, BadgeItem } from '~/common';
|
||||||
import AttachFileChat from './Files/AttachFileChat';
|
import AttachFileChat from './Files/AttachFileChat';
|
||||||
|
|
@ -36,6 +37,7 @@ import store from '~/store';
|
||||||
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
useFocusChatEffect(textAreaRef);
|
||||||
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [, setIsScrollable] = useState(false);
|
const [, setIsScrollable] = useState(false);
|
||||||
|
|
@ -43,7 +45,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
|
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
|
||||||
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
|
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
|
||||||
|
|
||||||
const search = useRecoilValue(store.search);
|
|
||||||
const SpeechToText = useRecoilValue(store.speechToText);
|
const SpeechToText = useRecoilValue(store.speechToText);
|
||||||
const TextToSpeech = useRecoilValue(store.textToSpeech);
|
const TextToSpeech = useRecoilValue(store.textToSpeech);
|
||||||
const chatDirection = useRecoilValue(store.chatDirection);
|
const chatDirection = useRecoilValue(store.chatDirection);
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default function NewChat({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
newConvo();
|
newConvo();
|
||||||
navigate('/c/new');
|
navigate('/c/new', { state: { focusChat: true } });
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
toggleNav();
|
toggleNav();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ export { default as useChatHelpers } from './useChatHelpers';
|
||||||
export { default as useAddedHelpers } from './useAddedHelpers';
|
export { default as useAddedHelpers } from './useAddedHelpers';
|
||||||
export { default as useAddedResponse } from './useAddedResponse';
|
export { default as useAddedResponse } from './useAddedResponse';
|
||||||
export { default as useChatFunctions } from './useChatFunctions';
|
export { default as useChatFunctions } from './useChatFunctions';
|
||||||
|
export { default as useFocusChatEffect } from './useFocusChatEffect';
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ export default function useChatFunctions({
|
||||||
parentMessageId = Constants.NO_PARENT;
|
parentMessageId = Constants.NO_PARENT;
|
||||||
currentMessages = [];
|
currentMessages = [];
|
||||||
conversationId = null;
|
conversationId = null;
|
||||||
navigate('/c/new');
|
navigate('/c/new', { state: { focusChat: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;
|
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;
|
||||||
|
|
|
||||||
18
client/src/hooks/Chat/useFocusChatEffect.ts
Normal file
18
client/src/hooks/Chat/useFocusChatEffect.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { logger } from '~/utils';
|
||||||
|
|
||||||
|
export default function useFocusChatEffect(textAreaRef: React.RefObject<HTMLTextAreaElement>) {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
useEffect(() => {
|
||||||
|
if (textAreaRef?.current && location.state?.focusChat) {
|
||||||
|
logger.log(
|
||||||
|
'conversation',
|
||||||
|
`Focusing textarea on location state change: ${location.pathname}`,
|
||||||
|
);
|
||||||
|
textAreaRef.current?.focus();
|
||||||
|
navigate(`${location.pathname}${location.search ?? ''}`, { replace: true, state: {} });
|
||||||
|
}
|
||||||
|
}, [navigate, textAreaRef, location.pathname, location.state?.focusChat, location.search]);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useSetRecoilState } from 'recoil';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||||
import {
|
import {
|
||||||
QueryKeys,
|
QueryKeys,
|
||||||
Constants,
|
Constants,
|
||||||
|
|
@ -16,8 +16,9 @@ const useNavigateToConvo = (index = 0) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clearAllConversations = store.useClearConvoState();
|
const clearAllConversations = store.useClearConvoState();
|
||||||
const clearAllLatestMessages = store.useClearLatestMessages(`useNavigateToConvo ${index}`);
|
const resetArtifacts = useResetRecoilState(store.artifactsState);
|
||||||
const setSubmission = useSetRecoilState(store.submissionByIndex(index));
|
const setSubmission = useSetRecoilState(store.submissionByIndex(index));
|
||||||
|
const clearAllLatestMessages = store.useClearLatestMessages(`useNavigateToConvo ${index}`);
|
||||||
const { hasSetConversation, setConversation } = store.useCreateConversationAtom(index);
|
const { hasSetConversation, setConversation } = store.useCreateConversationAtom(index);
|
||||||
|
|
||||||
const fetchFreshData = async (conversationId?: string | null) => {
|
const fetchFreshData = async (conversationId?: string | null) => {
|
||||||
|
|
@ -31,6 +32,7 @@ const useNavigateToConvo = (index = 0) => {
|
||||||
logger.log('conversation', 'Fetched fresh conversation data', data);
|
logger.log('conversation', 'Fetched fresh conversation data', data);
|
||||||
await queryClient.invalidateQueries([QueryKeys.messages, conversationId]);
|
await queryClient.invalidateQueries([QueryKeys.messages, conversationId]);
|
||||||
setConversation(data);
|
setConversation(data);
|
||||||
|
navigate(`/c/${conversationId ?? Constants.NEW_CONVO}`, { state: { focusChat: true } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching conversation data on navigation', error);
|
console.error('Error fetching conversation data on navigation', error);
|
||||||
}
|
}
|
||||||
|
|
@ -82,11 +84,13 @@ const useNavigateToConvo = (index = 0) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
clearAllConversations(true);
|
clearAllConversations(true);
|
||||||
|
resetArtifacts();
|
||||||
setConversation(convo);
|
setConversation(convo);
|
||||||
navigate(`/c/${convo.conversationId ?? Constants.NEW_CONVO}`);
|
|
||||||
if (convo.conversationId !== Constants.NEW_CONVO && convo.conversationId) {
|
if (convo.conversationId !== Constants.NEW_CONVO && convo.conversationId) {
|
||||||
queryClient.invalidateQueries([QueryKeys.conversation, convo.conversationId]);
|
queryClient.invalidateQueries([QueryKeys.conversation, convo.conversationId]);
|
||||||
fetchFreshData(convo.conversationId);
|
fetchFreshData(convo.conversationId);
|
||||||
|
} else {
|
||||||
|
navigate(`/c/${convo.conversationId ?? Constants.NEW_CONVO}`, { state: { focusChat: true } });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Constants,
|
Constants,
|
||||||
FileSources,
|
FileSources,
|
||||||
|
|
@ -30,12 +30,12 @@ import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } fro
|
||||||
import useAssistantListMap from './Assistants/useAssistantListMap';
|
import useAssistantListMap from './Assistants/useAssistantListMap';
|
||||||
import { useResetChatBadges } from './useChatBadges';
|
import { useResetChatBadges } from './useChatBadges';
|
||||||
import { usePauseGlobalAudio } from './Audio';
|
import { usePauseGlobalAudio } from './Audio';
|
||||||
import { mainTextareaId } from '~/common';
|
|
||||||
import { logger } from '~/utils';
|
import { logger } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
const useNewConvo = (index = 0) => {
|
const useNewConvo = (index = 0) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const clearAllConversations = store.useClearConvoState();
|
const clearAllConversations = store.useClearConvoState();
|
||||||
const defaultPreset = useRecoilValue(store.defaultPreset);
|
const defaultPreset = useRecoilValue(store.defaultPreset);
|
||||||
|
|
@ -47,7 +47,6 @@ const useNewConvo = (index = 0) => {
|
||||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||||
|
|
||||||
const modelsQuery = useGetModelsQuery();
|
const modelsQuery = useGetModelsQuery();
|
||||||
const timeoutIdRef = useRef<NodeJS.Timeout>();
|
|
||||||
const assistantsListMap = useAssistantListMap();
|
const assistantsListMap = useAssistantListMap();
|
||||||
const { pauseGlobalAudio } = usePauseGlobalAudio(index);
|
const { pauseGlobalAudio } = usePauseGlobalAudio(index);
|
||||||
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
|
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
|
||||||
|
|
@ -159,24 +158,24 @@ const useNewConvo = (index = 0) => {
|
||||||
clearAllLatestMessages();
|
clearAllLatestMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchParamsString = searchParams?.toString();
|
||||||
|
const getParams = () => (searchParamsString ? `?${searchParamsString}` : '');
|
||||||
|
|
||||||
if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) {
|
if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) {
|
||||||
const appTitle = localStorage.getItem(LocalStorageKeys.APP_TITLE) ?? '';
|
const appTitle = localStorage.getItem(LocalStorageKeys.APP_TITLE) ?? '';
|
||||||
if (appTitle) {
|
if (appTitle) {
|
||||||
document.title = appTitle;
|
document.title = appTitle;
|
||||||
}
|
}
|
||||||
navigate(`/c/${Constants.NEW_CONVO}`);
|
const path = `/c/${Constants.NEW_CONVO}${getParams()}`;
|
||||||
}
|
navigate(path, { state: { focusChat: true } });
|
||||||
|
|
||||||
clearTimeout(timeoutIdRef.current);
|
|
||||||
if (disableFocus === true) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
timeoutIdRef.current = setTimeout(() => {
|
|
||||||
const textarea = document.getElementById(mainTextareaId);
|
const path = `/c/${conversation.conversationId}${getParams()}`;
|
||||||
if (textarea) {
|
navigate(path, {
|
||||||
textarea.focus();
|
replace: true,
|
||||||
}
|
state: disableFocus ? {} : { focusChat: true },
|
||||||
}, 150);
|
});
|
||||||
},
|
},
|
||||||
[endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data],
|
[endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue