mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 01:22:54 +00:00
🪟 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:
parent
ddf5879ccd
commit
65b63b889e
5 changed files with 320 additions and 119 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
47
client/src/hooks/SSE/__tests__/cleanup.spec.ts
Normal file
47
client/src/hooks/SSE/__tests__/cleanup.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
11
client/src/hooks/SSE/cleanup.ts
Normal file
11
client/src/hooks/SSE/cleanup.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue