From 9b4c4cafb6a2303f5db0eebd7ab4403a874a4243 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 31 Oct 2025 13:05:12 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A0=20refactor:=20Improve=20Reasoning?= =?UTF-8?q?=20Component=20Structure=20and=20UX=20(#10320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Reasoning components with independent toggle buttons - Refactored ThinkingButton to remove unnecessary state and props. - Updated ContentParts to simplify content rendering and remove hover handling. - Improved Reasoning component to include independent toggle functionality for each THINK part. - Adjusted styles for better layout consistency and user experience. * refactor: isolate hover effects for Reasoning - Updated ThinkingButton to improve hover effects and layout consistency. - Refactored Reasoning component to include a new wrapper class for better styling. - Adjusted icon visibility and transitions for a smoother user experience. * fix: Prevent rendering of empty messages in Chat component - Added a check to skip rendering if the message text is only whitespace, improving the user interface by avoiding empty containers. * chore: Replace div with fragment in Thinking component for cleaner markup * chore: move Thinking component to Content Parts directory * refactor: prevent rendering of whitespace-only text in Part component only for edge cases --- .../Chat/Messages/Content/ContentParts.tsx | 166 ++++-------------- .../Chat/Messages/Content/MessageContent.tsx | 2 +- .../components/Chat/Messages/Content/Part.tsx | 6 +- .../Chat/Messages/Content/Parts/Reasoning.tsx | 67 +++++-- .../Messages/Content/Parts}/Thinking.tsx | 30 +--- 5 files changed, 95 insertions(+), 176 deletions(-) rename client/src/components/{Artifacts => Chat/Messages/Content/Parts}/Thinking.tsx (82%) diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 157e57aa4a..14883b4b94 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -1,5 +1,4 @@ -import { memo, useMemo, useState, useCallback } from 'react'; -import { useAtom } from 'jotai'; +import { memo, useMemo } from 'react'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts, @@ -7,14 +6,11 @@ import type { TAttachment, Agents, } from 'librechat-data-provider'; -import { ThinkingButton } from '~/components/Artifacts/Thinking'; import { MessageContext, SearchContext } from '~/Providers'; -import { showThinkingAtom } from '~/store/showThinking'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; import { mapAttachments } from '~/utils/map'; import { EditTextPart } from './Parts'; -import { useLocalize } from '~/hooks'; import Part from './Part'; type ContentPartsProps = { @@ -52,53 +48,10 @@ const ContentParts = memo( siblingIdx, setSiblingIdx, }: ContentPartsProps) => { - const localize = useLocalize(); - const [showThinking, setShowThinking] = useAtom(showThinkingAtom); - const [isExpanded, setIsExpanded] = useState(showThinking); - const [isContentHovered, setIsContentHovered] = useState(false); const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; - const handleContentEnter = useCallback(() => setIsContentHovered(true), []); - const handleContentLeave = useCallback(() => setIsContentHovered(false), []); - - const hasReasoningParts = useMemo(() => { - const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false; - const allThinkPartsHaveContent = - content?.every((part) => { - if (part?.type !== ContentTypes.THINK) { - return true; - } - - if (typeof part.think === 'string') { - const cleanedContent = part.think.replace(/<\/?think>/g, '').trim(); - return cleanedContent.length > 0; - } - - return false; - }) ?? false; - - return hasThinkPart && allThinkPartsHaveContent; - }, [content]); - - // Extract all reasoning text for copy functionality - const reasoningContent = useMemo(() => { - if (!content) { - return ''; - } - return content - .filter((part) => part?.type === ContentTypes.THINK) - .map((part) => { - if (typeof part?.think === 'string') { - return part.think.replace(/<\/?think>/g, '').trim(); - } - return ''; - }) - .filter(Boolean) - .join('\n\n'); - }, [content]); - if (!content) { return null; } @@ -147,91 +100,40 @@ const ContentParts = memo( - {hasReasoningParts && ( -
-
- - setIsExpanded((prev) => { - const val = !prev; - setShowThinking(val); - return val; - }) - } - label={ - effectiveIsSubmitting && isLast - ? localize('com_ui_thinking') - : localize('com_ui_thoughts') - } - content={reasoningContent} - isContentHovered={isContentHovered} - /> -
- {content - .filter((part) => part?.type === ContentTypes.THINK) - .map((part) => { - const originalIdx = content.indexOf(part); - return ( - - - - ); - })} -
- )} - {content - .filter((part) => part && part.type !== ContentTypes.THINK) - .map((part) => { - const originalIdx = content.indexOf(part); - const toolCallId = - (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? ''; - const attachments = attachmentMap[toolCallId]; + {content.map((part, idx) => { + if (!part) { + return null; + } - return ( - - - - ); - })} + const toolCallId = + (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? ''; + const partAttachments = attachmentMap[toolCallId]; + + return ( + + + + ); + })}
); diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index 9204d3738d..7a823a07e9 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -4,10 +4,10 @@ import { DelayedRender } from '@librechat/client'; import type { TMessage } from 'librechat-data-provider'; import type { TMessageContentProps, TDisplayProps } from '~/common'; import Error from '~/components/Messages/Content/Error'; -import Thinking from '~/components/Artifacts/Thinking'; import { useMessageContext } from '~/Providers'; import MarkdownLite from './MarkdownLite'; import EditMessage from './EditMessage'; +import Thinking from './Parts/Thinking'; import { useLocalize } from '~/hooks'; import Container from './Container'; import Markdown from './Markdown'; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index aa9f4da82d..b8d70f33e4 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -65,6 +65,10 @@ const Part = memo( if (part.tool_call_ids != null && !text) { return null; } + /** Skip rendering if text is only whitespace to avoid empty Container */ + if (!isLast && text.length > 0 && /^\s*$/.test(text)) { + return null; + } return ( @@ -75,7 +79,7 @@ const Part = memo( if (typeof reasoning !== 'string') { return null; } - return ; + return ; } else if (part.type === ContentTypes.TOOL_CALL) { const toolCall = part[ContentTypes.TOOL_CALL]; diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index 8f7da551d0..0c1d0cc944 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -1,11 +1,16 @@ -import { memo, useMemo } from 'react'; +import { memo, useMemo, useState, useCallback } from 'react'; +import { useAtom } from 'jotai'; +import type { MouseEvent } from 'react'; import { ContentTypes } from 'librechat-data-provider'; -import { ThinkingContent } from '~/components/Artifacts/Thinking'; +import { ThinkingContent, ThinkingButton } from './Thinking'; +import { showThinkingAtom } from '~/store/showThinking'; import { useMessageContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; type ReasoningProps = { reasoning: string; + isLast: boolean; }; /** @@ -25,13 +30,16 @@ type ReasoningProps = { * Key differences from legacy Thinking.tsx: * - Works with content parts array instead of plain text * - Strips `` tags instead of `:::thinking:::` markers - * - Uses shared ThinkingButton via ContentParts.tsx - * - Controlled by MessageContext isExpanded state + * - Each THINK part has its own independent toggle button + * - Can be interleaved with other content types * * For legacy text-based messages, see Thinking.tsx component. */ -const Reasoning = memo(({ reasoning }: ReasoningProps) => { - const { isExpanded, nextType } = useMessageContext(); +const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => { + const localize = useLocalize(); + const [showThinking] = useAtom(showThinkingAtom); + const [isExpanded, setIsExpanded] = useState(showThinking); + const { isSubmitting, isLatestMessage, nextType } = useMessageContext(); // Strip tags from the reasoning content (modern format) const reasoningText = useMemo(() => { @@ -41,24 +49,45 @@ const Reasoning = memo(({ reasoning }: ReasoningProps) => { .trim(); }, [reasoning]); + const handleClick = useCallback((e: MouseEvent) => { + e.preventDefault(); + setIsExpanded((prev) => !prev); + }, []); + + const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + + const label = useMemo( + () => + effectiveIsSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts'), + [effectiveIsSubmitting, localize, isLast], + ); + if (!reasoningText) { return null; } - // Note: The toggle button is rendered separately in ContentParts.tsx - // This component only handles the collapsible content area return ( -
-
- {reasoningText} +
+
+ +
+
+
+ {reasoningText} +
); diff --git a/client/src/components/Artifacts/Thinking.tsx b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx similarity index 82% rename from client/src/components/Artifacts/Thinking.tsx rename to client/src/components/Chat/Messages/Content/Parts/Thinking.tsx index 25d5810e16..f84f4edc9d 100644 --- a/client/src/components/Artifacts/Thinking.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx @@ -35,25 +35,17 @@ export const ThinkingButton = memo( onClick, label, content, - isContentHovered = false, }: { isExpanded: boolean; onClick: (e: MouseEvent) => void; label: string; content?: string; - isContentHovered?: boolean; }) => { const localize = useLocalize(); const fontSize = useAtomValue(fontSizeAtom); - const [isButtonHovered, setIsButtonHovered] = useState(false); const [isCopied, setIsCopied] = useState(false); - const isHovered = useMemo( - () => isButtonHovered || isContentHovered, - [isButtonHovered, isContentHovered], - ); - const handleCopy = useCallback( (e: MouseEvent) => { e.stopPropagation(); @@ -71,23 +63,20 @@ export const ThinkingButton = memo( {content && ( @@ -132,16 +121,12 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN const localize = useLocalize(); const showThinking = useAtomValue(showThinkingAtom); const [isExpanded, setIsExpanded] = useState(showThinking); - const [isContentHovered, setIsContentHovered] = useState(false); const handleClick = useCallback((e: MouseEvent) => { e.preventDefault(); setIsExpanded((prev) => !prev); }, []); - const handleContentEnter = useCallback(() => setIsContentHovered(true), []); - const handleContentLeave = useCallback(() => setIsContentHovered(false), []); - const label = useMemo(() => localize('com_ui_thoughts'), [localize]); // Extract text content for copy functionality @@ -157,14 +142,13 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN } return ( -
+ <>
{children}
-
+ ); });