🎋 refactor: Improve Message UI State Handling (#9678)

* refactor: `ExecuteCode` component with submission state handling and cancellation message

* fix: Remove unnecessary argument check for execute_code tool call

* refactor: streamlined messages context

* chore: remove unused Convo prop

* chore: remove unnecessary whitespace in Message component

* refactor: enhance message context with submission state and latest message tracking

* chore: import order
This commit is contained in:
Danny Avila 2025-09-17 13:07:56 -04:00 committed by GitHub
parent 0ceef12eea
commit 45ab4d4503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 242 additions and 88 deletions

View file

@ -1,10 +1,15 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
type MessageContext = { type MessageContext = {
messageId: string; messageId: string;
nextType?: string; nextType?: string;
partIndex?: number; partIndex?: number;
isExpanded: boolean; isExpanded: boolean;
conversationId?: string | null; conversationId?: string | null;
/** Submission state for cursor display - only true for latest message when submitting */
isSubmitting?: boolean;
/** Whether this is the latest message in the conversation */
isLatestMessage?: boolean;
}; };
export const MessageContext = createContext<MessageContext>({} as MessageContext); export const MessageContext = createContext<MessageContext>({} as MessageContext);

View file

@ -0,0 +1,150 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useAddedChatContext } from './AddedChatContext';
import { useChatContext } from './ChatContext';
interface MessagesViewContextValue {
/** Core conversation data */
conversation: ReturnType<typeof useChatContext>['conversation'];
conversationId: string | null | undefined;
/** Submission and control states */
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
isSubmittingFamily: boolean;
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
setAbortScroll: ReturnType<typeof useChatContext>['setAbortScroll'];
/** Message operations */
ask: ReturnType<typeof useChatContext>['ask'];
regenerate: ReturnType<typeof useChatContext>['regenerate'];
handleContinue: ReturnType<typeof useChatContext>['handleContinue'];
/** Message state management */
index: ReturnType<typeof useChatContext>['index'];
latestMessage: ReturnType<typeof useChatContext>['latestMessage'];
setLatestMessage: ReturnType<typeof useChatContext>['setLatestMessage'];
getMessages: ReturnType<typeof useChatContext>['getMessages'];
setMessages: ReturnType<typeof useChatContext>['setMessages'];
}
const MessagesViewContext = createContext<MessagesViewContextValue | undefined>(undefined);
export function MessagesViewProvider({ children }: { children: React.ReactNode }) {
const chatContext = useChatContext();
const addedChatContext = useAddedChatContext();
const {
ask,
index,
regenerate,
isSubmitting: isSubmittingRoot,
conversation,
latestMessage,
setAbortScroll,
handleContinue,
setLatestMessage,
abortScroll,
getMessages,
setMessages,
} = chatContext;
const { isSubmitting: isSubmittingAdditional } = addedChatContext;
/** Memoize conversation-related values */
const conversationValues = useMemo(
() => ({
conversation,
conversationId: conversation?.conversationId,
}),
[conversation],
);
/** Memoize submission states */
const submissionStates = useMemo(
() => ({
isSubmitting: isSubmittingRoot,
isSubmittingFamily: isSubmittingRoot || isSubmittingAdditional,
abortScroll,
setAbortScroll,
}),
[isSubmittingRoot, isSubmittingAdditional, abortScroll, setAbortScroll],
);
/** Memoize message operations (these are typically stable references) */
const messageOperations = useMemo(
() => ({
ask,
regenerate,
getMessages,
setMessages,
handleContinue,
}),
[ask, regenerate, handleContinue, getMessages, setMessages],
);
/** Memoize message state values */
const messageState = useMemo(
() => ({
index,
latestMessage,
setLatestMessage,
}),
[index, latestMessage, setLatestMessage],
);
/** Combine all values into final context value */
const contextValue = useMemo<MessagesViewContextValue>(
() => ({
...conversationValues,
...submissionStates,
...messageOperations,
...messageState,
}),
[conversationValues, submissionStates, messageOperations, messageState],
);
return (
<MessagesViewContext.Provider value={contextValue}>{children}</MessagesViewContext.Provider>
);
}
export function useMessagesViewContext() {
const context = useContext(MessagesViewContext);
if (!context) {
throw new Error('useMessagesViewContext must be used within MessagesViewProvider');
}
return context;
}
/** Hook for components that only need conversation data */
export function useMessagesConversation() {
const { conversation, conversationId } = useMessagesViewContext();
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
}
/** Hook for components that only need submission states */
export function useMessagesSubmission() {
const { isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll } =
useMessagesViewContext();
return useMemo(
() => ({ isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll }),
[isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll],
);
}
/** Hook for components that only need message operations */
export function useMessagesOperations() {
const { ask, regenerate, handleContinue, getMessages, setMessages } = useMessagesViewContext();
return useMemo(
() => ({ ask, regenerate, handleContinue, getMessages, setMessages }),
[ask, regenerate, handleContinue, getMessages, setMessages],
);
}
/** Hook for components that only need message state */
export function useMessagesState() {
const { index, latestMessage, setLatestMessage } = useMessagesViewContext();
return useMemo(
() => ({ index, latestMessage, setLatestMessage }),
[index, latestMessage, setLatestMessage],
);
}

View file

@ -26,4 +26,5 @@ export * from './SidePanelContext';
export * from './MCPPanelContext'; export * from './MCPPanelContext';
export * from './ArtifactsContext'; export * from './ArtifactsContext';
export * from './PromptGroupsContext'; export * from './PromptGroupsContext';
export * from './MessagesViewContext';
export { default as BadgeRowProvider } from './BadgeRowContext'; export { default as BadgeRowProvider } from './BadgeRowContext';

View file

@ -26,6 +26,7 @@ type ContentPartsProps = {
isCreatedByUser: boolean; isCreatedByUser: boolean;
isLast: boolean; isLast: boolean;
isSubmitting: boolean; isSubmitting: boolean;
isLatestMessage?: boolean;
edit?: boolean; edit?: boolean;
enterEdit?: (cancel?: boolean) => void | null | undefined; enterEdit?: (cancel?: boolean) => void | null | undefined;
siblingIdx?: number; siblingIdx?: number;
@ -45,6 +46,7 @@ const ContentParts = memo(
isCreatedByUser, isCreatedByUser,
isLast, isLast,
isSubmitting, isSubmitting,
isLatestMessage,
edit, edit,
enterEdit, enterEdit,
siblingIdx, siblingIdx,
@ -55,6 +57,8 @@ const ContentParts = memo(
const [isExpanded, setIsExpanded] = useState(showThinking); const [isExpanded, setIsExpanded] = useState(showThinking);
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const hasReasoningParts = useMemo(() => { const hasReasoningParts = useMemo(() => {
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false; const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
const allThinkPartsHaveContent = const allThinkPartsHaveContent =
@ -134,7 +138,9 @@ const ContentParts = memo(
}) })
} }
label={ label={
isSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts') effectiveIsSubmitting && isLast
? localize('com_ui_thinking')
: localize('com_ui_thoughts')
} }
/> />
</div> </div>
@ -155,12 +161,14 @@ const ContentParts = memo(
conversationId, conversationId,
partIndex: idx, partIndex: idx,
nextType: content[idx + 1]?.type, nextType: content[idx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}} }}
> >
<Part <Part
part={part} part={part}
attachments={attachments} attachments={attachments}
isSubmitting={isSubmitting} isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${idx}`} key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser} isCreatedByUser={isCreatedByUser}
isLast={idx === content.length - 1} isLast={idx === content.length - 1}

View file

@ -4,7 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { TextareaAutosize, TooltipAnchor } from '@librechat/client'; import { TextareaAutosize, TooltipAnchor } from '@librechat/client';
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query'; import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
import type { TEditProps } from '~/common'; import type { TEditProps } from '~/common';
import { useChatContext, useAddedChatContext } from '~/Providers'; import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
import { cn, removeFocusRings } from '~/utils'; import { cn, removeFocusRings } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import Container from './Container'; import Container from './Container';
@ -22,7 +22,8 @@ const EditMessage = ({
const { addedIndex } = useAddedChatContext(); const { addedIndex } = useAddedChatContext();
const saveButtonRef = useRef<HTMLButtonElement | null>(null); const saveButtonRef = useRef<HTMLButtonElement | null>(null);
const submitButtonRef = useRef<HTMLButtonElement | null>(null); const submitButtonRef = useRef<HTMLButtonElement | null>(null);
const { getMessages, setMessages, conversation } = useChatContext(); const { conversation } = useMessagesConversation();
const { getMessages, setMessages } = useMessagesOperations();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState( const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex), store.latestMessageFamily(addedIndex),
); );

View file

@ -5,7 +5,7 @@ import type { TMessage } from 'librechat-data-provider';
import type { TMessageContentProps, TDisplayProps } from '~/common'; import type { TMessageContentProps, TDisplayProps } from '~/common';
import Error from '~/components/Messages/Content/Error'; import Error from '~/components/Messages/Content/Error';
import Thinking from '~/components/Artifacts/Thinking'; import Thinking from '~/components/Artifacts/Thinking';
import { useChatContext } from '~/Providers'; import { useMessageContext } from '~/Providers';
import MarkdownLite from './MarkdownLite'; import MarkdownLite from './MarkdownLite';
import EditMessage from './EditMessage'; import EditMessage from './EditMessage';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
@ -70,16 +70,12 @@ export const ErrorMessage = ({
}; };
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => { const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
const { isSubmitting, latestMessage } = useChatContext(); const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo( const showCursorState = useMemo(
() => showCursor === true && isSubmitting, () => showCursor === true && isSubmitting,
[showCursor, isSubmitting], [showCursor, isSubmitting],
); );
const isLatestMessage = useMemo(
() => message.messageId === latestMessage?.messageId,
[message.messageId, latestMessage?.messageId],
);
let content: React.ReactElement; let content: React.ReactElement;
if (!isCreatedByUser) { if (!isCreatedByUser) {

View file

@ -85,13 +85,14 @@ const Part = memo(
const isToolCall = const isToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL); 'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (isToolCall && toolCall.name === Tools.execute_code && toolCall.args) { if (isToolCall && toolCall.name === Tools.execute_code) {
return ( return (
<ExecuteCode <ExecuteCode
args={typeof toolCall.args === 'string' ? toolCall.args : ''} attachments={attachments}
isSubmitting={isSubmitting}
output={toolCall.output ?? ''} output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1} initialProgress={toolCall.progress ?? 0.1}
attachments={attachments} args={typeof toolCall.args === 'string' ? toolCall.args : ''}
/> />
); );
} else if ( } else if (

View file

@ -6,8 +6,8 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query'; import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
import type { Agents } from 'librechat-data-provider'; import type { Agents } from 'librechat-data-provider';
import type { TEditProps } from '~/common'; import type { TEditProps } from '~/common';
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
import Container from '~/components/Chat/Messages/Content/Container'; import Container from '~/components/Chat/Messages/Content/Container';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { cn, removeFocusRings } from '~/utils'; import { cn, removeFocusRings } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
@ -25,7 +25,8 @@ const EditTextPart = ({
}) => { }) => {
const localize = useLocalize(); const localize = useLocalize();
const { addedIndex } = useAddedChatContext(); const { addedIndex } = useAddedChatContext();
const { ask, getMessages, setMessages, conversation } = useChatContext(); const { conversation } = useMessagesConversation();
const { ask, getMessages, setMessages } = useMessagesOperations();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState( const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex), store.latestMessageFamily(addedIndex),
); );

View file

@ -45,26 +45,28 @@ export function useParseArgs(args?: string): ParsedArgs | null {
} }
export default function ExecuteCode({ export default function ExecuteCode({
isSubmitting,
initialProgress = 0.1, initialProgress = 0.1,
args, args,
output = '', output = '',
attachments, attachments,
}: { }: {
initialProgress: number; initialProgress: number;
isSubmitting: boolean;
args?: string; args?: string;
output?: string; output?: string;
attachments?: TAttachment[]; attachments?: TAttachment[];
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const showAnalysisCode = useRecoilValue(store.showCode);
const [showCode, setShowCode] = useState(showAnalysisCode);
const codeContentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const [isAnimating, setIsAnimating] = useState(false);
const hasOutput = output.length > 0; const hasOutput = output.length > 0;
const outputRef = useRef<string>(output); const outputRef = useRef<string>(output);
const prevShowCodeRef = useRef<boolean>(showCode); const codeContentRef = useRef<HTMLDivElement>(null);
const [isAnimating, setIsAnimating] = useState(false);
const showAnalysisCode = useRecoilValue(store.showCode);
const [showCode, setShowCode] = useState(showAnalysisCode);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const prevShowCodeRef = useRef<boolean>(showCode);
const { lang, code } = useParseArgs(args) ?? ({} as ParsedArgs); const { lang, code } = useParseArgs(args) ?? ({} as ParsedArgs);
const progress = useProgress(initialProgress); const progress = useProgress(initialProgress);
@ -136,6 +138,8 @@ export default function ExecuteCode({
}; };
}, [showCode, isAnimating]); }, [showCode, isAnimating]);
const cancelled = !isSubmitting && progress < 1;
return ( return (
<> <>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5"> <div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
@ -143,9 +147,12 @@ export default function ExecuteCode({
progress={progress} progress={progress}
onClick={() => setShowCode((prev) => !prev)} onClick={() => setShowCode((prev) => !prev)}
inProgressText={localize('com_ui_analyzing')} inProgressText={localize('com_ui_analyzing')}
finishedText={localize('com_ui_analyzing_finished')} finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished')
}
hasInput={!!code?.length} hasInput={!!code?.length}
isExpanded={showCode} isExpanded={showCode}
error={cancelled}
/> />
</div> </div>
<div <div

View file

@ -2,7 +2,7 @@ import { memo, useMemo, ReactElement } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import Markdown from '~/components/Chat/Messages/Content/Markdown'; import Markdown from '~/components/Chat/Messages/Content/Markdown';
import { useChatContext, useMessageContext } from '~/Providers'; import { useMessageContext } from '~/Providers';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
@ -18,14 +18,9 @@ type ContentType =
| ReactElement; | ReactElement;
const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => { const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => {
const { messageId } = useMessageContext(); const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const { isSubmitting, latestMessage } = useChatContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]); const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]);
const isLatestMessage = useMemo(
() => messageId === latestMessage?.messageId,
[messageId, latestMessage?.messageId],
);
const content: ContentType = useMemo(() => { const content: ContentType = useMemo(() => {
if (!isCreatedByUser) { if (!isCreatedByUser) {

View file

@ -21,7 +21,7 @@ type THoverButtons = {
latestMessage: TMessage | null; latestMessage: TMessage | null;
isLast: boolean; isLast: boolean;
index: number; index: number;
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void; handleFeedback?: ({ feedback }: { feedback: TFeedback | undefined }) => void;
}; };
type HoverButtonProps = { type HoverButtonProps = {
@ -238,7 +238,7 @@ const HoverButtons = ({
/> />
{/* Feedback Buttons */} {/* Feedback Buttons */}
{!isCreatedByUser && ( {!isCreatedByUser && handleFeedback != null && (
<Feedback handleFeedback={handleFeedback} feedback={message.feedback} isLast={isLast} /> <Feedback handleFeedback={handleFeedback} feedback={message.feedback} isLast={isLast} />
)} )}

View file

@ -125,6 +125,7 @@ export default function Message(props: TMessageProps) {
setSiblingIdx={setSiblingIdx} setSiblingIdx={setSiblingIdx}
isCreatedByUser={message.isCreatedByUser} isCreatedByUser={message.isCreatedByUser}
conversationId={conversation?.conversationId} conversationId={conversation?.conversationId}
isLatestMessage={messageId === latestMessage?.messageId}
content={message.content as Array<TMessageContentParts | undefined>} content={message.content as Array<TMessageContentParts | undefined>}
/> />
</div> </div>

View file

@ -4,11 +4,12 @@ import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks'; import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom'; import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { MessagesViewProvider } from '~/Providers';
import MultiMessage from './MultiMessage'; import MultiMessage from './MultiMessage';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
export default function MessagesView({ function MessagesViewContent({
messagesTree: _messagesTree, messagesTree: _messagesTree,
}: { }: {
messagesTree?: TMessage[] | null; messagesTree?: TMessage[] | null;
@ -92,3 +93,11 @@ export default function MessagesView({
</> </>
); );
} }
export default function MessagesView({ messagesTree }: { messagesTree?: TMessage[] | null }) {
return (
<MessagesViewProvider>
<MessagesViewContent messagesTree={messagesTree} />
</MessagesViewProvider>
);
}

View file

@ -27,7 +27,7 @@ export default function MultiMessage({
useEffect(() => { useEffect(() => {
// reset siblingIdx when the tree changes, mostly when a new message is submitting. // reset siblingIdx when the tree changes, mostly when a new message is submitting.
setSiblingIdx(0); setSiblingIdx(0);
}, [messagesTree?.length]); }, [messagesTree?.length, setSiblingIdx]);
useEffect(() => { useEffect(() => {
if (messagesTree?.length && siblingIdx >= messagesTree.length) { if (messagesTree?.length && siblingIdx >= messagesTree.length) {

View file

@ -71,6 +71,9 @@ const MessageRender = memo(
const showCardRender = isLast && !isSubmittingFamily && isCard; const showCardRender = isLast && !isSubmittingFamily && isCard;
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage; const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const iconData: TMessageIcon = useMemo( const iconData: TMessageIcon = useMemo(
() => ({ () => ({
endpoint: msg?.endpoint ?? conversation?.endpoint, endpoint: msg?.endpoint ?? conversation?.endpoint,
@ -166,6 +169,8 @@ const MessageRender = memo(
messageId: msg.messageId, messageId: msg.messageId,
conversationId: conversation?.conversationId, conversationId: conversation?.conversationId,
isExpanded: false, isExpanded: false,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}} }}
> >
{msg.plugin && <Plugin plugin={msg.plugin} />} {msg.plugin && <Plugin plugin={msg.plugin} />}
@ -177,7 +182,7 @@ const MessageRender = memo(
message={msg} message={msg}
enterEdit={enterEdit} enterEdit={enterEdit}
error={!!(msg.error ?? false)} error={!!(msg.error ?? false)}
isSubmitting={isSubmitting} isSubmitting={effectiveIsSubmitting}
unfinished={msg.unfinished ?? false} unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true} isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0} siblingIdx={siblingIdx ?? 0}
@ -186,7 +191,7 @@ const MessageRender = memo(
</MessageContext.Provider> </MessageContext.Provider>
</div> </div>
{hasNoChildren && (isSubmittingFamily === true || isSubmitting) ? ( {hasNoChildren && (isSubmittingFamily === true || effectiveIsSubmitting) ? (
<PlaceholderRow isCard={isCard} /> <PlaceholderRow isCard={isCard} />
) : ( ) : (
<SubRow classes="text-xs"> <SubRow classes="text-xs">

View file

@ -1,6 +1,5 @@
import { useMemo, memo, type FC, useCallback } from 'react'; import { useMemo, memo, type FC, useCallback } from 'react';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import { parseISO, isToday } from 'date-fns';
import { Spinner, useMediaQuery } from '@librechat/client'; import { Spinner, useMediaQuery } from '@librechat/client';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { TConversation } from 'librechat-data-provider'; import { TConversation } from 'librechat-data-provider';
@ -50,27 +49,17 @@ const MemoizedConvo = memo(
conversation, conversation,
retainView, retainView,
toggleNav, toggleNav,
isLatestConvo,
}: { }: {
conversation: TConversation; conversation: TConversation;
retainView: () => void; retainView: () => void;
toggleNav: () => void; toggleNav: () => void;
isLatestConvo: boolean;
}) => { }) => {
return ( return <Convo conversation={conversation} retainView={retainView} toggleNav={toggleNav} />;
<Convo
conversation={conversation}
retainView={retainView}
toggleNav={toggleNav}
isLatestConvo={isLatestConvo}
/>
);
}, },
(prevProps, nextProps) => { (prevProps, nextProps) => {
return ( return (
prevProps.conversation.conversationId === nextProps.conversation.conversationId && prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
prevProps.conversation.title === nextProps.conversation.title && prevProps.conversation.title === nextProps.conversation.title &&
prevProps.isLatestConvo === nextProps.isLatestConvo &&
prevProps.conversation.endpoint === nextProps.conversation.endpoint prevProps.conversation.endpoint === nextProps.conversation.endpoint
); );
}, },
@ -98,13 +87,6 @@ const Conversations: FC<ConversationsProps> = ({
[filteredConversations], [filteredConversations],
); );
const firstTodayConvoId = useMemo(
() =>
filteredConversations.find((convo) => convo.updatedAt && isToday(parseISO(convo.updatedAt)))
?.conversationId ?? undefined,
[filteredConversations],
);
const flattenedItems = useMemo(() => { const flattenedItems = useMemo(() => {
const items: FlattenedItem[] = []; const items: FlattenedItem[] = [];
groupedConversations.forEach(([groupName, convos]) => { groupedConversations.forEach(([groupName, convos]) => {
@ -154,26 +136,25 @@ const Conversations: FC<ConversationsProps> = ({
</CellMeasurer> </CellMeasurer>
); );
} }
let rendering: JSX.Element;
if (item.type === 'header') {
rendering = <DateLabel groupName={item.groupName} />;
} else if (item.type === 'convo') {
rendering = (
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
);
}
return ( return (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}> <CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
{({ registerChild }) => ( {({ registerChild }) => (
<div ref={registerChild} style={style}> <div ref={registerChild} style={style}>
{item.type === 'header' ? ( {rendering}
<DateLabel groupName={item.groupName} />
) : item.type === 'convo' ? (
<MemoizedConvo
conversation={item.convo}
retainView={moveToTop}
toggleNav={toggleNav}
isLatestConvo={item.convo.conversationId === firstTodayConvoId}
/>
) : null}
</div> </div>
)} )}
</CellMeasurer> </CellMeasurer>
); );
}, },
[cache, flattenedItems, firstTodayConvoId, moveToTop, toggleNav], [cache, flattenedItems, moveToTop, toggleNav],
); );
const getRowHeight = useCallback( const getRowHeight = useCallback(

View file

@ -11,23 +11,17 @@ import { useGetEndpointsQuery } from '~/data-provider';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
import { ConvoOptions } from './ConvoOptions'; import { ConvoOptions } from './ConvoOptions';
import RenameForm from './RenameForm'; import RenameForm from './RenameForm';
import { cn, logger } from '~/utils';
import ConvoLink from './ConvoLink'; import ConvoLink from './ConvoLink';
import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
interface ConversationProps { interface ConversationProps {
conversation: TConversation; conversation: TConversation;
retainView: () => void; retainView: () => void;
toggleNav: () => void; toggleNav: () => void;
isLatestConvo: boolean;
} }
export default function Conversation({ export default function Conversation({ conversation, retainView, toggleNav }: ConversationProps) {
conversation,
retainView,
toggleNav,
isLatestConvo,
}: ConversationProps) {
const params = useParams(); const params = useParams();
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
@ -84,6 +78,7 @@ export default function Conversation({
}); });
setRenaming(false); setRenaming(false);
} catch (error) { } catch (error) {
logger.error('Error renaming conversation', error);
setTitleInput(title as string); setTitleInput(title as string);
showToast({ showToast({
message: localize('com_ui_rename_failed'), message: localize('com_ui_rename_failed'),

View file

@ -173,6 +173,7 @@ const ContentRender = memo(
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
searchResults={searchResults} searchResults={searchResults}
setSiblingIdx={setSiblingIdx} setSiblingIdx={setSiblingIdx}
isLatestMessage={isLatestMessage}
isCreatedByUser={msg.isCreatedByUser} isCreatedByUser={msg.isCreatedByUser}
conversationId={conversation?.conversationId} conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>} content={msg.content as Array<TMessageContentParts | undefined>}

View file

@ -76,6 +76,8 @@ export default function Message(props: TMessageProps) {
messageId, messageId,
isExpanded: false, isExpanded: false,
conversationId: conversation?.conversationId, conversationId: conversation?.conversationId,
isSubmitting: false, // Share view is always read-only
isLatestMessage: false, // No concept of latest message in share view
}} }}
> >
{/* Legacy Plugins */} {/* Legacy Plugins */}

View file

@ -2,7 +2,7 @@ import throttle from 'lodash/throttle';
import { useEffect, useRef, useCallback, useMemo } from 'react'; import { useEffect, useRef, useCallback, useMemo } from 'react';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { TMessageProps } from '~/common'; import type { TMessageProps } from '~/common';
import { useChatContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import useCopyToClipboard from './useCopyToClipboard'; import useCopyToClipboard from './useCopyToClipboard';
import { getTextKey, logger } from '~/utils'; import { getTextKey, logger } from '~/utils';
@ -20,9 +20,9 @@ export default function useMessageHelpers(props: TMessageProps) {
setAbortScroll, setAbortScroll,
handleContinue, handleContinue,
setLatestMessage, setLatestMessage,
} = useChatContext(); } = useMessagesViewContext();
const assistantMap = useAssistantsMapContext();
const agentsMap = useAgentsMapContext(); const agentsMap = useAgentsMapContext();
const assistantMap = useAssistantsMapContext();
const { text, content, children, messageId = null, isCreatedByUser } = message ?? {}; const { text, content, children, messageId = null, isCreatedByUser } = message ?? {};
const edit = messageId === currentEditId; const edit = messageId === currentEditId;

View file

@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider'; import { Constants } from 'librechat-data-provider';
import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useChatContext, useAddedChatContext } from '~/Providers'; import { useMessagesViewContext } from '~/Providers';
import { getTextKey, logger } from '~/utils'; import { getTextKey, logger } from '~/utils';
import store from '~/store'; import store from '~/store';
@ -18,14 +18,9 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
latestMessage, latestMessage,
setAbortScroll, setAbortScroll,
setLatestMessage, setLatestMessage,
isSubmitting: isSubmittingRoot, isSubmittingFamily,
} = useChatContext(); } = useMessagesViewContext();
const { isSubmitting: isSubmittingAdditional } = useAddedChatContext();
const latestMultiMessage = useRecoilValue(store.latestMessageFamily(index + 1)); const latestMultiMessage = useRecoilValue(store.latestMessageFamily(index + 1));
const isSubmittingFamily = useMemo(
() => isSubmittingRoot || isSubmittingAdditional,
[isSubmittingRoot, isSubmittingAdditional],
);
useEffect(() => { useEffect(() => {
const convoId = conversation?.conversationId; const convoId = conversation?.conversationId;

View file

@ -2,8 +2,8 @@ import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider'; import { Constants } from 'librechat-data-provider';
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useMessagesConversation, useMessagesSubmission } from '~/Providers';
import useScrollToRef from '~/hooks/useScrollToRef'; import useScrollToRef from '~/hooks/useScrollToRef';
import { useChatContext } from '~/Providers';
import store from '~/store'; import store from '~/store';
const threshold = 0.85; const threshold = 0.85;
@ -15,8 +15,8 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
const scrollableRef = useRef<HTMLDivElement | null>(null); const scrollableRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null); const messagesEndRef = useRef<HTMLDivElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false);
const { conversation, setAbortScroll, isSubmitting, abortScroll } = useChatContext(); const { conversation, conversationId } = useMessagesConversation();
const { conversationId } = conversation ?? {}; const { setAbortScroll, isSubmitting, abortScroll } = useMessagesSubmission();
const timeoutIdRef = useRef<NodeJS.Timeout>(); const timeoutIdRef = useRef<NodeJS.Timeout>();