From 71427f9149d477829bc57508c9cdbe5eaf83190a Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 14 Jun 2026 03:53:10 +0200 Subject: [PATCH] feat: Group Reasoning with Tool Calls and Polish Activity UI - Treat reasoning (Thoughts) as transparent to tool grouping so interleaved thoughts fold into the tool group instead of splitting it - Group a lone tool call that has reasoning (e.g. a skill) so it gets the same collapsible chrome as multi-tool groups - Count only real tool calls for the group header; add a reasoning indicator and a singular 'Used 1 tool' label - Rework the in-group Thoughts panel: tool-row sizing, rounded content, header copy button, and a floating collapse/copy bar - Round the floating thinking-bar buttons - Refine the agent handoff row and instructions panel with consistent spacing and a copy affordance --- .../Chat/Messages/Content/AgentHandoff.tsx | 69 ++++++-- .../Chat/Messages/Content/Parts/Reasoning.tsx | 154 +++++++++++++++++- .../Chat/Messages/Content/Parts/Thinking.tsx | 4 +- .../Chat/Messages/Content/Parts/index.ts | 2 +- .../Chat/Messages/Content/ToolCallGroup.tsx | 71 ++++++-- .../Content/__tests__/AgentHandoff.test.tsx | 8 +- client/src/locales/en/translation.json | 1 + .../utils/__tests__/groupToolCalls.test.ts | 95 +++++++++++ client/src/utils/groupToolCalls.ts | 44 +++-- 9 files changed, 396 insertions(+), 52 deletions(-) create mode 100644 client/src/utils/__tests__/groupToolCalls.test.ts diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index 5a5505ee60..9ebe736d4c 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 6c059af06c..502c854f0c 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -1,13 +1,22 @@ 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, FloatingThinkingBar } from './Thinking'; import { useLocalize, useExpandCollapse } from '~/hooks'; 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; @@ -46,12 +55,7 @@ const Reasoning = memo(({ reasoning, isLast }: 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) => { e.preventDefault(); @@ -133,4 +137,142 @@ const Reasoning = memo(({ reasoning, isLast }: 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 39240af3d0..90b9e2d00c 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx @@ -184,7 +184,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', )} @@ -207,7 +207,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 6d6bf470b7..b066a62086 100644 --- a/client/src/components/Chat/Messages/Content/Parts/index.ts +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -2,7 +2,7 @@ export * from './Attachment'; export * from './OpenAIImageGen'; export { default as Text } from './Text'; -export { default as Reasoning } from './Reasoning'; +export { default as Reasoning, 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 52b166b110..8aa2e68b6b 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useMemo, useEffect, useCallback } from 'react'; import { useRecoilValue } from 'recoil'; -import { ChevronDown, Users } from 'lucide-react'; +import { ChevronDown, Users, Lightbulb } from 'lucide-react'; import { Tools, Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider'; import type { TAttachment, @@ -10,12 +10,12 @@ import type { } from 'librechat-data-provider'; import type { PartWithIndex } from './ParallelContent'; import { useLocalize, useExpandCollapse, scheduleMessageContentLayoutReconcile } from '~/hooks'; +import { AttachmentGroup, ReasoningCompact } from './Parts'; +import { isBashProgrammaticToolCall } from './routing'; import { cn, getToolDisplayLabel } from '~/utils'; import { StackedToolIcons } from './ToolOutput'; import { useMCPIconMap } from '~/hooks/MCP'; -import { AttachmentGroup } from './Parts'; import store from '~/store'; -import { isBashProgrammaticToolCall } from './routing'; interface ToolMeta { name: string; @@ -106,15 +106,29 @@ export default function ToolCallGroup({ const mcpIconMap = useMCPIconMap(); const rootRef = useRef(null); const cancelLayoutReconcileRef = useRef<(() => void) | null>(null); - const count = parts.length; - const toolMetadata = useMemo(() => parts.map((p) => getToolMeta(p.part)), [parts]); + /** `parts` may include interleaved reasoning ("Thoughts") parts that render + * inside the body but are not tools — count and summarize only the real tool + * calls so the header reads "Used N tools" and the stacked icons stay clean. */ + const toolMetadata = useMemo( + () => parts.map((p) => getToolMeta(p.part)).filter((m): m is ToolMeta => m != null), + [parts], + ); + const count = toolMetadata.length; const allCompleted = useMemo( - () => toolMetadata.every((m) => m?.hasOutput === true), + () => toolMetadata.every((m) => m.hasOutput === true), [toolMetadata], ); - const toolNames = useMemo(() => toolMetadata.map((m) => m?.name ?? ''), [toolMetadata]); - const iconToolNames = useMemo(() => toolMetadata.map((m) => m?.iconName ?? ''), [toolMetadata]); + 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 @@ -151,7 +165,10 @@ export default function ToolCallGroup({ }, [toolNames, localize]); const autoExpand = useRecoilValue(store.autoExpandTools); - const autoCollapse = !autoExpand && count >= 2 && allCompleted; + /** Every group has ≥1 tool; collapse a completed one by default just like a + * multi-tool group, so a lone tool-with-thinking group (e.g. a skill) stays + * visually consistent with the larger groups around it. */ + const autoCollapse = !autoExpand && count >= 1 && allCompleted; const initialState = initialExpansionState?.userOverride === true ? initialExpansionState : null; const [isExpanded, setIsExpanded] = useState( initialState?.isExpanded ?? (autoExpand || !autoCollapse), @@ -221,12 +238,14 @@ export default function ToolCallGroup({ subagentsDone ? localize('com_ui_ran_n_agents', { 0: String(count) }) : localize('com_ui_running_n_agents', { 0: String(count) }); - const groupLabel = allSubagents - ? getSubagentLabel() - : localize('com_ui_used_n_tools', { 0: String(count) }); + const getToolsLabel = () => + count === 1 + ? localize('com_ui_used_one_tool') + : localize('com_ui_used_n_tools', { 0: String(count) }); + const groupLabel = allSubagents ? getSubagentLabel() : getToolsLabel(); const hasActiveToolCall = useMemo( - () => isSubmitting && toolMetadata.some((m) => m && !m.hasOutput), + () => isSubmitting && toolMetadata.some((m) => !m.hasOutput), [toolMetadata, isSubmitting], ); @@ -244,7 +263,7 @@ export default function ToolCallGroup({ className="inline-flex w-full items-center gap-2 py-1 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy" onClick={handleToggle} aria-expanded={isExpanded} - aria-label={groupLabel} + aria-label={hasReasoning ? `${groupLabel}, ${localize('com_ui_thoughts')}` : groupLabel} > {allSubagents ? ( /** Subagent groups don't have per-tool icons — StackedToolIcons @@ -275,6 +294,9 @@ export default function ToolCallGroup({ {toolNameSummary && !allSubagents && ( — {toolNameSummary} )} + {hasReasoning && ( +