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`.
This commit is contained in:
Marco Beretta 2026-09-06 19:19:04 +02:00
parent 638a55635c
commit 9441da45c3
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 84 additions and 3 deletions

View file

@ -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({
</span>
</div>
) : 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 ? (
<AttachmentGroup attachments={attachments} />
<>
<SearchVerticals attachments={attachments} />
<AttachmentGroup attachments={attachments} />
</>
) : null;
if (!label) {
return (

View file

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

View file

@ -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<FrameRequestCallback | undefined>;
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(
<ActivityPhaseGroup labelPart={labelPart} hasContent attachments={[searchAttachment]}>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
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);
});
});

View file

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