- {!isAtBottom && (
-
+ {!canOpenDurablePanel && (
+
+
-
- {prompt ? (
-
setPromptExpanded((expanded) => !expanded)}
- />
- ) : null}
- {renderDialogBody()}
+ >
+
+
+
+ {isSelfSpawn
+ ? localize('com_ui_subagent_dialog_title_self')
+ : localize('com_ui_subagent_dialog_title', { 0: subagentType })}
+
+
+
+ {localize('com_ui_subagent_dialog_description')}
+
+
+
+
+ {!isAtBottom && (
+
+ )}
+
+
+ {prompt ? (
+ setPromptExpanded((expanded) => !expanded)}
+ />
+ ) : null}
+ {renderDialogBody()}
+
-
-
-
+
+
+ )}
{!hideAttachments && attachments && attachments.length > 0 && (
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 952385af6a..6d21e11899 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,6 +1,6 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
-import { RecoilRoot, useRecoilCallback } from 'recoil';
+import { RecoilRoot, useRecoilCallback, useRecoilValue } from 'recoil';
import { render, screen, act, fireEvent, waitFor, within } from '@testing-library/react';
import type { SubagentUpdateEvent } from 'librechat-data-provider';
import type {
@@ -8,15 +8,16 @@ import type {
SubagentTickerState,
SubagentAggregatorState,
} from '~/utils/subagentContent';
-import type { SubagentProgress } from '~/store/subagents';
+import type { ActiveSubagentPanel, SubagentProgress } from '~/store/subagents';
import {
foldSubagentEvent,
foldSubagentEventIntoTicker,
initSubagentAggregatorState,
initSubagentTickerState,
} from '~/utils/subagentContent';
+import { activeSubagentPanel, subagentProgressByToolCallId } from '~/store/subagents';
import SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall';
-import { subagentProgressByToolCallId } from '~/store/subagents';
+import { MessageContext } from '~/Providers/MessageContext';
const mockNavigateToConvo = jest.fn();
@@ -32,6 +33,7 @@ jest.mock('~/hooks', () => ({
const arg1 = (values?.[1] as string | undefined) ?? '';
const translations: Record
= {
com_ui_subagent_running: 'Running agent',
+ com_ui_subagent_activity: 'Agent activity',
com_ui_subagent_complete: 'Ran agent',
com_ui_subagent_cancelled: 'Cancelled agent',
com_ui_subagent_errored: 'Agent errored',
@@ -151,6 +153,7 @@ jest.mock('~/utils', () => ({
...jest.requireActual('~/utils/groupToolCalls'),
...jest.requireActual('~/utils/toolLabels'),
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
+ logger: { log: jest.fn() },
}));
afterEach(() => {
@@ -646,7 +649,7 @@ describe('SubagentCall — dialog content', () => {
rerender({null});
});
- it('links only an exact host-issued detached result to its durable child chat', () => {
+ it('opens only an exact host-issued detached result in the parent activity panel', () => {
const output = JSON.stringify({
background_task_id: 'task-1',
subagent_thread_id: 'child-thread-1',
@@ -656,23 +659,44 @@ describe('SubagentCall — dialog content', () => {
message:
'Started subagent "self" background task. Poll the host background-task tool with background_task_id "task-1".',
});
+ let selectedPanel: ActiveSubagentPanel | null = null;
+ const SelectionObserver = () => {
+ selectedPanel = useRecoilValue(activeSubagentPanel);
+ return null;
+ };
render(
-
+
+
+
+
,
);
- openSubagentDialog();
- fireEvent.click(screen.getByRole('button', { name: 'Open child chat' }));
- expect(mockNavigateToConvo).toHaveBeenCalledWith({ conversationId: 'child-thread-1' });
+ fireEvent.click(screen.getByRole('button', { name: 'Agent activity' }));
+ expect(selectedPanel).toEqual({
+ parentConversationId: 'parent-conversation',
+ threadId: 'child-thread-1',
+ taskId: 'task-1',
+ toolCallId: 'call_detached',
+ subagentType: 'self',
+ });
+ expect(mockNavigateToConvo).not.toHaveBeenCalled();
+ expect(screen.queryByTestId('dialog-content')).not.toBeInTheDocument();
expect(screen.queryByText(output)).not.toBeInTheDocument();
});
diff --git a/client/src/components/Chat/Presentation.test.tsx b/client/src/components/Chat/Presentation.test.tsx
index ed45513962..7787f0b7d8 100644
--- a/client/src/components/Chat/Presentation.test.tsx
+++ b/client/src/components/Chat/Presentation.test.tsx
@@ -1,12 +1,16 @@
import React from 'react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { fireEvent, render, screen } from '@testing-library/react';
+import type { TConversation } from 'librechat-data-provider';
import type { Artifact } from '~/common';
+import { activeSubagentPanel } from '~/store/subagents';
import Presentation from './Presentation';
import store from '~/store';
const mockArtifactPanelLabel = 'Artifact panel loaded';
const mockOpenArtifactLabel = 'Open Artifact';
+const mockChildPanelLabel = 'Child activity panel loaded';
+const mockOpenChildLabel = 'Open Child Activity';
jest.mock('~/components/Artifacts/Artifacts', () => {
const artifactPanelLabel = 'Artifact panel loaded';
@@ -21,22 +25,21 @@ jest.mock('~/components/Artifacts/Artifacts', () => {
};
});
+jest.mock('~/components/Chat/Subagents/SubagentThreadPanel', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
jest.mock('~/components/Chat/Input/Files/DragDropWrapper', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => {children}
,
}));
jest.mock('~/components/SidePanel', () => ({
- SidePanelGroup: ({
- artifacts,
- children,
- }: {
- artifacts: React.ReactNode;
- children: React.ReactNode;
- }) => (
+ SidePanelGroup: ({ panel, children }: { panel: React.ReactNode; children: React.ReactNode }) => (
{children}
- {artifacts}
+ {panel}
),
}));
@@ -84,6 +87,26 @@ const OpenArtifactPanel = () => {
);
};
+const OpenSubagentPanel = () => {
+ const setConversation = useSetRecoilState(store.conversationByIndex(0));
+ const setSelection = useSetRecoilState(activeSubagentPanel);
+ const open = () => {
+ setConversation({ conversationId: 'parent-conversation' } as TConversation);
+ setSelection({
+ parentConversationId: 'parent-conversation',
+ threadId: 'child-thread',
+ taskId: 'background-task',
+ toolCallId: 'tool-call',
+ subagentType: 'researcher',
+ });
+ };
+ return (
+
+ );
+};
+
describe('Presentation Artifact loading', () => {
it('loads the Artifact panel bundle only when the panel is opened', async () => {
const testGlobal = globalThis as typeof globalThis & {
@@ -105,4 +128,22 @@ describe('Presentation Artifact loading', () => {
expect(await screen.findByText(mockArtifactPanelLabel)).toBeInTheDocument();
expect(testGlobal.presentationArtifactModuleEvaluations).toBe(1);
});
+
+ it('uses one panel slot and lets an opened artifact replace child activity', async () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: mockOpenChildLabel }));
+ expect(await screen.findByText(mockChildPanelLabel)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: mockOpenArtifactLabel }));
+ expect(await screen.findByText(mockArtifactPanelLabel)).toBeInTheDocument();
+ expect(screen.queryByText(mockChildPanelLabel)).not.toBeInTheDocument();
+ });
});
diff --git a/client/src/components/Chat/Presentation.tsx b/client/src/components/Chat/Presentation.tsx
index 7ab0a492b2..86003aa984 100644
--- a/client/src/components/Chat/Presentation.tsx
+++ b/client/src/components/Chat/Presentation.tsx
@@ -1,5 +1,5 @@
-import { lazy, Suspense, useEffect, useMemo } from 'react';
-import { useRecoilValue } from 'recoil';
+import { lazy, Suspense, useEffect, useMemo, useRef } from 'react';
+import { useRecoilValue, useResetRecoilState } from 'recoil';
import { FileSources, LocalStorageKeys } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import useResetArtifactsOnConversationChange from '~/hooks/Artifacts/useResetArtifactsOnConversationChange';
@@ -7,10 +7,12 @@ import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper';
import { EditorProvider, ArtifactsProvider } from '~/Providers';
import { useDeleteFilesMutation } from '~/data-provider';
import { SidePanelGroup } from '~/components/SidePanel';
+import { activeSubagentPanel } from '~/store/subagents';
import { useSetFilesToDelete } from '~/hooks';
import store from '~/store';
const Artifacts = lazy(() => import('~/components/Artifacts/Artifacts'));
+const SubagentThreadPanel = lazy(() => import('~/components/Chat/Subagents/SubagentThreadPanel'));
export default function Presentation({ children }: { children: React.ReactNode }) {
const artifacts = useRecoilValue(store.artifactsState);
@@ -23,9 +25,20 @@ export default function Presentation({ children }: { children: React.ReactNode }
// arriving via SSE auto-focus through `ToolArtifactCard`'s mount effect
// (gated on `isSubmitting`), restoring the legacy streaming UX.
const currentArtifactId = useRecoilValue(store.currentArtifactId);
+ const conversationId = useRecoilValue(store.conversationIdByIndex(0));
+ const selectedSubagent = useRecoilValue(activeSubagentPanel);
+ const resetSelectedSubagent = useResetRecoilState(activeSubagentPanel);
+ const previousConversationIdRef = useRef(null);
useResetArtifactsOnConversationChange();
+ useEffect(() => {
+ const previous = previousConversationIdRef.current;
+ const next = conversationId ?? null;
+ previousConversationIdRef.current = next;
+ if (previous != null && previous !== next) resetSelectedSubagent();
+ }, [conversationId, resetSelectedSubagent]);
+
const setFilesToDelete = useSetFilesToDelete();
const { mutateAsync } = useDeleteFilesMutation({
@@ -78,9 +91,26 @@ export default function Presentation({ children }: { children: React.ReactNode }
return null;
}, [artifactsVisibility, artifacts, currentArtifactId]);
+ useEffect(() => {
+ if (artifactsElement != null && selectedSubagent != null) resetSelectedSubagent();
+ }, [artifactsElement, resetSelectedSubagent, selectedSubagent]);
+
+ const subagentElement = useMemo(() => {
+ if (selectedSubagent == null || selectedSubagent.parentConversationId !== conversationId) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ }, [conversationId, selectedSubagent]);
+
+ const panelElement = artifactsElement ?? subagentElement;
+
return (
-
+
{children}
diff --git a/client/src/components/Chat/SubagentThreadLink.tsx b/client/src/components/Chat/SubagentThreadLink.tsx
index 22db15754e..74cb097147 100644
--- a/client/src/components/Chat/SubagentThreadLink.tsx
+++ b/client/src/components/Chat/SubagentThreadLink.tsx
@@ -1,50 +1,30 @@
-import { useMemo } from 'react';
import { Button } from '@librechat/client';
-import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { ChevronLeft } from 'lucide-react';
import { useGetConversationByIdQuery } from 'librechat-data-provider/react-query';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { cn } from '~/utils';
-const CHILD_THREAD_POLL_WINDOW_MS = 60_000;
-
export default function SubagentThreadLink({
threadId,
- relation,
className,
labelClassName,
}: {
threadId: string;
- relation: 'parent' | 'child';
className?: string;
labelClassName?: string;
}) {
const localize = useLocalize();
const { navigateToConvo } = useNavigateToConvo();
const normalizedThreadId = threadId.trim();
- const isParent = relation === 'parent';
- const childPoll = useMemo(
- () => ({ threadId: normalizedThreadId, deadline: Date.now() + CHILD_THREAD_POLL_WINDOW_MS }),
- [normalizedThreadId],
- );
const { data: targetConversation } = useGetConversationByIdQuery(normalizedThreadId, {
enabled: normalizedThreadId !== '',
retry: false,
- refetchInterval: (conversation) =>
- !isParent &&
- conversation == null &&
- normalizedThreadId === childPoll.threadId &&
- Date.now() < childPoll.deadline
- ? 1500
- : false,
});
if (normalizedThreadId === '' || targetConversation == null) {
return null;
}
- const label = localize(
- isParent ? 'com_ui_subagent_back_to_parent' : 'com_ui_subagent_open_thread',
- );
- const Icon = isParent ? ChevronLeft : ChevronRight;
+ const label = localize('com_ui_subagent_back_to_parent');
return (
);
}
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
new file mode 100644
index 0000000000..ce24533f82
--- /dev/null
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import { RecoilRoot, useRecoilValue } from 'recoil';
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { SubagentThreadView } from 'librechat-data-provider';
+import type { ActiveSubagentPanel } from '~/store/subagents';
+import { activeSubagentPanel } from '~/store/subagents';
+import SubagentThreadPanel from './SubagentThreadPanel';
+
+const mockUseSubagentThreadQuery = jest.fn();
+const mockSpinnerLabel = 'spinner';
+let mockIsMobile = false;
+
+jest.mock('~/data-provider', () => ({
+ useSubagentThreadQuery: (...args: unknown[]) => mockUseSubagentThreadQuery(...args),
+}));
+
+jest.mock('~/hooks', () => ({
+ useFocusTrap: jest.fn(),
+ useLocalize: () => (key: string) => key,
+}));
+
+jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({
+ __esModule: true,
+ default: ({ content }: { content: string }) => {content}
,
+}));
+
+jest.mock('@librechat/client', () => ({
+ Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
+
+ ),
+ Spinner: () => {mockSpinnerLabel},
+ useMediaQuery: () => mockIsMobile,
+}));
+
+jest.mock('lucide-react', () => ({
+ AlertCircle: () => null,
+ Bot: () => null,
+ CheckCircle2: () => null,
+ Clock3: () => null,
+ X: () => null,
+ XCircle: () => null,
+}));
+
+const selection: ActiveSubagentPanel = {
+ parentConversationId: 'parent-conversation',
+ threadId: 'child-thread',
+ taskId: 'task',
+ toolCallId: 'tool-call',
+ subagentType: 'researcher',
+};
+
+const completedView: SubagentThreadView = {
+ threadId: 'child-thread',
+ parentConversationId: 'parent-conversation',
+ parentMessageId: 'parent-message',
+ parentToolCallId: 'tool-call',
+ subagentType: 'researcher',
+ subagentKind: 'agent',
+ title: 'Research child',
+ status: 'completed',
+ historyTruncated: true,
+ messages: [
+ {
+ messageId: 'task:user',
+ parentMessageId: null,
+ role: 'user',
+ text: 'Investigate the release.',
+ },
+ {
+ messageId: 'task:assistant',
+ parentMessageId: 'task:user',
+ role: 'assistant',
+ text: 'The release is ready.',
+ textTruncated: true,
+ },
+ ],
+};
+
+describe('SubagentThreadPanel', () => {
+ beforeEach(() => {
+ mockIsMobile = false;
+ });
+
+ it('renders a bounded read-only activity timeline and closes its selection', () => {
+ mockUseSubagentThreadQuery.mockReturnValue({
+ data: completedView,
+ isLoading: false,
+ isError: false,
+ isReadinessPending: false,
+ });
+ let active: ActiveSubagentPanel | null = selection;
+ const Observer = () => {
+ active = useRecoilValue(activeSubagentPanel);
+ return null;
+ };
+
+ render(
+ set(activeSubagentPanel, selection)}>
+
+
+ ,
+ );
+
+ expect(mockUseSubagentThreadQuery).toHaveBeenCalledWith(
+ 'parent-conversation',
+ 'child-thread',
+ 'task',
+ );
+ expect(screen.getByText('Research child')).toBeInTheDocument();
+ expect(screen.getByText('com_ui_subagent_thread_status_completed')).toBeInTheDocument();
+ expect(screen.getByText('com_ui_subagent_thread_history_truncated')).toBeInTheDocument();
+ expect(screen.getByText('Investigate the release.')).toBeInTheDocument();
+ expect(screen.getByText('The release is ready.')).toBeInTheDocument();
+ expect(screen.getByText('com_ui_subagent_thread_message_truncated')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' }));
+ expect(active).toBeNull();
+ });
+
+ it('keeps an expected pre-reservation 404 in the readiness state', () => {
+ mockUseSubagentThreadQuery.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ isReadinessPending: true,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(mockSpinnerLabel)).toBeInTheDocument();
+ expect(screen.queryByText('com_ui_subagent_thread_load_error')).not.toBeInTheDocument();
+ });
+
+ it('exposes the focus-trapped mobile overlay as a modal dialog', () => {
+ mockIsMobile = true;
+ mockUseSubagentThreadQuery.mockReturnValue({
+ data: completedView,
+ isLoading: false,
+ isError: false,
+ isReadinessPending: false,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
+ });
+});
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
new file mode 100644
index 0000000000..e6db4d9499
--- /dev/null
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
@@ -0,0 +1,165 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useResetRecoilState } from 'recoil';
+import { Button, Spinner, useMediaQuery } from '@librechat/client';
+import { AlertCircle, Bot, CheckCircle2, Clock3, X, XCircle } from 'lucide-react';
+import type { SubagentThreadStatus } from 'librechat-data-provider';
+import type { ReactNode } from 'react';
+import type { ActiveSubagentPanel } from '~/store/subagents';
+import type { TranslationKeys } from '~/hooks';
+import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
+import { useSubagentThreadQuery } from '~/data-provider';
+import { activeSubagentPanel } from '~/store/subagents';
+import { useFocusTrap, useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+const statusIcon = (status: SubagentThreadStatus) => {
+ if (status === 'completed') return CheckCircle2;
+ if (status === 'failed' || status === 'interrupted') return AlertCircle;
+ if (status === 'cancelled') return XCircle;
+ return Clock3;
+};
+
+const statusLabels: Record = {
+ dispatched: 'com_ui_subagent_thread_status_dispatched',
+ running: 'com_ui_subagent_thread_status_running',
+ completed: 'com_ui_subagent_thread_status_completed',
+ failed: 'com_ui_subagent_thread_status_failed',
+ interrupted: 'com_ui_subagent_thread_status_interrupted',
+ cancelled: 'com_ui_subagent_thread_status_cancelled',
+};
+
+export default function SubagentThreadPanel({ selection }: { selection: ActiveSubagentPanel }) {
+ const localize = useLocalize();
+ const panelRef = useRef(null);
+ const isMobile = useMediaQuery('(max-width: 767px)');
+ const resetSelection = useResetRecoilState(activeSubagentPanel);
+ const { data, isLoading, isError, isReadinessPending } = useSubagentThreadQuery(
+ selection.parentConversationId,
+ selection.threadId,
+ selection.taskId,
+ );
+
+ const close = useCallback(() => {
+ resetSelection();
+ requestAnimationFrame(() => {
+ const trigger = Array.from(
+ document.querySelectorAll('[data-subagent-tool-call]'),
+ ).find((element) => element.dataset.subagentToolCall === selection.toolCallId);
+ trigger?.focus();
+ });
+ }, [resetSelection, selection.toolCallId]);
+
+ useFocusTrap(panelRef, isMobile, close);
+
+ useEffect(() => {
+ const activeElement = document.activeElement;
+ if (!isMobile || !(activeElement instanceof HTMLElement)) return;
+ return () => {
+ if (activeElement.isConnected) activeElement.focus();
+ };
+ }, [isMobile]);
+
+ const status = data?.status ?? 'dispatched';
+ const StatusIcon = statusIcon(status);
+ const title = data?.title ?? selection.subagentType;
+ let panelBody: ReactNode;
+ if (isLoading || isReadinessPending) {
+ panelBody = (
+
+
+
+ );
+ } else if (isError) {
+ panelBody = (
+
+ {localize('com_ui_subagent_thread_load_error')}
+
+ );
+ } else if (data?.messages.length === 0) {
+ panelBody = (
+
+ {localize('com_ui_subagent_thread_empty')}
+
+ );
+ } else {
+ panelBody = (
+
+ {data?.historyTruncated === true && (
+ -
+
+ {localize('com_ui_subagent_thread_history_truncated')}
+
+ )}
+ {data?.messages.map((message) => (
+ -
+
+
+
+ {message.role === 'user'
+ ? localize('com_ui_subagent_thread_task')
+ : localize('com_ui_subagent_thread_response')}
+
+
+
+
+ {message.textTruncated === true && (
+
+ {localize('com_ui_subagent_thread_message_truncated')}
+
+ )}
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/client/src/components/Chat/Subagents/index.ts b/client/src/components/Chat/Subagents/index.ts
new file mode 100644
index 0000000000..2eba22288f
--- /dev/null
+++ b/client/src/components/Chat/Subagents/index.ts
@@ -0,0 +1 @@
+export { default as SubagentThreadPanel } from './SubagentThreadPanel';
diff --git a/client/src/components/Chat/__tests__/SubagentThreadLink.test.tsx b/client/src/components/Chat/__tests__/SubagentThreadLink.test.tsx
index 6e04154dd5..a256309474 100644
--- a/client/src/components/Chat/__tests__/SubagentThreadLink.test.tsx
+++ b/client/src/components/Chat/__tests__/SubagentThreadLink.test.tsx
@@ -14,14 +14,12 @@ jest.mock('librechat-data-provider/react-query', () => ({
}));
jest.mock('~/hooks', () => ({
- useLocalize: () => (key: string) =>
- key === 'com_ui_subagent_back_to_parent' ? 'Back to parent chat' : 'Open child chat',
+ useLocalize: () => () => 'Back to parent chat',
useNavigateToConvo: () => ({ navigateToConvo: mockNavigateToConvo }),
}));
jest.mock('lucide-react', () => ({
ChevronLeft: () => ,
- ChevronRight: () => ,
}));
describe('SubagentThreadLink', () => {
@@ -36,7 +34,7 @@ describe('SubagentThreadLink', () => {
it('loads a parent chat and navigates through the conversation state helper', () => {
const parent = { conversationId: 'parent-thread' };
mockUseGetConversationByIdQuery.mockReturnValue({ data: parent });
- renderLink();
+ renderLink();
fireEvent.click(screen.getByRole('button', { name: 'Back to parent chat' }));
expect(mockNavigateToConvo).toHaveBeenCalledWith(parent);
@@ -47,58 +45,8 @@ describe('SubagentThreadLink', () => {
);
});
- it('links a parent tool result only after the child conversation is durable', () => {
- mockUseGetConversationByIdQuery.mockReturnValue({
- data: { conversationId: 'child/thread' },
- });
- renderLink();
-
- fireEvent.click(screen.getByRole('button', { name: 'Open child chat' }));
- expect(mockNavigateToConvo).toHaveBeenCalledWith({ conversationId: 'child/thread' });
- expect(screen.getByTestId('right-icon')).toBeInTheDocument();
- });
-
- it('hides a provisional child link while polling for durable creation', () => {
- const { container } = renderLink(
- ,
- );
-
- expect(container).toBeEmptyDOMElement();
- expect(mockUseGetConversationByIdQuery).toHaveBeenCalledWith(
- 'provisional-child',
- expect.objectContaining({ enabled: true, retry: false }),
- );
- const config = mockUseGetConversationByIdQuery.mock.calls[0][1] as {
- refetchInterval: (conversation: unknown) => number | false;
- };
- expect(config.refetchInterval(undefined)).toBe(1500);
- });
-
- it('stops polling for a child that never became durable', () => {
- const now = jest.spyOn(Date, 'now').mockReturnValue(10_000);
- renderLink();
- const config = mockUseGetConversationByIdQuery.mock.calls[0][1] as {
- refetchInterval: (conversation: unknown) => number | false;
- };
-
- now.mockReturnValue(70_000);
- expect(config.refetchInterval(undefined)).toBe(false);
- now.mockRestore();
- });
-
it('does not render an empty thread selector', () => {
- const { container } = renderLink();
+ const { container } = renderLink();
expect(container).toBeEmptyDOMElement();
});
-
- it('passes the complete fetched child record into conversation navigation', () => {
- const child = { conversationId: 'child-thread', title: 'Research child' };
- mockUseGetConversationByIdQuery.mockReturnValue({
- data: child,
- });
- renderLink();
-
- fireEvent.click(screen.getByRole('button', { name: 'Open child chat' }));
- expect(mockNavigateToConvo).toHaveBeenCalledWith(child);
- });
});
diff --git a/client/src/components/SidePanel/ArtifactsPanel.tsx b/client/src/components/SidePanel/ArtifactsPanel.tsx
index ac9a20e910..8bfb6f9dd5 100644
--- a/client/src/components/SidePanel/ArtifactsPanel.tsx
+++ b/client/src/components/SidePanel/ArtifactsPanel.tsx
@@ -3,14 +3,14 @@ import { usePanelRef } from 'react-resizable-panels';
import { ResizableHandleAlt, ResizablePanel } from '@librechat/client';
interface ArtifactsPanelProps {
- artifacts: React.ReactNode | null;
+ panel: React.ReactNode | null;
minSizeMain: string;
shouldRender: boolean;
onRenderChange: (shouldRender: boolean) => void;
}
const ArtifactsPanel = memo(function ArtifactsPanel({
- artifacts,
+ panel,
minSizeMain,
shouldRender,
onRenderChange,
@@ -18,7 +18,7 @@ const ArtifactsPanel = memo(function ArtifactsPanel({
const artifactsPanelRef = usePanelRef();
useEffect(() => {
- if (artifacts != null) {
+ if (panel != null) {
onRenderChange(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
@@ -28,7 +28,7 @@ const ArtifactsPanel = memo(function ArtifactsPanel({
} else if (shouldRender) {
onRenderChange(false);
}
- }, [artifacts, shouldRender, onRenderChange, artifactsPanelRef]);
+ }, [panel, shouldRender, onRenderChange, artifactsPanelRef]);
if (!shouldRender) {
return null;
@@ -36,7 +36,7 @@ const ArtifactsPanel = memo(function ArtifactsPanel({
return (
<>
- {artifacts != null && (
+ {panel != null && (
)}
- {artifacts}
+ {panel}
>
);
diff --git a/client/src/components/SidePanel/SidePanelGroup.tsx b/client/src/components/SidePanel/SidePanelGroup.tsx
index fe5a306576..aa611b1452 100644
--- a/client/src/components/SidePanel/SidePanelGroup.tsx
+++ b/client/src/components/SidePanel/SidePanelGroup.tsx
@@ -4,24 +4,25 @@ import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/c
import ArtifactsPanel from './ArtifactsPanel';
const PANEL_IDS_SINGLE = ['messages-view'];
+/** Keep the persisted id stable so existing artifact panel widths carry over. */
const PANEL_IDS_SPLIT = ['messages-view', 'artifacts-panel'];
interface SidePanelProps {
- artifacts?: React.ReactNode;
+ panel?: React.ReactNode;
children: React.ReactNode;
}
-const SidePanelGroup = memo(({ artifacts, children }: SidePanelProps) => {
- const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
+const SidePanelGroup = memo(({ panel, children }: SidePanelProps) => {
+ const [shouldRenderPanel, setShouldRenderPanel] = useState(panel != null);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'side-panel-layout',
- panelIds: artifacts != null ? PANEL_IDS_SPLIT : PANEL_IDS_SINGLE,
+ panelIds: panel != null ? PANEL_IDS_SPLIT : PANEL_IDS_SINGLE,
storage: localStorage,
});
- const minSizeMain = artifacts != null ? '15' : '30';
+ const minSizeMain = panel != null ? '15' : '30';
return (
<>
@@ -37,16 +38,14 @@ const SidePanelGroup = memo(({ artifacts, children }: SidePanelProps) => {
{!isSmallScreen && (
)}
- {artifacts != null && isSmallScreen && (
- {artifacts}
- )}
+ {panel != null && isSmallScreen && {panel}
}
>
);
});
diff --git a/client/src/data-provider/Subagents/index.ts b/client/src/data-provider/Subagents/index.ts
new file mode 100644
index 0000000000..3cf1ef310b
--- /dev/null
+++ b/client/src/data-provider/Subagents/index.ts
@@ -0,0 +1 @@
+export * from './queries';
diff --git a/client/src/data-provider/Subagents/queries.test.ts b/client/src/data-provider/Subagents/queries.test.ts
new file mode 100644
index 0000000000..37da42d966
--- /dev/null
+++ b/client/src/data-provider/Subagents/queries.test.ts
@@ -0,0 +1,71 @@
+import { renderHook } from '@testing-library/react';
+import type { SubagentThreadView } from 'librechat-data-provider';
+import {
+ isSubagentReadinessPending,
+ subagentThreadRefetchInterval,
+ useSubagentThreadQuery,
+} from './queries';
+
+const mockUseQuery = jest.fn();
+
+jest.mock('@tanstack/react-query', () => ({
+ useQuery: (...args: unknown[]) => mockUseQuery(...args),
+}));
+
+const view = (status: SubagentThreadView['status']): SubagentThreadView =>
+ ({ status }) as SubagentThreadView;
+
+describe('subagent thread refresh policy', () => {
+ it('bounds child-readiness retries and keeps active work fresh', () => {
+ expect(subagentThreadRefetchInterval(undefined, 1_000, 500)).toBe(2_000);
+ expect(subagentThreadRefetchInterval(view('dispatched'), 1_000, 500)).toBe(2_000);
+ expect(subagentThreadRefetchInterval(undefined, 1_000, 1_000)).toBe(false);
+ expect(subagentThreadRefetchInterval(view('dispatched'), 1_000, 1_000)).toBe(false);
+ expect(subagentThreadRefetchInterval(view('running'), 1_000, 10_000)).toBe(2_000);
+ });
+
+ it.each(['completed', 'failed', 'interrupted', 'cancelled'] as const)(
+ 'stops polling terminal %s threads',
+ (status) => {
+ expect(subagentThreadRefetchInterval(view(status), 1_000, 500)).toBe(false);
+ },
+ );
+
+ it('keeps polling a cached terminal thread until the selected task appears', () => {
+ const prior = {
+ ...view('completed'),
+ messages: [{ messageId: 'old-task:assistant' }],
+ } as SubagentThreadView;
+ const current = {
+ ...view('completed'),
+ messages: [{ messageId: 'new-task:assistant' }],
+ } as SubagentThreadView;
+
+ expect(subagentThreadRefetchInterval(prior, 1_000, 500, 'new-task')).toBe(2_000);
+ expect(subagentThreadRefetchInterval(current, 1_000, 500, 'new-task')).toBe(false);
+ expect(subagentThreadRefetchInterval(prior, 1_000, 1_000, 'new-task')).toBe(false);
+ });
+
+ it('treats only readiness-window 404s as pending', () => {
+ expect(isSubagentReadinessPending({ response: { status: 404 } }, 1_000, 500)).toBe(true);
+ expect(isSubagentReadinessPending({ response: { status: 404 } }, 1_000, 1_000)).toBe(false);
+ expect(isSubagentReadinessPending({ response: { status: 500 } }, 1_000, 500)).toBe(false);
+ });
+
+ it('refetches a terminal thread when a new invocation continues it', () => {
+ const refetch = jest.fn();
+ mockUseQuery.mockReturnValue({
+ data: view('completed'),
+ error: null,
+ refetch,
+ });
+ const { rerender } = renderHook(
+ ({ taskId }) => useSubagentThreadQuery('parent-conversation', 'child-thread', taskId),
+ { initialProps: { taskId: 'task-1' } },
+ );
+
+ expect(refetch).not.toHaveBeenCalled();
+ rerender({ taskId: 'task-2' });
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/client/src/data-provider/Subagents/queries.ts b/client/src/data-provider/Subagents/queries.ts
new file mode 100644
index 0000000000..cec1181de2
--- /dev/null
+++ b/client/src/data-provider/Subagents/queries.ts
@@ -0,0 +1,89 @@
+import { useEffect, useMemo, useRef } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { QueryKeys, dataService } from 'librechat-data-provider';
+import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
+import type { SubagentThreadView } from 'librechat-data-provider';
+
+const ACTIVE_THREAD_REFRESH_MS = 2_000;
+const CHILD_READY_POLL_WINDOW_MS = 60_000;
+
+const isTerminal = (status: SubagentThreadView['status']): boolean =>
+ status === 'completed' ||
+ status === 'failed' ||
+ status === 'interrupted' ||
+ status === 'cancelled';
+
+export const subagentThreadRefetchInterval = (
+ view: SubagentThreadView | undefined,
+ readinessDeadline: number,
+ now = Date.now(),
+ expectedTaskId?: string,
+): number | false => {
+ if (
+ expectedTaskId != null &&
+ !view?.messages.some(
+ (message) =>
+ message.messageId === `${expectedTaskId}:user` ||
+ message.messageId === `${expectedTaskId}:assistant`,
+ )
+ ) {
+ return now < readinessDeadline ? ACTIVE_THREAD_REFRESH_MS : false;
+ }
+ if (view == null || view.status === 'dispatched') {
+ return now < readinessDeadline ? ACTIVE_THREAD_REFRESH_MS : false;
+ }
+ return isTerminal(view.status) ? false : ACTIVE_THREAD_REFRESH_MS;
+};
+
+const responseStatus = (error: unknown): number | undefined => {
+ if (error == null || typeof error !== 'object') return undefined;
+ const candidate = error as { status?: number; response?: { status?: number } };
+ return candidate.response?.status ?? candidate.status;
+};
+
+export const isSubagentReadinessPending = (
+ error: unknown,
+ readinessDeadline: number,
+ now = Date.now(),
+): boolean => responseStatus(error) === 404 && now < readinessDeadline;
+
+export type SubagentThreadQueryResult = QueryObserverResult & {
+ isReadinessPending: boolean;
+};
+
+export const useSubagentThreadQuery = (
+ parentConversationId: string,
+ threadId: string,
+ taskId: string,
+ config?: UseQueryOptions,
+): SubagentThreadQueryResult => {
+ const readinessKey = `${parentConversationId}\u0000${threadId}\u0000${taskId}`;
+ const readiness = useMemo(
+ () => ({ key: readinessKey, deadline: Date.now() + CHILD_READY_POLL_WINDOW_MS }),
+ [readinessKey],
+ );
+ const previousTaskId = useRef(taskId);
+ const query = useQuery(
+ [QueryKeys.subagentThread, parentConversationId, threadId],
+ () => dataService.getSubagentThread(parentConversationId, threadId),
+ {
+ enabled: parentConversationId !== '' && threadId !== '',
+ retry: false,
+ refetchOnWindowFocus: true,
+ refetchInterval: (view) =>
+ subagentThreadRefetchInterval(view, readiness.deadline, Date.now(), taskId),
+ ...config,
+ },
+ );
+ const { refetch } = query;
+ useEffect(() => {
+ if (previousTaskId.current === taskId) return;
+ previousTaskId.current = taskId;
+ void refetch();
+ }, [taskId, refetch]);
+
+ return {
+ ...query,
+ isReadinessPending: isSubagentReadinessPending(query.error, readiness.deadline),
+ };
+};
diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts
index 8285caa905..090c8b9689 100644
--- a/client/src/data-provider/index.ts
+++ b/client/src/data-provider/index.ts
@@ -12,6 +12,7 @@ export * from './Misc';
export * from './Projects';
/* Scheduled chats */
export * from './Schedules';
+export * from './Subagents';
export * from './Tools';
export * from './connection';
export * from './Favorites';
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 1c30d82868..a3ee38c85d 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -2154,6 +2154,7 @@
"com_ui_storage": "Storage",
"com_ui_storage_filter_sort": "Filter and Sort by Storage",
"com_ui_subagent_back_to_parent": "Back to parent chat",
+ "com_ui_subagent_activity": "Agent activity",
"com_ui_subagent_cancelled": "Cancelled agent",
"com_ui_subagent_complete": "Ran agent",
"com_ui_subagent_dialog_description": "Isolated-context child run. Activity and final result below.",
@@ -2163,7 +2164,20 @@
"com_ui_subagent_errored": "Agent errored",
"com_ui_subagent_no_result_yet": "Still running — no final result yet.",
"com_ui_subagent_open_thread": "Open child chat",
+ "com_ui_subagent_thread_empty": "This agent has not recorded any activity yet.",
+ "com_ui_subagent_thread_history_truncated": "Earlier activity is not shown.",
+ "com_ui_subagent_thread_load_error": "The agent activity could not be loaded.",
+ "com_ui_subagent_thread_message_truncated": "This entry was shortened for display.",
+ "com_ui_subagent_thread_panel": "Child agent activity",
+ "com_ui_subagent_thread_response": "Agent response",
"com_ui_subagent_thread_read_only": "This child thread is view-only here. Its parent agent owns this execution and can continue it with the saved thread history.",
+ "com_ui_subagent_thread_status_cancelled": "Cancelled",
+ "com_ui_subagent_thread_status_completed": "Completed",
+ "com_ui_subagent_thread_status_dispatched": "Dispatched",
+ "com_ui_subagent_thread_status_failed": "Failed",
+ "com_ui_subagent_thread_status_interrupted": "Interrupted",
+ "com_ui_subagent_thread_status_running": "Running",
+ "com_ui_subagent_thread_task": "Assigned task",
"com_ui_subagent_running": "Running agent",
"com_ui_subagent_scroll_to_bottom": "Scroll to latest",
"com_ui_subagent_ticker_error": "Error",
diff --git a/client/src/store/subagents.ts b/client/src/store/subagents.ts
index cf243a8c03..d665aa6feb 100644
--- a/client/src/store/subagents.ts
+++ b/client/src/store/subagents.ts
@@ -1,4 +1,4 @@
-import { atomFamily } from 'recoil';
+import { atom, atomFamily } from 'recoil';
import type { SubagentUpdatePhase } from 'librechat-data-provider';
import type {
SubagentAggregatorState,
@@ -40,6 +40,20 @@ export interface SubagentProgress {
latestLabel?: string;
}
+/** One parent-owned durable child selected for the read-only activity panel. */
+export type ActiveSubagentPanel = {
+ parentConversationId: string;
+ threadId: string;
+ taskId: string;
+ toolCallId: string;
+ subagentType: string;
+};
+
+export const activeSubagentPanel = atom({
+ key: 'activeSubagentPanel',
+ default: null,
+});
+
/** Progress state keyed by parent tool_call_id. */
export const subagentProgressByToolCallId = atomFamily({
key: 'subagentProgressByToolCallId',