🧠 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 { 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(
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
<Sources messageId={messageId} conversationId={conversationId || undefined} />
{hasReasoningParts && (
<div onMouseEnter={handleContentEnter} onMouseLeave={handleContentLeave}>
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
<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];
{content.map((part, idx) => {
if (!part) {
return null;
}
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={attachments}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${originalIdx}`}
isCreatedByUser={isCreatedByUser}
isLast={originalIdx === content.length - 1}
showCursor={originalIdx === content.length - 1 && isLast}
/>
</MessageContext.Provider>
);
})}
const toolCallId =
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const partAttachments = attachmentMap[toolCallId];
return (
<MessageContext.Provider
key={`provider-${messageId}-${idx}`}
value={{
messageId,
isExpanded: true,
conversationId,
partIndex: idx,
nextType: content[idx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<Part
part={part}
attachments={partAttachments}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={idx === content.length - 1}
showCursor={idx === content.length - 1 && isLast}
/>
</MessageContext.Provider>
);
})}
</SearchContext.Provider>
</>
);

View file

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

View file

@ -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 (
<Container>
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
@ -75,7 +79,7 @@ const Part = memo(
if (typeof reasoning !== 'string') {
return null;
}
return <Reasoning reasoning={reasoning} />;
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
} else if (part.type === 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 { 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 `<think>` 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 <think> 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<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) {
return null;
}
// Note: The toggle button is rendered separately in ContentParts.tsx
// This component only handles the collapsible content area
return (
<div
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 className="group/reasoning">
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
<ThinkingButton
isExpanded={isExpanded}
onClick={handleClick}
label={label}
content={reasoningText}
/>
</div>
<div
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>
);

View file

@ -35,25 +35,17 @@ export const ThinkingButton = memo(
onClick,
label,
content,
isContentHovered = false,
}: {
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => 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<HTMLButtonElement>) => {
e.stopPropagation();
@ -71,23 +63,20 @@ export const ThinkingButton = memo(
<button
type="button"
onClick={onClick}
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
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,
)}
>
{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
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',
)}
/>
) : (
<Lightbulb className="icon-sm mr-1.5 text-text-secondary" />
)}
</span>
{label}
</button>
{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<HTMLButtonElement>) => {
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 (
<div onMouseEnter={handleContentEnter} onMouseLeave={handleContentLeave}>
<>
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
<ThinkingButton
isExpanded={isExpanded}
onClick={handleClick}
label={label}
content={textContent}
isContentHovered={isContentHovered}
/>
</div>
<div
@ -177,7 +161,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
<ThinkingContent>{children}</ThinkingContent>
</div>
</div>
</div>
</>
);
});