From 9441da45c3bd8b555fee8044e6c164b2a349a330 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Sun, 6 Sep 2026 19:19:04 +0200
Subject: [PATCH] fix(client): keep grouped markers and folded search media
visible
Two things a fold could swallow.
A detached-subagent projection is an empty THINK part flagged
`reasoning_unavailable`, which `Part` renders as a `ReasoningMarker`.
`ToolCallGroup` sent every THINK part to `ReasoningCompact`, which has
no text to show and returns null, so the marker disappeared the moment
its call joined a group. Those parts go back through the standalone
renderer.
A phase hoists its span's media out of the fold, and the nested segment
renders with `hideAttachments` so a `WebSearch` row inside it stands
down for that hoist. The hoist was `AttachmentGroup` alone, which drops
`web_search` attachments outright, so a folded search lost its images,
products and places instead of lifting them out. The card renders
`SearchVerticals` beside the attachment row, mirroring `ToolCallGroup`.
---
.../Messages/Content/ActivityPhaseGroup.tsx | 11 +++++-
.../Chat/Messages/Content/ToolCallGroup.tsx | 14 +++++++
.../__tests__/ActivityPhaseGroup.test.tsx | 38 ++++++++++++++++++-
.../Content/__tests__/ToolCallGroup.test.tsx | 24 ++++++++++++
4 files changed, 84 insertions(+), 3 deletions(-)
diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx
index f31f39b245..dd51f89f8f 100644
--- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx
+++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx
@@ -13,6 +13,7 @@ import {
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import { getActivityLabelText } from '~/utils/activityLabels';
import { ROW_GLYPH_SLOT, TOOL_ROW_CLASSES } from './rows';
+import SearchVerticals from './verticals';
import { AttachmentGroup } from './Parts';
import { cn } from '~/utils';
@@ -285,9 +286,17 @@ export default function ActivityPhaseGroup({
) : null;
+ /** `AttachmentGroup` drops `web_search` attachments, and the nested segment
+ * renders with `hideAttachments` so its own `WebSearch` row stands down
+ * for this hoist — so without `SearchVerticals` here a phase containing a
+ * search would swallow its images, products and places instead of lifting
+ * them out of the fold. Mirrors `ToolCallGroup`'s own hoist. */
const media =
attachments != null && attachments.length > 0 ? (
-
+ <>
+
+
+ >
) : null;
if (!label) {
return (
diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
index 7c7a107ce7..10505e94c7 100644
--- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
@@ -658,6 +658,20 @@ export default function ToolCallGroup({
if (part.type === ContentTypes.THINK) {
const think = part.think;
const reasoning = typeof think === 'string' ? think : (think?.value ?? '');
+ /** A detached-subagent projection carries an empty THINK
+ * part flagged `reasoning_unavailable`, which `Part`
+ * renders as a `ReasoningMarker`. `ReasoningCompact` has
+ * no text to show and returns null, so the marker has to
+ * keep going through the standalone path or it vanishes
+ * the moment its call joins a group. */
+ if (reasoning.trim() === '' && part.reasoning_unavailable === true) {
+ return renderPart(
+ part,
+ idx,
+ isLast && idx === lastContentIdx,
+ handleToolExpand,
+ );
+ }
const streaming = isSubmitting && idx === lastContentIdx;
const isAfterTool =
partIndex > 0 && parts[partIndex - 1]?.part.type === ContentTypes.TOOL_CALL;
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx
index f11898f86b..172bcd94aa 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx
@@ -1,6 +1,6 @@
-import { ContentTypes } from 'librechat-data-provider';
+import { ContentTypes, Tools } from 'librechat-data-provider';
import { act, fireEvent, render, screen } from '@testing-library/react';
-import type { TMessageContentParts } from 'librechat-data-provider';
+import type { TAttachment, TMessageContentParts } from 'librechat-data-provider';
import { ROW_GLYPH_SLOT, TOOL_ROW_CLASSES } from '../rows';
import ActivityPhaseGroup from '../ActivityPhaseGroup';
@@ -16,6 +16,7 @@ jest.mock('~/hooks', () => {
const expandCollapse = jest.requireActual('~/hooks/Messages/useExpandCollapse');
const lazyCollapseBody = jest.requireActual('~/hooks/Messages/useLazyCollapseBody');
return {
+ useLocalize: () => (key: string) => key,
useExpandCollapse: expandCollapse.default,
useLazyCollapseBody: lazyCollapseBody.default,
EXPAND_TRANSITION: expandCollapse.EXPAND_TRANSITION,
@@ -40,6 +41,22 @@ const makeLabelPart = (
const labelPart = makeLabelPart(LABEL);
const nextLabelPart = makeLabelPart(NEXT_LABEL);
+/** A completed web search inside the phase: `AttachmentGroup` discards the
+ * `web_search` type, so only `SearchVerticals` can surface these. */
+const searchAttachment = {
+ type: Tools.web_search,
+ [Tools.web_search]: {
+ images: [
+ {
+ title: 'A pictured result',
+ imageUrl: 'https://example.com/pic.png',
+ thumbnailUrl: 'https://example.com/thumb.png',
+ link: 'https://example.com/page',
+ },
+ ],
+ },
+} as unknown as TAttachment;
+
describe('ActivityPhaseGroup', () => {
let frames: Array;
const originalRequestAnimationFrame = window.requestAnimationFrame;
@@ -366,4 +383,21 @@ describe('ActivityPhaseGroup', () => {
fireEvent.transitionEnd(screen.getByTestId('activity-phase-panel'));
expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
});
+
+ test('hoists a folded search call verticals out of the card', () => {
+ /** The nested segment renders with `hideAttachments`, standing its own
+ * `WebSearch` verticals down for this hoist, and `AttachmentGroup` drops
+ * `web_search` attachments outright — so the card is the only surface
+ * left for a folded search's images, products and places. */
+ render(
+
+
+ ,
+ );
+
+ const image = screen.getByRole('link', { name: 'A pictured result' });
+ expect(image).toHaveAttribute('href', 'https://example.com/page');
+ /** Outside the fold: collapsing the card must not take the media with it. */
+ expect(screen.getByTestId('activity-phase-panel')).not.toContainElement(image);
+ });
});
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
index 4373ca252d..04a5ae9180 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
@@ -506,6 +506,30 @@ describe('ToolCallGroup image hoisting', () => {
expect(screen.getByTestId('compact-reasoning')).toHaveAttribute('data-after-tool', 'true');
});
+ it('delegates an unavailable-reasoning part to the standalone renderer', () => {
+ /** A detached-subagent projection has no text to compact, so `Part`'s
+ * `ReasoningMarker` is the only thing that stands for it. Rendering it
+ * as a compact row drops the marker the moment the call joins a group. */
+ const unavailablePart = {
+ type: ContentTypes.THINK,
+ [ContentTypes.THINK]: '',
+ reasoning_unavailable: true,
+ } as TMessageContentParts;
+
+ renderGroup({
+ ...baseProps,
+ parts: [
+ { part: makePart('t1'), idx: 0 },
+ { part: unavailablePart, idx: 1 },
+ ],
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /^Fetch_image/ }));
+
+ expect(screen.getByTestId('inner-1')).toBeInTheDocument();
+ expect(screen.queryByTestId('compact-reasoning')).not.toBeInTheDocument();
+ });
+
it('unmounts tool bodies after a collapsed group finishes transitioning', () => {
renderGroup(baseProps);