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
This commit is contained in:
Marco Beretta 2026-06-14 03:53:10 +02:00
parent e1f2298cb8
commit bf5e844a29
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
9 changed files with 366 additions and 63 deletions

View file

@ -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<AgentHandoffProps> = ({ 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<AgentHandoffProps> = ({ 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<HTMLButtonElement>) => {
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 (
<div className="my-1">
<div className="my-1.5">
<button
type="button"
className={cn(
@ -58,9 +76,9 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
disabled={!hasInfo}
onClick={hasInfo ? () => setShowInfo(!showInfo) : undefined}
aria-expanded={hasInfo ? showInfo : undefined}
aria-label={`${localize('com_ui_transferred_to')} ${targetAgent?.name || localize('com_ui_agent')}`}
aria-label={`${localize('com_ui_transferred_to')} ${agentName}`}
>
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full ring-1 ring-border-light">
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full ring-1 ring-border-light">
<MessageIcon
message={
{
@ -72,12 +90,13 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
/>
</div>
<span className="select-none">{localize('com_ui_transferred_to')}</span>
<span className="select-none font-medium text-text-primary">
{targetAgent?.name || localize('com_ui_agent')}
</span>
<span className="select-none font-medium text-text-primary">{agentName}</span>
{hasInfo && (
<ChevronDown
className={cn('ml-1 h-3 w-3 transition-transform', showInfo && 'rotate-180')}
className={cn(
'size-4 shrink-0 translate-y-[1px] text-text-secondary transition-transform duration-200 ease-out',
showInfo && 'rotate-180',
)}
aria-hidden="true"
/>
)}
@ -85,11 +104,37 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
{hasInfo && (
<div className="ml-8 mt-2 rounded-lg border border-border-light bg-surface-secondary p-3 text-xs">
<div className="mb-1 font-medium text-text-secondary">
{localize('com_ui_handoff_instructions')}:
<div className="group/handoff my-2 ml-8 rounded-xl border border-border-light bg-surface-secondary p-4 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-[10px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_ui_handoff_instructions')}
</span>
<TooltipAnchor
description={copyLabel}
render={
<button
type="button"
onClick={handleCopy}
aria-label={copyLabel}
className={cn(
'flex shrink-0 items-center justify-center rounded-lg p-1 text-text-tertiary transition-opacity duration-150',
'opacity-0 group-focus-within/handoff:opacity-100 group-hover/handoff:opacity-100',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
>
{isCopied ? (
<CheckMark className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Clipboard size="14" aria-hidden="true" />
)}
</button>
}
/>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-text-primary">{args}</pre>
<pre className="overflow-x-auto whitespace-pre-wrap leading-relaxed text-text-primary">
{args}
</pre>
</div>
)}
</div>

View file

@ -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(/^<think>\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 <think> tags from the reasoning content (modern format)
const reasoningText = useMemo(() => {
return reasoning
.replace(/^<think>\s*/, '')
.replace(/\s*<\/think>$/, '')
.trim();
}, [reasoning]);
const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]);
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
@ -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<HTMLDivElement>(null);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]);
const handleToggle = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsExpanded((prev) => !prev);
}, []);
const handleCopy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
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 (
<div
ref={containerRef}
className="group/reasoning-compact"
onMouseEnter={revealBar}
onMouseLeave={hideBar}
onFocus={revealBar}
onBlur={handleBlur}
>
<div className="relative my-1.5 flex h-5 shrink-0 items-center gap-1.5">
<button
type="button"
onClick={handleToggle}
aria-expanded={isExpanded}
aria-controls={contentId}
className="inline-flex min-w-0 flex-1 items-center gap-2 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
<Lightbulb className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="tool-status-text font-medium">{label}</span>
<ChevronDown
className={cn(
'size-4 shrink-0 translate-y-[1px] text-text-secondary transition-transform duration-200 ease-out',
isExpanded && 'rotate-180',
)}
aria-hidden="true"
/>
</button>
<TooltipAnchor
description={copyLabel}
render={
<button
type="button"
onClick={handleCopy}
aria-label={copyLabel}
className={cn(
'flex shrink-0 items-center justify-center rounded-lg p-1 text-text-tertiary transition-opacity duration-150',
'opacity-0 group-focus-within/reasoning-compact:opacity-100 group-hover/reasoning-compact:opacity-100',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
>
{isCopied ? (
<CheckMark className="h-4 w-4" aria-hidden="true" />
) : (
<Clipboard size="16" aria-hidden="true" />
)}
</button>
}
/>
</div>
<div
id={contentId}
role="group"
aria-label={label}
aria-hidden={!isExpanded || undefined}
style={expandStyle}
>
<div className="overflow-hidden" ref={expandRef}>
<div className="relative my-2 rounded-2xl border border-border-light bg-surface-secondary p-4 pb-9 text-text-secondary">
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{reasoningText}</p>
<FloatingThinkingBar
isVisible={isBarVisible && isExpanded}
isExpanded={isExpanded}
onClick={handleToggle}
content={reasoningText}
contentId={contentId}
/>
</div>
</div>
</div>
</div>
);
});
ReasoningCompact.displayName = 'ReasoningCompact';
export default Reasoning;

View file

@ -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',
)}

View file

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

View file

@ -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}
</span>
)}
{hasReasoning && (
<Lightbulb className="size-3.5 shrink-0 text-text-secondary" aria-hidden="true" />
)}
<ChevronDown
className={cn(
'size-4 shrink-0 text-text-secondary transition-transform duration-200 ease-out',
@ -410,9 +426,20 @@ export default function ToolCallGroup({
{shouldRenderBody && (
<div className="overflow-hidden" ref={expandRef}>
<div className="py-0.5 pl-4">
{parts.map(({ part, idx }) =>
renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand),
)}
{parts.map(({ part, idx }) => {
if (part.type === ContentTypes.THINK) {
const think = part.think;
const reasoning = typeof think === 'string' ? think : (think?.value ?? '');
const label =
isSubmitting && idx === lastContentIdx
? localize('com_ui_thinking')
: localize('com_ui_thoughts');
return (
<ReasoningCompact key={`reasoning-${idx}`} reasoning={reasoning} label={label} />
);
}
return renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand);
})}
</div>
</div>
)}

View file

@ -59,7 +59,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
args: '{"key":"value"}',
});
const button = screen.getByRole('button');
const button = screen.getByRole('button', { name: /Transferred to/i });
expect(button.tagName).toBe('BUTTON');
});
@ -69,7 +69,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
args: '{"key":"value"}',
});
const button = screen.getByRole('button');
const button = screen.getByRole('button', { name: /Transferred to/i });
expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toContain('Transferred to');
expect(button.getAttribute('aria-label')).toContain('Test Agent');
@ -81,7 +81,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
args: '{"key":"value"}',
});
const button = screen.getByRole('button');
const button = screen.getByRole('button', { name: /Transferred to/i });
expect(button.className).toContain('focus-visible:ring-2');
});
@ -91,7 +91,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
args: '',
});
const button = screen.getByRole('button');
const button = screen.getByRole('button', { name: /Transferred to/i });
expect(button).toBeDisabled();
});
});

View file

@ -2483,6 +2483,7 @@
"com_ui_use_prompt": "Use Prompt",
"com_ui_used": "Used",
"com_ui_used_n_tools": "Used {{0}} tools",
"com_ui_used_one_tool": "Used 1 tool",
"com_ui_user": "User",
"com_ui_user_group_permissions": "User & Group Permissions",
"com_ui_user_provides_key": "Each user provides their own key",

View file

@ -22,26 +22,33 @@ const label = (text: string): TMessageContentParts =>
pending: text.length === 0,
}) as unknown as TMessageContentParts;
const textPart = (text = 'answer'): TMessageContentParts =>
({ type: ContentTypes.TEXT, text }) as unknown as TMessageContentParts;
const transferPart = (): TMessageContentParts =>
({
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: { id: 'x', name: 'lc_transfer_to_agent', args: '{}' },
}) as unknown as TMessageContentParts;
const withIndex = (parts: TMessageContentParts[]): PartWithIndex[] =>
parts.map((part, idx) => ({ part, idx }));
describe('groupSequentialToolCalls with activity labels', () => {
/**
* Every batch publishes its reservation the moment the batch ends, so an
* empty label is the NORMAL state while generation is in flight. It must
* render exactly as the feature-off path does: a lone tool call stays a
* single (no group wrapper) and THINK parts stay standalone.
* empty label is the NORMAL state while generation is in flight. Reasoning
* folds into the run either way, so a lone tool call with its thinking
* still groups, just without a labelPart.
*/
it('leaves a single tool call and its reasoning untouched while the label is empty', () => {
it('groups a single tool call with its reasoning while the label is empty', () => {
const grouped = groupSequentialToolCalls(
withIndex([think('deciding what to search'), toolCall('t1'), label('')]),
);
expect(grouped).toHaveLength(2);
expect(grouped[0]).toMatchObject({ type: 'single' });
expect(grouped[1]).toMatchObject({ type: 'single' });
/** No labelPart anywhere, and crucially no 'tool-group' wrapper. */
expect(grouped.some((entry) => entry.type === 'tool-group')).toBe(false);
expect(grouped).toHaveLength(1);
expect(grouped[0]).toMatchObject({ type: 'tool-group' });
expect((grouped[0] as { labelPart?: PartWithIndex }).labelPart).toBeUndefined();
});
/** A blank/failed fill is permanent, and must stay equally invisible. */
@ -198,3 +205,74 @@ describe('groupSequentialToolCalls with activity labels', () => {
});
});
});
describe('groupSequentialToolCalls reasoning transparency', () => {
it('groups two adjacent tool calls', () => {
const result = groupSequentialToolCalls(withIndex([toolCall('a'), toolCall('b')]));
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ type: 'tool-group' });
expect(result[0].type === 'tool-group' && result[0].parts).toHaveLength(2);
});
it('keeps a single tool call ungrouped', () => {
const result = groupSequentialToolCalls(withIndex([toolCall('a')]));
expect(result).toEqual([{ type: 'single', part: { part: expect.anything(), idx: 0 } }]);
});
it('absorbs reasoning interleaved between tool calls into one group', () => {
const result = groupSequentialToolCalls(
withIndex([toolCall('a'), think('mid'), toolCall('b')]),
);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('tool-group');
expect(result[0].type === 'tool-group' && result[0].parts.map((p) => p.idx)).toEqual([0, 1, 2]);
});
it('absorbs leading and trailing reasoning around a tool run', () => {
const result = groupSequentialToolCalls(
withIndex([think('lead'), toolCall('a'), toolCall('b'), think('trail')]),
);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('tool-group');
expect(result[0].type === 'tool-group' && result[0].parts).toHaveLength(4);
});
it('groups a lone tool call wrapped in reasoning', () => {
const result = groupSequentialToolCalls(
withIndex([think('lead'), toolCall('a'), think('trail')]),
);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('tool-group');
expect(result[0].type === 'tool-group' && result[0].parts.map((p) => p.idx)).toEqual([0, 1, 2]);
});
it('keeps a lone tool call without reasoning inline', () => {
const result = groupSequentialToolCalls(withIndex([toolCall('a'), textPart()]));
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
});
it('keeps pure reasoning (no tool call) standalone', () => {
const result = groupSequentialToolCalls(withIndex([think('only'), textPart()]));
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
});
it('does not pull a thought-then-answer tail into a preceding group', () => {
const result = groupSequentialToolCalls(
withIndex([toolCall('a'), toolCall('b'), textPart(), think('tail'), textPart('final')]),
);
expect(result.map((g) => g.type)).toEqual(['tool-group', 'single', 'single', 'single']);
});
it('splits tool runs separated by a non-reasoning part', () => {
const result = groupSequentialToolCalls(
withIndex([toolCall('a'), toolCall('b'), textPart(), toolCall('c'), toolCall('d')]),
);
expect(result.map((g) => g.type)).toEqual(['tool-group', 'single', 'tool-group']);
});
it('does not group transfer/handoff tool calls', () => {
const result = groupSequentialToolCalls(withIndex([transferPart(), transferPart()]));
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
});
});

View file

@ -50,6 +50,28 @@ function isGroupableToolCall(part: TMessageContentParts): boolean {
return true;
}
/** Reasoning ("Thoughts") parts are transparent to grouping: a thought
* interleaved between tool calls joins the run instead of splitting it, so
* reasoning models that think between every call still collapse into a single
* tool group. A run becomes a group once it holds >= 2 tool calls, OR a single
* tool call accompanied by reasoning, so a lone tool wrapped in thinking
* (a skill invocation, say) still gets the grouped chrome with its thoughts
* folded in. A run of pure reasoning (no tool call) keeps rendering as its own
* standalone card. */
function isReasoningPart(part: TMessageContentParts): boolean {
return part.type === ContentTypes.THINK;
}
function countToolCalls(parts: PartWithIndex[]): number {
let count = 0;
for (const { part } of parts) {
if (isGroupableToolCall(part)) {
count += 1;
}
}
return count;
}
/**
* True when the label covers ANY `transfer_to_*` handoff call. Transfer
* parts are never groupable, so the flush at the handoff card leaves such a
@ -83,10 +105,9 @@ function coversTransferCall(labelPart: TMessageContentParts, allParts: PartWithI
* calls) becomes one labeled group — the claude.ai-style hierarchy. Any
* other part type breaks the block.
*
* Legacy behavior is preserved exactly when no ACTIVITY_LABEL part arrives
* (feature off, older conversations): the tentative block is re-split so
* THINK parts render standalone in their original positions and only runs
* of >= 2 tool calls group.
* Without an ACTIVITY_LABEL part (feature off, older conversations), the
* block still groups: reasoning stays folded in with its tool calls, and a
* run groups at >= 2 tool calls or a single call accompanied by reasoning.
*/
export function groupSequentialToolCalls(parts: PartWithIndex[]): GroupedPart[] {
const result: GroupedPart[] = [];
@ -97,26 +118,15 @@ export function groupSequentialToolCalls(parts: PartWithIndex[]): GroupedPart[]
let claimStart = 0;
const flushWithoutLabel = () => {
let toolRun: PartWithIndex[] = [];
const flushToolRun = () => {
if (toolRun.length >= 2) {
result.push({ type: 'tool-group', parts: [...toolRun] });
} else {
for (const p of toolRun) {
result.push({ type: 'single', part: p });
}
}
toolRun = [];
};
for (const p of currentBlock) {
if (isGroupableToolCall(p.part)) {
toolRun.push(p);
} else {
flushToolRun();
const toolCallCount = countToolCalls(currentBlock);
const hasReasoning = currentBlock.some((p) => isReasoningPart(p.part));
if (toolCallCount >= 2 || (toolCallCount >= 1 && hasReasoning)) {
result.push({ type: 'tool-group', parts: [...currentBlock] });
} else {
for (const p of currentBlock) {
result.push({ type: 'single', part: p });
}
}
flushToolRun();
currentBlock = [];
claimStart = 0;
};