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);