🧠 refactor: Improve Reasoning Component Structure and UX (#10320)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* 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
This commit is contained in:
Danny Avila 2025-10-31 13:05:12 -04:00 committed by GitHub
parent c0f1cfcaba
commit 9b4c4cafb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 95 additions and 176 deletions

View file

@ -1,5 +1,4 @@
import { memo, useMemo, useState, useCallback } from 'react'; import { memo, useMemo } from 'react';
import { useAtom } from 'jotai';
import { ContentTypes } from 'librechat-data-provider'; import { ContentTypes } from 'librechat-data-provider';
import type { import type {
TMessageContentParts, TMessageContentParts,
@ -7,14 +6,11 @@ import type {
TAttachment, TAttachment,
Agents, Agents,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { ThinkingButton } from '~/components/Artifacts/Thinking';
import { MessageContext, SearchContext } from '~/Providers'; import { MessageContext, SearchContext } from '~/Providers';
import { showThinkingAtom } from '~/store/showThinking';
import MemoryArtifacts from './MemoryArtifacts'; import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources'; import Sources from '~/components/Web/Sources';
import { mapAttachments } from '~/utils/map'; import { mapAttachments } from '~/utils/map';
import { EditTextPart } from './Parts'; import { EditTextPart } from './Parts';
import { useLocalize } from '~/hooks';
import Part from './Part'; import Part from './Part';
type ContentPartsProps = { type ContentPartsProps = {
@ -52,53 +48,10 @@ const ContentParts = memo(
siblingIdx, siblingIdx,
setSiblingIdx, setSiblingIdx,
}: ContentPartsProps) => { }: 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 attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; 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) { if (!content) {
return null; return null;
} }
@ -147,91 +100,40 @@ const ContentParts = memo(
<SearchContext.Provider value={{ searchResults }}> <SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} /> <MemoryArtifacts attachments={attachments} />
<Sources messageId={messageId} conversationId={conversationId || undefined} /> <Sources messageId={messageId} conversationId={conversationId || undefined} />
{hasReasoningParts && ( {content.map((part, idx) => {
<div onMouseEnter={handleContentEnter} onMouseLeave={handleContentLeave}> if (!part) {
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2"> return null;
<ThinkingButton }
isExpanded={isExpanded}
onClick={() =>
setIsExpanded((prev) => {
const val = !prev;
setShowThinking(val);
return val;
})
}
label={
effectiveIsSubmitting && isLast
? localize('com_ui_thinking')
: localize('com_ui_thoughts')
}
content={reasoningContent}
isContentHovered={isContentHovered}
/>
</div>
{content
.filter((part) => part?.type === ContentTypes.THINK)
.map((part) => {
const originalIdx = content.indexOf(part);
return (
<MessageContext.Provider
key={`provider-${messageId}-${originalIdx}`}
value={{
messageId,
isExpanded,
conversationId,
partIndex: originalIdx,
nextType: content[originalIdx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<Part
part={part}
attachments={undefined}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${originalIdx}`}
isCreatedByUser={isCreatedByUser}
isLast={originalIdx === content.length - 1}
showCursor={false}
/>
</MessageContext.Provider>
);
})}
</div>
)}
{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];
return ( const toolCallId =
<MessageContext.Provider (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
key={`provider-${messageId}-${originalIdx}`} const partAttachments = attachmentMap[toolCallId];
value={{
messageId, return (
isExpanded, <MessageContext.Provider
conversationId, key={`provider-${messageId}-${idx}`}
partIndex: originalIdx, value={{
nextType: content[originalIdx + 1]?.type, messageId,
isSubmitting: effectiveIsSubmitting, isExpanded: true,
isLatestMessage, conversationId,
}} partIndex: idx,
> nextType: content[idx + 1]?.type,
<Part isSubmitting: effectiveIsSubmitting,
part={part} isLatestMessage,
attachments={attachments} }}
isSubmitting={effectiveIsSubmitting} >
key={`part-${messageId}-${originalIdx}`} <Part
isCreatedByUser={isCreatedByUser} part={part}
isLast={originalIdx === content.length - 1} attachments={partAttachments}
showCursor={originalIdx === content.length - 1 && isLast} isSubmitting={effectiveIsSubmitting}
/> key={`part-${messageId}-${idx}`}
</MessageContext.Provider> isCreatedByUser={isCreatedByUser}
); isLast={idx === content.length - 1}
})} showCursor={idx === content.length - 1 && isLast}
/>
</MessageContext.Provider>
);
})}
</SearchContext.Provider> </SearchContext.Provider>
</> </>
); );

View file

@ -4,10 +4,10 @@ import { DelayedRender } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider'; 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 { useMessageContext } from '~/Providers'; import { useMessageContext } from '~/Providers';
import MarkdownLite from './MarkdownLite'; import MarkdownLite from './MarkdownLite';
import EditMessage from './EditMessage'; import EditMessage from './EditMessage';
import Thinking from './Parts/Thinking';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import Container from './Container'; import Container from './Container';
import Markdown from './Markdown'; import Markdown from './Markdown';

View file

@ -65,6 +65,10 @@ const Part = memo(
if (part.tool_call_ids != null && !text) { if (part.tool_call_ids != null && !text) {
return null; return null;
} }
/** Skip rendering if text is only whitespace to avoid empty Container */
if (!isLast && text.length > 0 && /^\s*$/.test(text)) {
return null;
}
return ( return (
<Container> <Container>
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} /> <Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
@ -75,7 +79,7 @@ const Part = memo(
if (typeof reasoning !== 'string') { if (typeof reasoning !== 'string') {
return null; return null;
} }
return <Reasoning reasoning={reasoning} />; return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
} else if (part.type === ContentTypes.TOOL_CALL) { } else if (part.type === ContentTypes.TOOL_CALL) {
const toolCall = part[ContentTypes.TOOL_CALL]; const toolCall = part[ContentTypes.TOOL_CALL];

View file

@ -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 { 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 { useMessageContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
type ReasoningProps = { type ReasoningProps = {
reasoning: string; reasoning: string;
isLast: boolean;
}; };
/** /**
@ -25,13 +30,16 @@ type ReasoningProps = {
* Key differences from legacy Thinking.tsx: * Key differences from legacy Thinking.tsx:
* - Works with content parts array instead of plain text * - Works with content parts array instead of plain text
* - Strips `<think>` tags instead of `:::thinking:::` markers * - Strips `<think>` tags instead of `:::thinking:::` markers
* - Uses shared ThinkingButton via ContentParts.tsx * - Each THINK part has its own independent toggle button
* - Controlled by MessageContext isExpanded state * - Can be interleaved with other content types
* *
* For legacy text-based messages, see Thinking.tsx component. * For legacy text-based messages, see Thinking.tsx component.
*/ */
const Reasoning = memo(({ reasoning }: ReasoningProps) => { const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
const { isExpanded, nextType } = useMessageContext(); const localize = useLocalize();
const [showThinking] = useAtom(showThinkingAtom);
const [isExpanded, setIsExpanded] = useState(showThinking);
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
// Strip <think> tags from the reasoning content (modern format) // Strip <think> tags from the reasoning content (modern format)
const reasoningText = useMemo(() => { const reasoningText = useMemo(() => {
@ -41,24 +49,45 @@ const Reasoning = memo(({ reasoning }: ReasoningProps) => {
.trim(); .trim();
}, [reasoning]); }, [reasoning]);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
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) { if (!reasoningText) {
return null; return null;
} }
// Note: The toggle button is rendered separately in ContentParts.tsx
// This component only handles the collapsible content area
return ( return (
<div <div className="group/reasoning">
className={cn( <div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
'grid transition-all duration-300 ease-out', <ThinkingButton
nextType !== ContentTypes.THINK && isExpanded && 'mb-4', isExpanded={isExpanded}
)} onClick={handleClick}
style={{ label={label}
gridTemplateRows: isExpanded ? '1fr' : '0fr', content={reasoningText}
}} />
> </div>
<div className="overflow-hidden"> <div
<ThinkingContent>{reasoningText}</ThinkingContent> className={cn(
'grid transition-all duration-300 ease-out',
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
)}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<ThinkingContent>{reasoningText}</ThinkingContent>
</div>
</div> </div>
</div> </div>
); );

View file

@ -35,25 +35,17 @@ export const ThinkingButton = memo(
onClick, onClick,
label, label,
content, content,
isContentHovered = false,
}: { }: {
isExpanded: boolean; isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void; onClick: (e: MouseEvent<HTMLButtonElement>) => void;
label: string; label: string;
content?: string; content?: string;
isContentHovered?: boolean;
}) => { }) => {
const localize = useLocalize(); const localize = useLocalize();
const fontSize = useAtomValue(fontSizeAtom); const fontSize = useAtomValue(fontSizeAtom);
const [isButtonHovered, setIsButtonHovered] = useState(false);
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const isHovered = useMemo(
() => isButtonHovered || isContentHovered,
[isButtonHovered, isContentHovered],
);
const handleCopy = useCallback( const handleCopy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => { (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
@ -71,23 +63,20 @@ export const ThinkingButton = memo(
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className={cn( className={cn(
'group flex flex-1 items-center justify-start rounded-lg leading-[18px]', 'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
fontSize, fontSize,
)} )}
> >
{isHovered ? ( <span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
<Lightbulb className="icon-sm absolute text-text-secondary opacity-100 transition-opacity group-hover/button:opacity-0" />
<ChevronDown <ChevronDown
className={cn( className={cn(
'icon-sm mr-1.5 transform-gpu text-text-primary transition-transform duration-300', 'icon-sm absolute transform-gpu text-text-primary opacity-0 transition-all duration-300 group-hover/button:opacity-100',
isExpanded && 'rotate-180', isExpanded && 'rotate-180',
)} )}
/> />
) : ( </span>
<Lightbulb className="icon-sm mr-1.5 text-text-secondary" />
)}
{label} {label}
</button> </button>
{content && ( {content && (
@ -132,16 +121,12 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
const localize = useLocalize(); const localize = useLocalize();
const showThinking = useAtomValue(showThinkingAtom); const showThinking = useAtomValue(showThinkingAtom);
const [isExpanded, setIsExpanded] = useState(showThinking); const [isExpanded, setIsExpanded] = useState(showThinking);
const [isContentHovered, setIsContentHovered] = useState(false);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => { const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
setIsExpanded((prev) => !prev); setIsExpanded((prev) => !prev);
}, []); }, []);
const handleContentEnter = useCallback(() => setIsContentHovered(true), []);
const handleContentLeave = useCallback(() => setIsContentHovered(false), []);
const label = useMemo(() => localize('com_ui_thoughts'), [localize]); const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
// Extract text content for copy functionality // Extract text content for copy functionality
@ -157,14 +142,13 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
} }
return ( return (
<div onMouseEnter={handleContentEnter} onMouseLeave={handleContentLeave}> <>
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2"> <div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
<ThinkingButton <ThinkingButton
isExpanded={isExpanded} isExpanded={isExpanded}
onClick={handleClick} onClick={handleClick}
label={label} label={label}
content={textContent} content={textContent}
isContentHovered={isContentHovered}
/> />
</div> </div>
<div <div
@ -177,7 +161,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
<ThinkingContent>{children}</ThinkingContent> <ThinkingContent>{children}</ThinkingContent>
</div> </div>
</div> </div>
</div> </>
); );
}); });