diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index 29a577eef8..d884d5a893 100644 --- a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -1,7 +1,9 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { ChevronDown } from 'lucide-react'; import { EModelEndpoint, Constants } from 'librechat-data-provider'; +import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client'; import type { TMessage } from 'librechat-data-provider'; +import type { MouseEvent } from 'react'; import MessageIcon from '~/components/Share/MessageIcon'; import { useLocalize, useExpandCollapse } from '~/hooks'; import { useAgentsMapContext } from '~/Providers'; @@ -16,6 +18,7 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = const localize = useLocalize(); const agentsMap = useAgentsMapContext(); const [showInfo, setShowInfo] = useState(false); + const [isCopied, setIsCopied] = useState(false); const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo); const targetAgentId = useMemo(() => { @@ -44,9 +47,24 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = }, [_args]) as string; const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]); + const agentName = targetAgent?.name || localize('com_ui_agent'); + + const handleCopy = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(args); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }, + [args], + ); + + const copyLabel = isCopied + ? localize('com_ui_copied_to_clipboard') + : localize('com_ui_copy_to_clipboard'); return ( -
+
+ } + />
-
{args}
+
+                {args}
+              
)} diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index bf2dd73e70..dc4fabd552 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -1,14 +1,23 @@ import { memo, useMemo, useState, useCallback, useRef, useId } from 'react'; import { useAtomValue } from 'jotai'; +import { Lightbulb, ChevronDown } from 'lucide-react'; import { ContentTypes } from 'librechat-data-provider'; +import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client'; import type { MouseEvent, FocusEvent } from 'react'; import { ThinkingContent, ThinkingButton, ThinkingLabel, FloatingThinkingBar } from './Thinking'; import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks'; import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import { showThinkingAtom } from '~/store/showThinking'; +import { fontSizeAtom } from '~/store/fontSize'; import { useMessageContext } from '~/Providers'; import { cn } from '~/utils'; +const stripThinkTags = (reasoning: string): string => + reasoning + .replace(/^\s*/, '') + .replace(/\s*<\/think>$/, '') + .trim(); + type ReasoningProps = { reasoning: string; isLast: boolean; @@ -62,12 +71,7 @@ const Reasoning = memo((props: ReasoningProps) => { const { isSubmitting, isLatestMessage, nextType } = useMessageContext(); // Strip tags from the reasoning content (modern format) - const reasoningText = useMemo(() => { - return reasoning - .replace(/^\s*/, '') - .replace(/\s*<\/think>$/, '') - .trim(); - }, [reasoning]); + const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]); const handleClick = useCallback( (e: MouseEvent) => { @@ -170,4 +174,142 @@ const Reasoning = memo((props: ReasoningProps) => { ); }); +Reasoning.displayName = 'Reasoning'; + +type ReasoningCompactProps = { + reasoning: string; + label: string; +}; + +/** + * Compact reasoning row for use INSIDE a ToolCallGroup. Keeps the tool-row + * header rhythm (icon + label + chevron) so an interleaved thought reads as a + * sibling of the surrounding tool calls, while retaining the standalone + * {@link Reasoning} affordances — a hover-revealed copy button on the header and + * a floating collapse + copy bar inside the rounded content panel. + */ +export const ReasoningCompact = memo(({ reasoning, label }: ReasoningCompactProps) => { + const contentId = useId(); + const localize = useLocalize(); + const fontSize = useAtomValue(fontSizeAtom); + const showThinking = useAtomValue(showThinkingAtom); + const [isExpanded, setIsExpanded] = useState(showThinking); + const [isBarVisible, setIsBarVisible] = useState(false); + const [isCopied, setIsCopied] = useState(false); + const containerRef = useRef(null); + const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded); + + const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]); + + const handleToggle = useCallback((e: MouseEvent) => { + e.preventDefault(); + setIsExpanded((prev) => !prev); + }, []); + + const handleCopy = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(reasoningText); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }, + [reasoningText], + ); + + const revealBar = useCallback(() => setIsBarVisible(true), []); + const hideBar = useCallback(() => { + if (!containerRef.current?.contains(document.activeElement)) { + setIsBarVisible(false); + } + }, []); + const handleBlur = useCallback((e: FocusEvent) => { + if (!containerRef.current?.contains(e.relatedTarget as Node)) { + setIsBarVisible(false); + } + }, []); + + const copyLabel = isCopied + ? localize('com_ui_copied_to_clipboard') + : localize('com_ui_copy_thoughts_to_clipboard'); + + if (!reasoningText) { + return null; + } + + return ( +
+
+ + + {isCopied ? ( +
+
+
+
+

{reasoningText}

+ +
+
+
+
+ ); +}); + +ReasoningCompact.displayName = 'ReasoningCompact'; + export default Reasoning; diff --git a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx index 6cac5568d9..1f2c0d3a87 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx @@ -250,7 +250,7 @@ export const FloatingThinkingBar = memo( aria-expanded={isExpanded} aria-controls={contentId} className={cn( - 'flex items-center justify-center rounded p-1.5 text-text-tertiary', + 'flex items-center justify-center rounded-lg p-1.5 text-text-tertiary', 'hover:bg-surface-hover hover:text-text-primary', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy', )} @@ -272,7 +272,7 @@ export const FloatingThinkingBar = memo( onClick={handleCopy} aria-label={copyTooltip} className={cn( - 'flex items-center justify-center rounded p-1.5 text-text-tertiary', + 'flex items-center justify-center rounded-lg p-1.5 text-text-tertiary', 'hover:bg-surface-hover hover:text-text-primary', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy', )} diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts index 0127e6ec2a..3b125e2b40 100644 --- a/client/src/components/Chat/Messages/Content/Parts/index.ts +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -3,7 +3,7 @@ export * from './OpenAIImageGen'; export { default as Text } from './Text'; export { default as CollapsibleText } from './CollapsibleText'; -export { default as Reasoning, ReasoningMarker } from './Reasoning'; +export { default as Reasoning, ReasoningMarker, ReasoningCompact } from './Reasoning'; export { default as EmptyText } from './EmptyText'; export { default as LogContent } from './LogContent'; export { default as ExecuteCode } from './ExecuteCode'; diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index cdd541aaa1..6da270d3d7 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useMemo, useEffect, useCallback } from 'react'; import { useRecoilValue } from 'recoil'; import { Button } from '@librechat/client'; -import { ChevronDown, MessageCircleQuestion, Users } from 'lucide-react'; +import { ChevronDown, Lightbulb, MessageCircleQuestion, Users } from 'lucide-react'; import { Tools, Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider'; import type { TAttachment, @@ -22,7 +22,7 @@ import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP'; import { isBashProgrammaticToolCall } from './routing'; import { ASK_USER_QUESTION } from '~/utils/approval'; import { StackedToolIcons } from './ToolOutput'; -import { AttachmentGroup } from './Parts'; +import { AttachmentGroup, ReasoningCompact } from './Parts'; import { ROW_GLYPH_SLOT } from './rows'; import store from '~/store'; @@ -164,6 +164,14 @@ export default function ToolCallGroup({ const toolNames = useMemo(() => toolMetadata.map((m) => m.name), [toolMetadata]); const iconToolNames = useMemo(() => toolMetadata.map((m) => m.iconName), [toolMetadata]); + /** Reasoning interleaved with the tool calls renders inside the body but is + * hidden while collapsed — surface a lightbulb in the header so the summary + * hints that the group also contains thoughts. */ + const hasReasoning = useMemo( + () => parts.some((p) => p.part.type === ContentTypes.THINK), + [parts], + ); + /** Subagent tool calls get their own label verb ("Running/Ran N agents") * since "Used N tools" reads oddly when the "tools" are actually child * agents. `subagentCount === count` ⇒ the group is 100% subagents. */ @@ -216,7 +224,10 @@ export default function ToolCallGroup({ /** A labeled activity block is summarized by its header, so it collapses * even at a single tool call — agent runs are full of one-call batches, * and leaving those expanded defeats the grouping. */ - const autoCollapse = !autoExpand && allCompleted && (count >= 2 || activityLabelText.length > 0); + /** Every group has >= 1 tool; collapse a completed one by default just like + * a multi-tool group, so a lone tool-with-thinking group (a skill, say) + * stays visually consistent with the larger groups around it. */ + const autoCollapse = !autoExpand && allCompleted && (count >= 1 || activityLabelText.length > 0); const suppressAutoExpand = withinActivityPhase && !hasPendingApproval; const initialState = initialExpansionState?.userOverride === true ? initialExpansionState : null; const [isExpanded, setIsExpanded] = useState( @@ -318,7 +329,9 @@ export default function ToolCallGroup({ ? localize('com_ui_asked_n_questions', { 0: String(count) }) : localize('com_ui_asking_n_questions', { 0: String(count) }); } - return localize('com_ui_used_n_tools', { 0: String(count) }); + return count === 1 + ? localize('com_ui_used_one_tool') + : localize('com_ui_used_n_tools', { 0: String(count) }); }; /** The generated line wins over the generic category verb — but only once * it exists. An unfilled label part leaves the block rendering exactly as @@ -347,7 +360,7 @@ export default function ToolCallGroup({ className="inline-flex h-auto w-full items-center justify-start gap-2 rounded-none bg-transparent p-0 py-1 text-text-secondary hover:bg-transparent hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy focus-visible:ring-offset-0" onClick={handleToggle} aria-expanded={isExpanded} - aria-label={groupLabel} + aria-label={hasReasoning ? `${groupLabel}, ${localize('com_ui_thoughts')}` : groupLabel} > {allSubagents || allAskQuestions ? ( /** Homogeneous category groups get a single category glyph instead @@ -393,6 +406,9 @@ export default function ToolCallGroup({ · {toolNameSummary} )} + {hasReasoning && ( +