mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 07:46:47 +00:00
🖼️ refactor: Tool Image Outputs outside of Tool Group Auto-Collapses (#12949)
* refactor(attachments): add variant prop to AttachmentGroup
* feat(tool-call): add hideImageAttachments prop to ToolCall
* fix(tool-call): keep MCP image outputs visible when tool group auto-collapses
* test(tool-call): verify MCP images hoist out of collapsed tool group
* fix(tool-call): hoist all grouped attachments and prevent ExecuteCode double-render
- rename hideImageAttachments -> hideAttachments and hide every attachment
in the inner tool when a group auto-collapses, then hoist them via
ToolCallGroup with default variant 'all' so non-image attachments survive
the collapse alongside images
- thread hideAttachments to ExecuteCode so it skips its inline AttachmentGroup
when grouped, preventing double-render when the group is expanded
- memoize sequentialParts and groupedParts in ContentParts (with
groupAttachments rolled into each tool-group entry) so we don't re-flatMap
on every render
* test(tool-call): cover hideAttachments contract and grouping integration
- ToolCall: assert AttachmentGroup is skipped when hideAttachments=true and
rendered when explicitly false, locking the prop's contract
- ToolCallGroup: update variant assertion to 'all' (now hoists images and
files together) and add a non-image-only hoist case
- ContentParts.integration: new test exercising the full
ContentParts -> Part -> ToolCall -> AttachmentGroup chain with realistic
MCP-shaped data (groups 2+ contiguous tool calls and hoists, single calls
render inline, mixed image+file hoists, empty attachments are a no-op)
* fix(tool-call): extend hideAttachments to bash/read_file/skill/subagent
When the post-rebase dev branch added BashCall, ReadFileCall, SkillCall,
and SubagentCall as dedicated tool renderers, each rendered its own
inline AttachmentGroup. Once the parent tool group hoists every
attachment, those inline groups would double-render, so they now honor
the same hideAttachments contract as ToolCall and ExecuteCode.
Also seed the new ToolCallGroup mocks (Users icon, getToolDisplayLabel)
so the existing hoist test suite keeps passing on dev.
* fix(image-gen): suppress inline image when attachments are hoisted
OpenAIImageGen renders the generated image directly via <Image>. When
its tool_call lands inside a grouped tool call, the parent now hoists
those attachments into ToolCallGroup's AttachmentGroup, and the inline
<Image> would render the same file a second time. Thread hideAttachments
through Part -> ImageGen (agent-style branch) so the agent-style image
slot stays out of the way once the parent has hoisted.
* refactor(tool-call): drop dead variant prop and flatten render-part hooks
- AttachmentGroup's variant prop ('images' / 'non-images') had no callers
after the final hoisting design landed, so remove the prop and the
filtering branches; everything passes the default 'all' behavior.
- Replace the makeRenderPart factory + dual useMemo with two plain
useCallbacks (renderPart, renderGroupedPart) sharing the same dep set.
- Tighten test mocks: drop 'any' in the new integration test, hoist the
MCP delimiter constant above its consumer, and remove the now-stale
data-variant attribute assertion.
* refactor(tool-call): extract getToolCallId helper and tidy imports
- Pull the (part?.[TOOL_CALL] as Agents.ToolCall)?.id chain into a single
getToolCallId helper in ContentParts so the three call sites stop
repeating the cast verbatim.
- Re-sort ToolCallGroup local imports longest-to-shortest per the project
convention.
- Add a Users mock to the integration test's lucide-react stub so future
subagent-group tests don't trip over an undefined glyph.
* refactor(tool-call): unnest ternaries in subagent and group labels
This commit is contained in:
parent
a43bc45b73
commit
26a6312917
13 changed files with 609 additions and 97 deletions
|
|
@ -16,6 +16,9 @@ import ToolCallGroup from './ToolCallGroup';
|
|||
import Container from './Container';
|
||||
import Part from './Part';
|
||||
|
||||
const getToolCallId = (part: TMessageContentParts): string =>
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
|
||||
type PartWithContextProps = {
|
||||
part: TMessageContentParts;
|
||||
idx: number;
|
||||
|
|
@ -28,6 +31,7 @@ type PartWithContextProps = {
|
|||
isCreatedByUser: boolean;
|
||||
isLast: boolean;
|
||||
partAttachments: TAttachment[] | undefined;
|
||||
hideAttachments?: boolean;
|
||||
};
|
||||
|
||||
const PartWithContext = memo(function PartWithContext({
|
||||
|
|
@ -42,6 +46,7 @@ const PartWithContext = memo(function PartWithContext({
|
|||
isCreatedByUser,
|
||||
isLast,
|
||||
partAttachments,
|
||||
hideAttachments,
|
||||
}: PartWithContextProps) {
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
|
|
@ -66,6 +71,7 @@ const PartWithContext = memo(function PartWithContext({
|
|||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={isLastPart}
|
||||
showCursor={isLastPart && isLast}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
|
|
@ -183,7 +189,6 @@ const ContentParts = memo(function ContentParts({
|
|||
|
||||
const renderPart = useCallback(
|
||||
(part: TMessageContentParts, idx: number, isLastPart: boolean) => {
|
||||
const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
return (
|
||||
<PartWithContext
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
|
|
@ -197,7 +202,7 @@ const ContentParts = memo(function ContentParts({
|
|||
isCreatedByUser={isCreatedByUser}
|
||||
nextType={content?.[idx + 1]?.type}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
partAttachments={attachmentMap[toolCallId]}
|
||||
partAttachments={attachmentMap[getToolCallId(part)]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
@ -213,6 +218,65 @@ const ContentParts = memo(function ContentParts({
|
|||
],
|
||||
);
|
||||
|
||||
const renderGroupedPart = useCallback(
|
||||
(part: TMessageContentParts, idx: number, isLastPart: boolean) => {
|
||||
return (
|
||||
<PartWithContext
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
idx={idx}
|
||||
part={part}
|
||||
isLast={isLast}
|
||||
messageId={messageId}
|
||||
isLastPart={isLastPart}
|
||||
conversationId={conversationId}
|
||||
isLatestMessage={isLatestMessage}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
nextType={content?.[idx + 1]?.type}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
partAttachments={attachmentMap[getToolCallId(part)]}
|
||||
hideAttachments
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
attachmentMap,
|
||||
content,
|
||||
conversationId,
|
||||
effectiveIsSubmitting,
|
||||
isCreatedByUser,
|
||||
isLast,
|
||||
isLatestMessage,
|
||||
messageId,
|
||||
],
|
||||
);
|
||||
|
||||
const sequentialParts = useMemo<PartWithIndex[]>(() => {
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
const result: PartWithIndex[] = [];
|
||||
content.forEach((part, idx) => {
|
||||
if (part) {
|
||||
result.push({ part, idx });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [content]);
|
||||
|
||||
const groupedParts = useMemo(
|
||||
() =>
|
||||
groupSequentialToolCalls(sequentialParts).map((group) => {
|
||||
if (group.type === 'single') {
|
||||
return group;
|
||||
}
|
||||
const groupAttachments = group.parts.flatMap(
|
||||
({ part }) => attachmentMap[getToolCallId(part)] ?? [],
|
||||
);
|
||||
return { ...group, groupAttachments };
|
||||
}),
|
||||
[sequentialParts, attachmentMap],
|
||||
);
|
||||
|
||||
// Early return: no content to render AND no pending skill cards
|
||||
if (!content && !hasPendingSkills) {
|
||||
return null;
|
||||
|
|
@ -283,14 +347,6 @@ const ContentParts = memo(function ContentParts({
|
|||
}
|
||||
|
||||
// Sequential content: render parts in order (90% of cases)
|
||||
const sequentialParts: PartWithIndex[] = [];
|
||||
safeContent.forEach((part, idx) => {
|
||||
if (part) {
|
||||
sequentialParts.push({ part, idx });
|
||||
}
|
||||
});
|
||||
const groupedParts = groupSequentialToolCalls(sequentialParts);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
|
|
@ -311,8 +367,9 @@ const ContentParts = memo(function ContentParts({
|
|||
parts={group.parts}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
isLast={group.parts.some((p) => p.idx === lastContentIdx)}
|
||||
renderPart={renderPart}
|
||||
renderPart={renderGroupedPart}
|
||||
lastContentIdx={lastContentIdx}
|
||||
groupAttachments={group.groupAttachments}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ type PartProps = {
|
|||
showCursor: boolean;
|
||||
isCreatedByUser: boolean;
|
||||
attachments?: TAttachment[];
|
||||
hideAttachments?: boolean;
|
||||
};
|
||||
|
||||
const Part = memo(function Part({
|
||||
|
|
@ -47,6 +48,7 @@ const Part = memo(function Part({
|
|||
isLast,
|
||||
showCursor,
|
||||
isCreatedByUser,
|
||||
hideAttachments,
|
||||
}: PartProps) {
|
||||
if (!part) {
|
||||
return null;
|
||||
|
|
@ -142,6 +144,7 @@ const Part = memo(function Part({
|
|||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
args={toolCall.args}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
|
|
@ -158,6 +161,7 @@ const Part = memo(function Part({
|
|||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
attachments={attachments}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall && toolCall.name === 'skill') {
|
||||
|
|
@ -168,6 +172,7 @@ const Part = memo(function Part({
|
|||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall && toolCall.name === Constants.SUBAGENT) {
|
||||
|
|
@ -191,6 +196,7 @@ const Part = memo(function Part({
|
|||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
persistedContent={persistedContent}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall && toolCall.name === 'read_file') {
|
||||
|
|
@ -201,6 +207,7 @@ const Part = memo(function Part({
|
|||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall && toolCall.name === 'bash_tool') {
|
||||
|
|
@ -211,6 +218,7 @@ const Part = memo(function Part({
|
|||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall && toolCall.name === Tools.web_search) {
|
||||
|
|
@ -245,6 +253,7 @@ const Part = memo(function Part({
|
|||
attachments={attachments}
|
||||
auth={toolCall.auth}
|
||||
isLast={isLast}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
|
||||
|
|
@ -302,6 +311,7 @@ const Part = memo(function Part({
|
|||
name={toolCall.function.name}
|
||||
output={toolCall.function.output}
|
||||
isLast={isLast}
|
||||
hideAttachments={hideAttachments}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@ export default function BashCall({
|
|||
args,
|
||||
output = '',
|
||||
attachments,
|
||||
hideAttachments = false,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting: boolean;
|
||||
args?: string | Record<string, unknown>;
|
||||
output?: string;
|
||||
attachments?: TAttachment[];
|
||||
hideAttachments?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const command = useMemo(() => parseJsonField(args, 'command'), [args]);
|
||||
|
|
@ -110,7 +112,9 @@ export default function BashCall({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
{!hideAttachments && attachments && attachments.length > 0 && (
|
||||
<AttachmentGroup attachments={attachments} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,12 +56,14 @@ export default function ExecuteCode({
|
|||
args,
|
||||
output = '',
|
||||
attachments,
|
||||
hideAttachments = false,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting: boolean;
|
||||
args?: string | Record<string, unknown>;
|
||||
output?: string;
|
||||
attachments?: TAttachment[];
|
||||
hideAttachments?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { lang = 'py', code } = useParseArgs(args) ?? ({} as ParsedArgs);
|
||||
|
|
@ -129,7 +131,9 @@ export default function ExecuteCode({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
{!hideAttachments && attachments && attachments.length > 0 && (
|
||||
<AttachmentGroup attachments={attachments} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export default function OpenAIImageGen({
|
|||
args: _args = '',
|
||||
output,
|
||||
attachments,
|
||||
hideAttachments = false,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting?: boolean;
|
||||
|
|
@ -38,6 +39,7 @@ export default function OpenAIImageGen({
|
|||
args: string | Record<string, unknown>;
|
||||
output?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
hideAttachments?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const isAgentStyle = toolName != null && AGENT_STYLE_TOOLS.has(toolName);
|
||||
|
|
@ -226,7 +228,7 @@ export default function OpenAIImageGen({
|
|||
<ToolIcon type="image_gen" isAnimating={isInProgress} />
|
||||
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
|
||||
</div>
|
||||
{isAgentStyle && (
|
||||
{isAgentStyle && !hideAttachments && (
|
||||
<div className="relative mb-2 flex w-full justify-start">
|
||||
<div ref={containerRef} className="w-full max-w-lg">
|
||||
{dimensions.width !== 'auto' && progress < 1 && (
|
||||
|
|
|
|||
|
|
@ -67,12 +67,14 @@ export default function ReadFileCall({
|
|||
args,
|
||||
output = '',
|
||||
attachments,
|
||||
hideAttachments = false,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting: boolean;
|
||||
args?: string | Record<string, unknown>;
|
||||
output?: string;
|
||||
attachments?: TAttachment[];
|
||||
hideAttachments?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]);
|
||||
|
|
@ -123,7 +125,9 @@ export default function ReadFileCall({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
{!hideAttachments && attachments && attachments.length > 0 && (
|
||||
<AttachmentGroup attachments={attachments} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ export default function SkillCall({
|
|||
args,
|
||||
output = '',
|
||||
attachments,
|
||||
hideAttachments = false,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting: boolean;
|
||||
args?: string | Record<string, unknown>;
|
||||
output?: string;
|
||||
attachments?: TAttachment[];
|
||||
hideAttachments?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const skillName = useMemo(() => parseJsonField(args, 'skillName'), [args]);
|
||||
|
|
@ -71,7 +73,9 @@ export default function SkillCall({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
{!hideAttachments && attachments && attachments.length > 0 && (
|
||||
<AttachmentGroup attachments={attachments} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface SubagentCallProps {
|
|||
* runs recorded before the persistence path landed will not have this
|
||||
* field; those fall back to the atom (or the raw `output` string). */
|
||||
persistedContent?: TMessageContentParts[];
|
||||
hideAttachments?: boolean;
|
||||
}
|
||||
|
||||
const TICKER_MAX_LINES = 3;
|
||||
|
|
@ -161,6 +162,7 @@ export default function SubagentCall({
|
|||
output,
|
||||
attachments,
|
||||
persistedContent,
|
||||
hideAttachments = false,
|
||||
}: SubagentCallProps) {
|
||||
const localize = useLocalize();
|
||||
const progress = useRecoilValue(subagentProgressByToolCallId(toolCallId));
|
||||
|
|
@ -250,14 +252,13 @@ export default function SubagentCall({
|
|||
/** Base verb-only label ("Running agent" / "Ran agent"). The agent name
|
||||
* is rendered separately as a muted sub-label so "agent" stays a
|
||||
* constant visual anchor regardless of name length. */
|
||||
let headerText = localize('com_ui_subagent_complete');
|
||||
if (hasError) {
|
||||
headerText = localize('com_ui_subagent_errored');
|
||||
} else if (cancelled) {
|
||||
headerText = localize('com_ui_subagent_cancelled');
|
||||
} else if (running) {
|
||||
headerText = localize('com_ui_subagent_running');
|
||||
}
|
||||
const getHeaderText = () => {
|
||||
if (hasError) return localize('com_ui_subagent_errored');
|
||||
if (cancelled) return localize('com_ui_subagent_cancelled');
|
||||
if (running) return localize('com_ui_subagent_running');
|
||||
return localize('com_ui_subagent_complete');
|
||||
};
|
||||
const headerText = getHeaderText();
|
||||
/** Muted sub-label shown to the right of the base label: the
|
||||
* configured agent name for named subagents. Self-spawns omit it
|
||||
* (redundant — the header already says "agent") as do cases where
|
||||
|
|
@ -383,60 +384,66 @@ export default function SubagentCall({
|
|||
setIsAtBottom(true);
|
||||
}, []);
|
||||
|
||||
const emptyDialogText = running
|
||||
? localize('com_ui_subagent_no_result_yet')
|
||||
: localize('com_ui_subagent_empty_result');
|
||||
|
||||
let dialogContent: JSX.Element;
|
||||
if (contentParts.length > 0) {
|
||||
dialogContent = (
|
||||
<MessageContext.Provider value={dialogMessageContext}>
|
||||
{groupedParts.map((group) => {
|
||||
if (group.type === 'single') {
|
||||
const { part, idx } = group.part;
|
||||
/** Per-type dispatch handles wrapping: TEXT goes through
|
||||
* `Container`, THINK/TOOL_CALL render directly so their own
|
||||
* wrappers set the width and spacing. */
|
||||
return renderDialogPart(part, idx, idx === lastPartIndex);
|
||||
}
|
||||
/** Consecutive tool_calls (2+) collapse into a `Used N tools`
|
||||
* group — same behavior as the main message view. */
|
||||
return (
|
||||
<ToolCallGroup
|
||||
key={`${toolCallId}-group-${group.parts[0].idx}`}
|
||||
parts={group.parts}
|
||||
isSubmitting={running}
|
||||
isLast={group.parts.some((p) => p.idx === lastPartIndex)}
|
||||
renderPart={renderDialogPart}
|
||||
lastContentIdx={lastPartIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MessageContext.Provider>
|
||||
const renderDialogBody = () => {
|
||||
if (contentParts.length > 0) {
|
||||
return (
|
||||
<MessageContext.Provider value={dialogMessageContext}>
|
||||
{groupedParts.map((group) => {
|
||||
if (group.type === 'single') {
|
||||
const { part, idx } = group.part;
|
||||
/** Per-type dispatch handles wrapping: TEXT goes
|
||||
* through `Container`, THINK/TOOL_CALL render
|
||||
* directly so their own wrappers set the width
|
||||
* and spacing. */
|
||||
return renderDialogPart(part, idx, idx === lastPartIndex);
|
||||
}
|
||||
/** Consecutive tool_calls (2+) collapse into a
|
||||
* `Used N tools` group — same behavior as the main
|
||||
* message view. */
|
||||
return (
|
||||
<ToolCallGroup
|
||||
key={`${toolCallId}-group-${group.parts[0].idx}`}
|
||||
parts={group.parts}
|
||||
isSubmitting={running}
|
||||
isLast={group.parts.some((p) => p.idx === lastPartIndex)}
|
||||
renderPart={renderDialogPart}
|
||||
lastContentIdx={lastPartIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
}
|
||||
if (output) {
|
||||
/** Fallback: no aggregated content parts but the backend
|
||||
* wrote a final tool_call output. Happens for older
|
||||
* subagent runs recorded before the event forwarder
|
||||
* existed. Route through the same leaf renderer so
|
||||
* markdown renders properly. */
|
||||
return (
|
||||
<MessageContext.Provider value={dialogMessageContext}>
|
||||
<SubagentDialogPart
|
||||
part={
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
text: output,
|
||||
} as unknown as TMessageContentParts
|
||||
}
|
||||
isSubmitting={false}
|
||||
showCursor={false}
|
||||
isLast
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-sm italic text-text-secondary">
|
||||
{running
|
||||
? localize('com_ui_subagent_no_result_yet')
|
||||
: localize('com_ui_subagent_empty_result')}
|
||||
</div>
|
||||
);
|
||||
} else if (output) {
|
||||
/** Fallback: no aggregated content parts but the backend wrote a final
|
||||
* tool_call output. Happens for older subagent runs recorded before the
|
||||
* event forwarder existed. Route through the same leaf renderer so
|
||||
* markdown renders properly. */
|
||||
dialogContent = (
|
||||
<MessageContext.Provider value={dialogMessageContext}>
|
||||
<SubagentDialogPart
|
||||
part={
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
text: output,
|
||||
} as unknown as TMessageContentParts
|
||||
}
|
||||
isSubmitting={false}
|
||||
showCursor={false}
|
||||
isLast
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
} else {
|
||||
dialogContent = <div className="text-sm italic text-text-secondary">{emptyDialogText}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -554,14 +561,16 @@ export default function SubagentCall({
|
|||
onToggle={() => setPromptExpanded((expanded) => !expanded)}
|
||||
/>
|
||||
) : null}
|
||||
{dialogContent}
|
||||
{renderDialogBody()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
{!hideAttachments && attachments && attachments.length > 0 && (
|
||||
<AttachmentGroup attachments={attachments} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export default function ToolCall({
|
|||
output,
|
||||
attachments,
|
||||
auth,
|
||||
hideAttachments = false,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isLast?: boolean;
|
||||
|
|
@ -36,6 +37,7 @@ export default function ToolCall({
|
|||
output?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
auth?: string;
|
||||
hideAttachments?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const autoExpand = useRecoilValue(store.autoExpandTools);
|
||||
|
|
@ -254,7 +256,9 @@ export default function ToolCall({
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
{!hideAttachments && attachments && attachments.length > 0 && (
|
||||
<AttachmentGroup attachments={attachments} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@ import { useState, useMemo, useEffect, useCallback } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { ChevronDown, Users } from 'lucide-react';
|
||||
import { Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider';
|
||||
import type { TMessageContentParts, Agents, FunctionToolCall } from 'librechat-data-provider';
|
||||
import type {
|
||||
TAttachment,
|
||||
TMessageContentParts,
|
||||
Agents,
|
||||
FunctionToolCall,
|
||||
} from 'librechat-data-provider';
|
||||
import type { PartWithIndex } from './ParallelContent';
|
||||
import { StackedToolIcons } from './ToolOutput';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import { useMCPIconMap } from '~/hooks/MCP';
|
||||
import { cn, getToolDisplayLabel } from '~/utils';
|
||||
import { StackedToolIcons } from './ToolOutput';
|
||||
import { useMCPIconMap } from '~/hooks/MCP';
|
||||
import { AttachmentGroup } from './Parts';
|
||||
import store from '~/store';
|
||||
|
||||
interface ToolMeta {
|
||||
|
|
@ -59,6 +65,7 @@ interface ToolCallGroupProps {
|
|||
isLast: boolean;
|
||||
renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode;
|
||||
lastContentIdx: number;
|
||||
groupAttachments?: TAttachment[];
|
||||
}
|
||||
|
||||
export default function ToolCallGroup({
|
||||
|
|
@ -67,6 +74,7 @@ export default function ToolCallGroup({
|
|||
isLast,
|
||||
renderPart,
|
||||
lastContentIdx,
|
||||
groupAttachments,
|
||||
}: ToolCallGroupProps) {
|
||||
const localize = useLocalize();
|
||||
const mcpIconMap = useMCPIconMap();
|
||||
|
|
@ -130,6 +138,14 @@ export default function ToolCallGroup({
|
|||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const getSubagentLabel = () =>
|
||||
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 hasActiveToolCall = useMemo(
|
||||
() => isSubmitting && toolMetadata.some((m) => m && !m.hasOutput),
|
||||
[toolMetadata, isSubmitting],
|
||||
|
|
@ -148,13 +164,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={
|
||||
allSubagents
|
||||
? subagentsDone
|
||||
? localize('com_ui_ran_n_agents', { 0: String(count) })
|
||||
: localize('com_ui_running_n_agents', { 0: String(count) })
|
||||
: localize('com_ui_used_n_tools', { 0: String(count) })
|
||||
}
|
||||
aria-label={groupLabel}
|
||||
>
|
||||
{allSubagents ? (
|
||||
/** Subagent groups don't have per-tool icons — StackedToolIcons
|
||||
|
|
@ -178,13 +188,7 @@ export default function ToolCallGroup({
|
|||
isAnimating={!allCompleted && isSubmitting}
|
||||
/>
|
||||
)}
|
||||
<span className="tool-status-text font-medium">
|
||||
{allSubagents
|
||||
? subagentsDone
|
||||
? localize('com_ui_ran_n_agents', { 0: String(count) })
|
||||
: localize('com_ui_running_n_agents', { 0: String(count) })
|
||||
: localize('com_ui_used_n_tools', { 0: String(count) })}
|
||||
</span>
|
||||
<span className="tool-status-text font-medium">{groupLabel}</span>
|
||||
{/** Hide the tool-name summary for pure-subagent groups — every
|
||||
* entry deduplicates to the same "subagent" token, which adds
|
||||
* noise without info. Mixed groups keep the summary. */}
|
||||
|
|
@ -206,6 +210,9 @@ export default function ToolCallGroup({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groupAttachments && groupAttachments.length > 0 && (
|
||||
<AttachmentGroup attachments={groupAttachments} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TAttachment, TMessageContentParts } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ContentParts from '../ContentParts';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, values?: Record<string | number, string>) => {
|
||||
if (key === 'com_ui_used_n_tools') {
|
||||
return `Used ${values?.[0]} tools`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
useExpandCollapse: (isExpanded: boolean) => ({
|
||||
style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' },
|
||||
ref: { current: null },
|
||||
}),
|
||||
useProgress: (initial: number) => (initial >= 1 ? 1 : initial),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/MCP', () => ({
|
||||
useMCPIconMap: () => new Map(),
|
||||
}));
|
||||
|
||||
jest.mock('../ToolOutput', () => ({
|
||||
StackedToolIcons: () => <span data-testid="stacked-icons" />,
|
||||
getMCPServerName: () => '',
|
||||
ToolIcon: () => <span data-testid="tool-icon" />,
|
||||
getToolIconType: () => 'mcp',
|
||||
isError: () => false,
|
||||
}));
|
||||
|
||||
jest.mock('../ToolCallInfo', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="tool-call-info" />,
|
||||
}));
|
||||
|
||||
jest.mock('../ProgressText', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onClick, finishedText }: { onClick?: () => void; finishedText?: string }) => (
|
||||
<div data-testid="progress-text" onClick={onClick}>
|
||||
{finishedText}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
ChevronDown: () => <span>{'chevron'}</span>,
|
||||
TriangleAlert: () => <span>{'alert'}</span>,
|
||||
Users: () => <span>{'users'}</span>,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Button: ({ children }: { children?: React.ReactNode }) => <button>{children}</button>,
|
||||
}));
|
||||
|
||||
jest.mock('../Parts', () => ({
|
||||
AttachmentGroup: ({ attachments }: { attachments?: TAttachment[] }) => (
|
||||
<div data-testid="attachment-group" data-count={attachments?.length ?? 0} />
|
||||
),
|
||||
ExecuteCode: () => <div data-testid="execute-code" />,
|
||||
ImageGen: () => <div data-testid="image-gen" />,
|
||||
AgentUpdate: () => <div data-testid="agent-update" />,
|
||||
EmptyText: () => <div data-testid="empty-text" />,
|
||||
Reasoning: () => <div data-testid="reasoning" />,
|
||||
Summary: () => <div data-testid="summary" />,
|
||||
Text: ({ text }: { text?: string }) => <div data-testid="text">{text}</div>,
|
||||
EditTextPart: () => <div data-testid="edit-text" />,
|
||||
}));
|
||||
|
||||
jest.mock('../MemoryArtifacts', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="memory-artifacts" />,
|
||||
}));
|
||||
|
||||
jest.mock('../WebSearch', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="web-search" />,
|
||||
}));
|
||||
|
||||
jest.mock('../RetrievalCall', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="retrieval-call" />,
|
||||
}));
|
||||
|
||||
jest.mock('../AgentHandoff', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="agent-handoff" />,
|
||||
}));
|
||||
|
||||
jest.mock('../CodeAnalyze', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="code-analyze" />,
|
||||
}));
|
||||
|
||||
jest.mock('../Image', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="image" />,
|
||||
}));
|
||||
|
||||
jest.mock('../Container', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => {
|
||||
const actual = jest.requireActual('~/utils');
|
||||
return {
|
||||
...actual,
|
||||
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||
logger: { error: jest.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
const MCP_DELIMITER = '_mcp_';
|
||||
|
||||
const makeMcpToolCall = (id: string, hasOutput = true): TMessageContentParts =>
|
||||
({
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: {
|
||||
id,
|
||||
name: `getTinyImage${MCP_DELIMITER}Everything`,
|
||||
args: '{}',
|
||||
output: hasOutput ? 'image_returned' : '',
|
||||
},
|
||||
}) as unknown as TMessageContentParts;
|
||||
|
||||
const imageAttachment = (toolCallId: string, name = 'tiny.png'): TAttachment =>
|
||||
({
|
||||
filename: name,
|
||||
filepath: `/files/${name}`,
|
||||
width: 16,
|
||||
height: 16,
|
||||
messageId: 'm1',
|
||||
toolCallId,
|
||||
conversationId: 'c1',
|
||||
}) as unknown as TAttachment;
|
||||
|
||||
const renderContentParts = (props: React.ComponentProps<typeof ContentParts>) =>
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ContentParts {...props} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
describe('ContentParts integration: MCP image hoist and grouping', () => {
|
||||
const baseProps = {
|
||||
messageId: 'msg1',
|
||||
isCreatedByUser: false,
|
||||
isLast: true,
|
||||
isSubmitting: false,
|
||||
isLatestMessage: true,
|
||||
};
|
||||
|
||||
it('groups 2+ MCP tool calls and hoists their attachments outside the collapsible', () => {
|
||||
const content = [makeMcpToolCall('t1'), makeMcpToolCall('t2')];
|
||||
const attachments = [imageAttachment('t1', 'a.png'), imageAttachment('t2', 'b.png')];
|
||||
|
||||
renderContentParts({
|
||||
...baseProps,
|
||||
content,
|
||||
attachments,
|
||||
});
|
||||
|
||||
const groups = screen.getAllByTestId('attachment-group');
|
||||
// One AttachmentGroup hoisted at the group level — inner ToolCalls skip rendering theirs.
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].getAttribute('data-count')).toBe('2');
|
||||
});
|
||||
|
||||
it('does not group a single tool call — image renders inline (no hoist)', () => {
|
||||
const content = [makeMcpToolCall('t1')];
|
||||
const attachments = [imageAttachment('t1', 'a.png')];
|
||||
|
||||
renderContentParts({
|
||||
...baseProps,
|
||||
content,
|
||||
attachments,
|
||||
});
|
||||
|
||||
// Single tool call: AttachmentGroup is rendered by ToolCall, not hoisted.
|
||||
const groups = screen.queryAllByTestId('attachment-group');
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].getAttribute('data-count')).toBe('1');
|
||||
// No tool group label.
|
||||
expect(screen.queryByText(/Used .* tools/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hoists attachments from all parts in the group, even mixed image and non-image', () => {
|
||||
const fileAtt: TAttachment = {
|
||||
filename: 'doc.pdf',
|
||||
filepath: '/files/doc.pdf',
|
||||
messageId: 'm1',
|
||||
toolCallId: 't2',
|
||||
conversationId: 'c1',
|
||||
} as unknown as TAttachment;
|
||||
|
||||
const content = [makeMcpToolCall('t1'), makeMcpToolCall('t2')];
|
||||
const attachments = [imageAttachment('t1', 'a.png'), fileAtt];
|
||||
|
||||
renderContentParts({
|
||||
...baseProps,
|
||||
content,
|
||||
attachments,
|
||||
});
|
||||
|
||||
const groups = screen.getAllByTestId('attachment-group');
|
||||
expect(groups).toHaveLength(1);
|
||||
// Both image and file are in the hoisted group.
|
||||
expect(groups[0].getAttribute('data-count')).toBe('2');
|
||||
});
|
||||
|
||||
it('renders no AttachmentGroup when grouped tool calls have no attachments', () => {
|
||||
const content = [makeMcpToolCall('t1'), makeMcpToolCall('t2')];
|
||||
|
||||
renderContentParts({
|
||||
...baseProps,
|
||||
content,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -214,6 +214,42 @@ describe('ToolCall', () => {
|
|||
|
||||
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render AttachmentGroup when hideAttachments is true (grouped path)', () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg-hide',
|
||||
toolCallId: 'tool-hide',
|
||||
conversationId: 'conv-hide',
|
||||
[Tools.ui_resources]: { '0': { type: 'chart', data: [] } },
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRecoil(
|
||||
<ToolCall {...mockProps} attachments={attachments as any} hideAttachments />,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render AttachmentGroup when hideAttachments is false explicitly', () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg-show',
|
||||
toolCallId: 'tool-show',
|
||||
conversationId: 'conv-show',
|
||||
[Tools.ui_resources]: { '0': { type: 'chart', data: [] } },
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRecoil(
|
||||
<ToolCall {...mockProps} attachments={attachments as any} hideAttachments={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('attachment-group')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool call info visibility', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TAttachment, TMessageContentParts } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ToolCallGroup from '../ToolCallGroup';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, values?: Record<string | number, string>) => {
|
||||
if (key === 'com_ui_used_n_tools') {
|
||||
return `Used ${values?.[0]} tools`;
|
||||
}
|
||||
if (key === 'com_ui_via_server') {
|
||||
return `via ${values?.[0]}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
useExpandCollapse: (isExpanded: boolean) => ({
|
||||
style: {
|
||||
display: 'grid',
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
},
|
||||
ref: { current: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/MCP', () => ({
|
||||
useMCPIconMap: () => new Map(),
|
||||
}));
|
||||
|
||||
jest.mock('../ToolOutput', () => ({
|
||||
StackedToolIcons: () => <span data-testid="stacked-icons" />,
|
||||
getMCPServerName: () => '',
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
ChevronDown: () => <span>{'chevron'}</span>,
|
||||
Users: () => <span>{'users'}</span>,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||
getToolDisplayLabel: (name: string) => name,
|
||||
}));
|
||||
|
||||
jest.mock('../Parts', () => ({
|
||||
AttachmentGroup: ({ attachments }: { attachments?: TAttachment[] }) => (
|
||||
<div data-testid="attachment-group" data-count={attachments?.length ?? 0} />
|
||||
),
|
||||
}));
|
||||
|
||||
const makePart = (id: string, output = 'done'): TMessageContentParts =>
|
||||
({
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: {
|
||||
id,
|
||||
name: 'fetch_image',
|
||||
args: '{}',
|
||||
output,
|
||||
},
|
||||
}) as unknown as TMessageContentParts;
|
||||
|
||||
const imageAttachment: TAttachment = {
|
||||
filename: 'foo.png',
|
||||
filepath: '/files/foo.png',
|
||||
width: 128,
|
||||
height: 128,
|
||||
messageId: 'm1',
|
||||
toolCallId: 't1',
|
||||
conversationId: 'c1',
|
||||
} as unknown as TAttachment;
|
||||
|
||||
const fileAttachment: TAttachment = {
|
||||
filename: 'bar.pdf',
|
||||
filepath: '/files/bar.pdf',
|
||||
messageId: 'm1',
|
||||
toolCallId: 't2',
|
||||
conversationId: 'c1',
|
||||
} as unknown as TAttachment;
|
||||
|
||||
const renderGroup = (props: React.ComponentProps<typeof ToolCallGroup>) =>
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<ToolCallGroup {...props} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
describe('ToolCallGroup image hoisting', () => {
|
||||
const parts = [
|
||||
{ part: makePart('t1'), idx: 0 },
|
||||
{ part: makePart('t2'), idx: 1 },
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
parts,
|
||||
isSubmitting: false,
|
||||
isLast: false,
|
||||
lastContentIdx: 1,
|
||||
renderPart: (_p: TMessageContentParts, idx: number) => (
|
||||
<div data-testid={`inner-${idx}`} key={idx}>
|
||||
{'inner'}
|
||||
</div>
|
||||
),
|
||||
} satisfies React.ComponentProps<typeof ToolCallGroup>;
|
||||
|
||||
it('renders an AttachmentGroup outside the collapsible container with all attachments', () => {
|
||||
renderGroup({
|
||||
...baseProps,
|
||||
groupAttachments: [imageAttachment, fileAttachment],
|
||||
});
|
||||
|
||||
const group = screen.getByTestId('attachment-group');
|
||||
expect(group).toBeInTheDocument();
|
||||
expect(group.getAttribute('data-count')).toBe('2');
|
||||
});
|
||||
|
||||
it('hoists non-image attachments so they survive collapse', () => {
|
||||
renderGroup({
|
||||
...baseProps,
|
||||
groupAttachments: [fileAttachment],
|
||||
});
|
||||
|
||||
const group = screen.getByTestId('attachment-group');
|
||||
expect(group).toBeInTheDocument();
|
||||
expect(group.getAttribute('data-count')).toBe('1');
|
||||
});
|
||||
|
||||
it('does not render an AttachmentGroup when there are no group attachments', () => {
|
||||
renderGroup(baseProps);
|
||||
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the image AttachmentGroup as a sibling of the collapsible panel, not a child', () => {
|
||||
const { container } = renderGroup({
|
||||
...baseProps,
|
||||
groupAttachments: [imageAttachment],
|
||||
});
|
||||
|
||||
const outer = container.firstChild as HTMLElement;
|
||||
const attachmentGroup = screen.getByTestId('attachment-group');
|
||||
expect(attachmentGroup.parentElement).toBe(outer);
|
||||
|
||||
const collapsible = outer.querySelector('[style]');
|
||||
expect(collapsible?.contains(attachmentGroup)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue