diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index 7b2171e5a4..fa6ed549af 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -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 = ( + + {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 ( + p.idx === lastPartIndex)} + renderPart={renderDialogPart} + lastContentIdx={lastPartIndex} + /> + ); + })} + + ); + } 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 = ( + + + + ); + } else { + dialogContent =
{emptyDialogText}
; + } + return ( <> @@ -470,70 +539,22 @@ export default function SubagentCall({
- {contentParts.length > 0 ? ( - - {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 ( - p.idx === lastPartIndex)} - renderPart={renderDialogPart} - lastContentIdx={lastPartIndex} - /> - ); - })} - - ) : 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. */ - - - - ) : ( -
- {running - ? localize('com_ui_subagent_no_result_yet') - : localize('com_ui_subagent_empty_result')} -
- )} + {prompt ? ( + setPromptExpanded((expanded) => !expanded)} + /> + ) : null} + {dialogContent}
@@ -558,20 +579,84 @@ function extractSubagentType(args: SubagentCallProps['args']): string { return a?.subagent_type ?? 'agent'; } -function extractDescription(args: Record | undefined): string | undefined { - const d = args?.description; - return typeof d === 'string' && d.length > 0 ? d : undefined; +function extractPrompt(args: Record | 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); } 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 ( +
+
+

+ {localize('com_ui_prompt')} +

+ +
+
+
+ +
+ {!expanded && ( + +
+ ); +} + /** 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. */ diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx index 14725282cf..47a9aa12cb 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx @@ -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 }) =>
{content}
, +})); + jest.mock('../Attachment', () => ({ AttachmentGroup: ({ attachments }: { attachments: unknown }) => (
{JSON.stringify(attachments)}
@@ -85,9 +97,15 @@ jest.mock('@librechat/client', () => ({ })); jest.mock('lucide-react', () => ({ + // eslint-disable-next-line i18next/no-literal-string + ArrowDown: () => arrow-down, // eslint-disable-next-line i18next/no-literal-string ChevronRight: () => chevron, // eslint-disable-next-line i18next/no-literal-string + Maximize2: () => maximize, + // eslint-disable-next-line i18next/no-literal-string + Minimize2: () => minimize, + // eslint-disable-next-line i18next/no-literal-string Users: () => users, })); @@ -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( + + + , + ); + + 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', diff --git a/client/src/hooks/SSE/__tests__/cleanup.spec.ts b/client/src/hooks/SSE/__tests__/cleanup.spec.ts new file mode 100644 index 0000000000..235d1cabde --- /dev/null +++ b/client/src/hooks/SSE/__tests__/cleanup.spec.ts @@ -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); + }); +}); diff --git a/client/src/hooks/SSE/cleanup.ts b/client/src/hooks/SSE/cleanup.ts new file mode 100644 index 0000000000..19e40224aa --- /dev/null +++ b/client/src/hooks/SSE/cleanup.ts @@ -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; +} diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index e696eefa84..e55bd6261c 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -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(paramId); + const preserveSubagentAtomsForNewConvoIdRef = useRef(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 }); } }