🪟 refactor: Improve Subagent Dialog Prompt Rendering (#12982)

* fix: Improve subagent dialog prompt rendering

* fix: Preserve cancelled subagent traces

* chore: Reuse generic prompt toggle labels

* fix: Scope new-chat subagent cleanup exemption

* fix: Use valid subagent prompt min-height

* fix: Flatten subagent dialog conditionals

* fix: Place subagent prompt in dialog scroll
This commit is contained in:
Danny Avila 2026-05-06 22:15:07 -04:00 • committed by GitHub
parent ddf5879ccd
commit 65b63b889e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 320 additions and 119 deletions

View file

@ -1,23 +1,26 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useReducer, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ArrowDown, ChevronRight, Users } from 'lucide-react';
import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client';
import { ContentTypes, EModelEndpoint } from 'librechat-data-provider';
import { ArrowDown, ChevronRight, Maximize2, Minimize2, Users } from 'lucide-react';
import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client';
import type { TAttachment, TMessage, TMessageContentParts } from 'librechat-data-provider';
import ToolCall from '~/components/Chat/Messages/Content/ToolCall';
import ToolCallGroup from '~/components/Chat/Messages/Content/ToolCallGroup';
import Container from '~/components/Chat/Messages/Content/Container';
import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent';
import type { SubagentTickerLine } from '~/utils/subagentContent';
import ToolCallGroup from '~/components/Chat/Messages/Content/ToolCallGroup';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import { cn, groupSequentialToolCalls, parseToolName } from '~/utils';
import Container from '~/components/Chat/Messages/Content/Container';
import ToolCall from '~/components/Chat/Messages/Content/ToolCall';
import { MessageContext } from '~/Providers/MessageContext';
import { subagentProgressByToolCallId } from '~/store';
import MessageIcon from '~/components/Share/MessageIcon';
import { useAgentsMapContext } from '~/Providers';
import Text from './Text';
import Reasoning from './Reasoning';
import { AttachmentGroup } from './Attachment';
import { MessageContext } from '~/Providers/MessageContext';
import { useLocalize } from '~/hooks';
import { subagentProgressByToolCallId } from '~/store';
import type { SubagentTickerLine } from '~/utils/subagentContent';
import { cn, groupSequentialToolCalls, parseToolName } from '~/utils';
import Reasoning from './Reasoning';
import Text from './Text';
interface SubagentCallProps {
toolCallId: string;
@ -163,6 +166,7 @@ export default function SubagentCall({
const progress = useRecoilValue(subagentProgressByToolCallId(toolCallId));
const agentsMap = useAgentsMapContext();
const [open, setOpen] = useState(false);
const [promptExpanded, setPromptExpanded] = useState(false);
const subagentType = progress?.subagentType ?? extractSubagentType(args);
const isSelfSpawn = subagentType === 'self';
@ -241,18 +245,19 @@ export default function SubagentCall({
shouldThrottleTicker,
);
const description = typeof args === 'string' ? tryDescription(args) : extractDescription(args);
const prompt = typeof args === 'string' ? tryPrompt(args) : extractPrompt(args);
/** 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. */
const headerText = hasError
? localize('com_ui_subagent_errored')
: cancelled
? localize('com_ui_subagent_cancelled')
: running
? localize('com_ui_subagent_running')
: localize('com_ui_subagent_complete');
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');
}
/** 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
@ -333,14 +338,21 @@ export default function SubagentCall({
setIsAtBottom(distance <= DIALOG_AT_BOTTOM_THRESHOLD_PX);
}, []);
/** Snap to bottom every time the dialog opens so a freshly-opened
* dialog starts parked on the live cursor. */
/** Reset to the compact prompt preview every time the dialog closes. */
useEffect(() => {
if (open) return;
setPromptExpanded(false);
}, [open]);
/** Start at the top every time the dialog opens so the prompt reads as the
* first item in the scrollable trace instead of a fixed header. */
useEffect(() => {
if (!open) return;
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
setIsAtBottom(true);
el.scrollTop = 0;
const distance = el.scrollHeight - el.clientHeight;
setIsAtBottom(distance <= DIALOG_AT_BOTTOM_THRESHOLD_PX);
}, [open]);
/** Keep the view pinned to the bottom while the user is at/near it —
@ -371,6 +383,61 @@ 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>
);
} 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 (
<>
<button
@ -435,11 +502,11 @@ export default function SubagentCall({
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogContent
className={cn(
'max-h-[85vh] overflow-hidden',
'flex h-[min(85vh,56rem)] flex-col overflow-hidden p-0',
/** Tighter inter-row gap than the dialog default (`gap-4`)
* — title + description + scroll area read as one block
* rather than three separated panels. */
'gap-2',
'gap-0',
/** Responsive width: narrow on phones, scales up to ~80rem on
* widescreens. Viewport-relative max keeps margin on the
* edges while still using real estate on laptops / large
@ -447,22 +514,24 @@ export default function SubagentCall({
'w-[min(96vw,80rem)] max-w-[min(96vw,80rem)]',
)}
>
<OGDialogTitle>
{isSelfSpawn
? localize('com_ui_subagent_dialog_title_self')
: localize('com_ui_subagent_dialog_title', { 0: subagentType })}
</OGDialogTitle>
<OGDialogDescription className="text-sm text-text-secondary">
{description || localize('com_ui_subagent_dialog_description')}
</OGDialogDescription>
<div className="shrink-0 px-6 pb-3 pr-14 pt-6">
<OGDialogTitle>
{isSelfSpawn
? localize('com_ui_subagent_dialog_title_self')
: localize('com_ui_subagent_dialog_title', { 0: subagentType })}
</OGDialogTitle>
<OGDialogDescription className="sr-only">
{localize('com_ui_subagent_dialog_description')}
</OGDialogDescription>
</div>
<div className="relative mt-3">
<div className="relative min-h-0 flex-1 border-t border-border-light bg-surface-primary">
{!isAtBottom && (
<button
type="button"
onClick={scrollDialogToBottom}
aria-label={localize('com_ui_subagent_scroll_to_bottom')}
className="absolute bottom-3 right-6 z-10 flex h-8 w-8 items-center justify-center rounded-full border border-border-light bg-surface-secondary text-text-secondary shadow-md transition hover:bg-surface-tertiary hover:text-text-primary"
className="absolute bottom-3 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full border border-border-light bg-surface-secondary text-text-secondary shadow-md transition hover:bg-surface-tertiary hover:text-text-primary"
>
<ArrowDown size={16} aria-hidden="true" />
</button>
@ -470,70 +539,22 @@ export default function SubagentCall({
<div
ref={scrollRef}
onScroll={handleScroll}
/** Mirrors the main conversation's content-parts wrapper
* (`max-w-full flex-grow flex-col gap-0` from
* `MessageParts.tsx`). Part-specific wrappers (`Container`
* on TEXT, the Reasoning component's own wrapper on THINK,
* `ToolCallGroup` margins on grouped tools) handle their
* own spacing — we don't re-wrap everything in `Container`
* because that would constrain Reasoning's full-column
* width the way it has in regular messages.
* Outer `px-4 py-3` gives the dialog breathing room. */
className="max-h-[65vh] overflow-y-auto px-4 py-3"
/** The prompt and activity trace share one scroller so expanded
* prompt content participates in the same reading flow as the
* subagent output instead of reserving fixed dialog space.
* Part-specific wrappers (`Container`, `Reasoning`,
* `ToolCallGroup`) handle their own widths and spacing. */
className="h-full overflow-y-auto px-3 py-3"
>
<div ref={contentRef} className="flex max-w-full flex-grow flex-col gap-0">
{contentParts.length > 0 ? (
<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>
) : 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. */
<MessageContext.Provider value={dialogMessageContext}>
<SubagentDialogPart
part={
{
type: ContentTypes.TEXT,
text: output,
} as unknown as TMessageContentParts
}
isSubmitting={false}
showCursor={false}
isLast
/>
</MessageContext.Provider>
) : (
<div className="text-sm italic text-text-secondary">
{running
? localize('com_ui_subagent_no_result_yet')
: localize('com_ui_subagent_empty_result')}
</div>
)}
{prompt ? (
<SubagentPrompt
prompt={prompt}
expanded={promptExpanded}
onToggle={() => setPromptExpanded((expanded) => !expanded)}
/>
) : null}
{dialogContent}
</div>
</div>
</div>
@ -558,20 +579,84 @@ function extractSubagentType(args: SubagentCallProps['args']): string {
return a?.subagent_type ?? 'agent';
}
function extractDescription(args: Record<string, unknown> | undefined): string | undefined {
const d = args?.description;
return typeof d === 'string' && d.length > 0 ? d : undefined;
function extractPrompt(args: Record<string, unknown> | undefined): string | undefined {
if (!args) return undefined;
for (const key of ['prompt', 'description', 'task', 'instructions']) {
const value = args[key];
if (typeof value === 'string' && value.trim().length > 0) return value;
}
return undefined;
}
function tryDescription(args: string): string | undefined {
function tryPrompt(args: string): string | undefined {
try {
const parsed = JSON.parse(args) as { description?: string };
return typeof parsed?.description === 'string' ? parsed.description : undefined;
return extractPrompt(JSON.parse(args) as Record<string, unknown>);
} catch {
return undefined;
}
}
function SubagentPrompt({
prompt,
expanded,
onToggle,
}: {
prompt: string;
expanded: boolean;
onToggle: () => void;
}): JSX.Element {
const localize = useLocalize();
const headingId = useId();
const contentId = useId();
const toggleLabel = expanded ? localize('com_ui_collapse') : localize('com_ui_expand');
return (
<section
aria-labelledby={headingId}
className="mb-3 shrink-0 overflow-hidden rounded-lg border border-border-light bg-surface-secondary text-text-primary"
>
<div className="flex min-h-[2.75rem] items-center justify-between gap-3 border-b border-border-light px-3 py-2">
<h3 id={headingId} className="text-sm font-medium text-text-primary">
{localize('com_ui_prompt')}
</h3>
<button
type="button"
onClick={onToggle}
aria-controls={contentId}
aria-expanded={expanded}
aria-label={toggleLabel}
title={toggleLabel}
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-text-secondary transition hover:bg-surface-tertiary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-ring"
>
{expanded ? (
<Minimize2 size={14} aria-hidden="true" />
) : (
<Maximize2 size={14} aria-hidden="true" />
)}
<span className="hidden sm:inline">{toggleLabel}</span>
</button>
</div>
<div
id={contentId}
className={cn(
'relative min-w-0 px-4 py-3',
expanded ? 'overflow-visible' : 'max-h-32 overflow-hidden',
)}
>
<div className="markdown prose prose-sm message-content light dark:prose-invert w-full max-w-none break-words text-text-primary dark:text-gray-100">
<MarkdownLite content={prompt} codeExecution={false} />
</div>
{!expanded && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-surface-secondary to-transparent"
aria-hidden="true"
/>
)}
</div>
</section>
);
}
/** Stable key for a ticker line — helps React reuse the DOM node across
* in-place updates to the same live `writing` / `reasoning` line, and
* gives tool-call lines a stable identity by tool name. */

View file

@ -1,18 +1,22 @@
import React from 'react';
import { RecoilRoot, useRecoilCallback } from 'recoil';
import { render, screen, act, waitFor } from '@testing-library/react';
import SubagentCall from '../SubagentCall';
import { subagentProgressByToolCallId, type SubagentProgress } from '~/store/subagents';
import { render, screen, act, fireEvent, waitFor } from '@testing-library/react';
import type { SubagentUpdateEvent } from 'librechat-data-provider';
import type {
SubagentContentPart,
SubagentTickerState,
SubagentAggregatorState,
} from '~/utils/subagentContent';
import type { SubagentProgress } from '~/store/subagents';
import {
foldSubagentEvent,
foldSubagentEventIntoTicker,
initSubagentAggregatorState,
initSubagentTickerState,
type SubagentContentPart,
type SubagentAggregatorState,
type SubagentTickerState,
} from '~/utils/subagentContent';
import type { SubagentUpdateEvent } from 'librechat-data-provider';
import { subagentProgressByToolCallId } from '~/store/subagents';
import SubagentCall from '../SubagentCall';
jest.mock('~/hooks', () => ({
useLocalize:
@ -31,12 +35,15 @@ jest.mock('~/hooks', () => ({
com_ui_subagent_dialog_description: 'Isolated child run.',
com_ui_subagent_no_result_yet: 'No result yet.',
com_ui_subagent_empty_result: 'No text.',
com_ui_collapse: 'Collapse',
com_ui_expand: 'Expand',
com_ui_subagent_ticker_writing: 'Writing',
com_ui_subagent_ticker_reasoning: 'Reasoning',
com_ui_subagent_ticker_error: 'Error',
com_ui_subagent_ticker_using: 'Using',
com_ui_subagent_ticker_tool_done: 'done',
com_ui_subagent_ticker_tool_output: `${arg0} → ${arg1}`,
com_ui_prompt: 'Prompt',
};
return translations[key] ?? key;
},
@ -65,6 +72,11 @@ jest.mock('~/components/Chat/Messages/Content/ToolCall', () => ({
),
}));
jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div data-testid="prompt-markdown">{content}</div>,
}));
jest.mock('../Attachment', () => ({
AttachmentGroup: ({ attachments }: { attachments: unknown }) => (
<div data-testid="attachment-group">{JSON.stringify(attachments)}</div>
@ -85,9 +97,15 @@ jest.mock('@librechat/client', () => ({
}));
jest.mock('lucide-react', () => ({
// eslint-disable-next-line i18next/no-literal-string
ArrowDown: () => <span>arrow-down</span>,
// eslint-disable-next-line i18next/no-literal-string
ChevronRight: () => <span>chevron</span>,
// eslint-disable-next-line i18next/no-literal-string
Maximize2: () => <span>maximize</span>,
// eslint-disable-next-line i18next/no-literal-string
Minimize2: () => <span>minimize</span>,
// eslint-disable-next-line i18next/no-literal-string
Users: () => <span>users</span>,
}));
@ -428,6 +446,37 @@ describe('SubagentCall — ticker', () => {
});
describe('SubagentCall — dialog content', () => {
it('renders the prompt through MarkdownLite and expands it inline with the activity area', () => {
render(
<RecoilRoot>
<SubagentCall
toolCallId="call_prompt"
initialProgress={1}
isSubmitting={false}
output="final answer"
args={{
subagent_type: 'self',
description: '# Review prompt\n\n**Focus:** edge cases',
}}
/>
</RecoilRoot>,
);
expect(screen.getByTestId('prompt-markdown')).toHaveTextContent('# Review prompt');
expect(screen.getByText('final answer')).toBeInTheDocument();
const expandButton = screen.getByRole('button', { name: 'Expand' });
expect(expandButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(expandButton);
expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByText('final answer')).toBeInTheDocument();
});
it('renders aggregated text, reasoning, and tool_call parts through leaf renderers', () => {
renderWithState({
toolCallId: 'call_dialog',

View file

@ -0,0 +1,47 @@
import { Constants } from 'librechat-data-provider';
import { shouldResetSubagentAtomsOnConversationChange } from '../cleanup';
describe('subagent atom cleanup', () => {
it('preserves live subagent atoms when the active new chat receives its saved id', () => {
expect(
shouldResetSubagentAtomsOnConversationChange(
Constants.NEW_CONVO,
'conversation-1',
'conversation-1',
),
).toBe(false);
});
it('resets live subagent atoms when leaving new chat for an existing conversation', () => {
expect(
shouldResetSubagentAtomsOnConversationChange(Constants.NEW_CONVO, 'conversation-1', null),
).toBe(true);
expect(
shouldResetSubagentAtomsOnConversationChange(
Constants.NEW_CONVO,
'conversation-1',
'conversation-2',
),
).toBe(true);
});
it('resets live subagent atoms when leaving an established conversation', () => {
expect(
shouldResetSubagentAtomsOnConversationChange('conversation-1', 'conversation-2', null),
).toBe(true);
expect(shouldResetSubagentAtomsOnConversationChange('conversation-1', null, null)).toBe(true);
expect(shouldResetSubagentAtomsOnConversationChange('conversation-1', undefined, null)).toBe(
true,
);
});
it('does not reset on initial mount or same-conversation rerenders', () => {
expect(shouldResetSubagentAtomsOnConversationChange(null, 'conversation-1', null)).toBe(false);
expect(shouldResetSubagentAtomsOnConversationChange(undefined, 'conversation-1', null)).toBe(
false,
);
expect(
shouldResetSubagentAtomsOnConversationChange('conversation-1', 'conversation-1', null),
).toBe(false);
});
});

View file

@ -0,0 +1,11 @@
import { Constants } from 'librechat-data-provider';
export function shouldResetSubagentAtomsOnConversationChange(
previous: string | null | undefined,
next: string | null | undefined,
preserveNewConversationId: string | null,
): boolean {
if (previous == null || previous === next) return false;
if (previous === Constants.NEW_CONVO && next === preserveNewConversationId) return false;
return true;
}

View file

@ -41,6 +41,7 @@ import { useApplyAgentTemplate } from '~/hooks/Agents';
import { useAuthContext } from '~/hooks/AuthContext';
import { MESSAGE_UPDATE_INTERVAL } from '~/common';
import { useLiveAnnouncer } from '~/Providers';
import { shouldResetSubagentAtomsOnConversationChange } from './cleanup';
import store from '~/store';
type TSyncData = {
@ -204,23 +205,30 @@ export default function useEventHandlers({
* doesn't lose any viewable history — it just keeps `atomFamily`
* bounded across multi-conversation sessions.
*
* Rule: only reset when transitioning AWAY FROM an established
* conversation (`previous != null`). Transitions FROM null or
* undefined pass through:
* Rule: reset on real conversation switches, but preserve atoms for
* the single `new` → saved-id transition created by this active run.
* Transitions FROM null or undefined pass through:
* - initial mount on a new-chat route: nothing to clear.
* - new-chat URL stamp mid-stream (null → newId): the in-flight
* subagent ticker/content state for that freshly-stamped id
* would be wiped if we reset here — that id IS the current
* run, not a stale one.
* - new-chat URL stamp mid-stream (`new` → savedId): the final
* handler marks that saved id before navigation so the in-flight
* subagent ticker/content state survives. Cancelled subagent
* runs depend on this live atom because the server may not have
* persisted `subagent_content` before interruption.
* Cases that DO reset (previous non-null, value changed):
* - id1 → id2 (switching between established chats)
* - new → id (user selected an existing chat from the sidebar)
* - id → null (user clicked "new chat")
* - id → undefined (route teardown / navigate away) */
const lastConversationIdRef = useRef<string | null | undefined>(paramId);
const preserveSubagentAtomsForNewConvoIdRef = useRef<string | null>(null);
useEffect(() => {
const previous = lastConversationIdRef.current;
const preserveNewConversationId = preserveSubagentAtomsForNewConvoIdRef.current;
lastConversationIdRef.current = paramId;
if (previous != null && previous !== paramId) {
preserveSubagentAtomsForNewConvoIdRef.current = null;
if (
shouldResetSubagentAtomsOnConversationChange(previous, paramId, preserveNewConversationId)
) {
resetSubagentAtoms();
}
}, [paramId, resetSubagentAtoms]);
@ -644,6 +652,7 @@ export default function useEventHandlers({
}
if (location.pathname === `/c/${Constants.NEW_CONVO}`) {
preserveSubagentAtomsForNewConvoIdRef.current = conversation.conversationId;
navigate(`/c/${conversation.conversationId}`, { replace: true });
}
}