🪶 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:
Danny Avila 2025-04-27 18:28:28 -04:00 committed by GitHub
parent 6826c0ed43
commit fc30482f65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 68 additions and 41 deletions

View file

@ -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>

View file

@ -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);

View file

@ -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();
} }

View file

@ -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';

View file

@ -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;

View 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]);
}

View file

@ -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 } });
} }
}; };

View file

@ -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],
); );