🛰️ feat: Show Child Agent Activity in a Side Panel (#15075)

* feat: add parent-scoped subagent thread reads

* fix: tighten child thread read bounds

* perf: project child activity messages

* test: update child activity route fixtures

* test: satisfy response mock types

* fix: bound child activity reads at storage

* style: sort child activity imports

* fix: bound child activity storage reads

* feat: show child activity in a parent-owned panel

* fix: preserve side panel identity

* fix: refresh child activity safely

* fix: follow the selected child task

* test: update child panel fixtures
This commit is contained in:
Danny Avila 2026-08-21 11:10:41 -04:00 committed by GitHub
parent 634432b2ae
commit 876a087558
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 768 additions and 200 deletions

View file

@ -80,11 +80,7 @@ function Header({
)}
>
{parentConversationId != null && (
<SubagentThreadLink
threadId={parentConversationId}
relation="parent"
labelClassName="hidden lg:inline"
/>
<SubagentThreadLink threadId={parentConversationId} labelClassName="hidden lg:inline" />
)}
{!readOnly && <ModelSelector startupConfig={startupConfig} />}
{!readOnly && interfaceConfig.presets === true && interfaceConfig.modelSelect === true && (

View file

@ -1,6 +1,15 @@
import { useCallback, useEffect, useId, useMemo, useReducer, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
useCallback,
useContext,
useEffect,
useId,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
import { ContentTypes, EModelEndpoint } from 'librechat-data-provider';
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
import { ArrowDown, ChevronRight, Maximize2, Minimize2, Users } from 'lucide-react';
import {
Button,
@ -18,17 +27,16 @@ import type {
} from 'librechat-data-provider';
import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent';
import type { SubagentTickerLine } from '~/utils/subagentContent';
import store, { activeSubagentPanel, subagentProgressByToolCallId } from '~/store';
import ToolCallGroup from '~/components/Chat/Messages/Content/ToolCallGroup';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import ToolApproval from '~/components/Chat/Messages/Content/ToolApproval';
import SubagentThreadLink from '~/components/Chat/SubagentThreadLink';
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 MessageIcon from '~/components/Share/MessageIcon';
import { parseSubagentBackgroundHandle } from './handle';
import { subagentProgressByToolCallId } from '~/store';
import { useAgentsMapContext } from '~/Providers';
import { useMCPServerNames } from '~/hooks/MCP';
import { AttachmentGroup } from './Attachment';
@ -184,7 +192,11 @@ export default function SubagentCall({
hideAttachments = false,
}: SubagentCallProps) {
const localize = useLocalize();
const parentMessageContext = useContext(MessageContext);
const progress = useRecoilValue(subagentProgressByToolCallId(toolCallId));
const setSelectedSubagent = useSetRecoilState(activeSubagentPanel);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const agentsMap = useAgentsMapContext();
const [open, setOpen] = useState(false);
const [promptExpanded, setPromptExpanded] = useState(false);
@ -192,6 +204,8 @@ export default function SubagentCall({
() => parseSubagentBackgroundHandle(output, args),
[output, args],
);
const parentConversationId = parentMessageContext.conversationId?.trim() ?? '';
const canOpenDurablePanel = backgroundHandle != null && parentConversationId !== '';
const subagentType = progress?.subagentType ?? extractSubagentType(args);
const isSelfSpawn = subagentType === 'self';
@ -234,6 +248,7 @@ export default function SubagentCall({
: initialProgress >= 1 || progress?.status === 'stop' || hasError;
const cancelled = isClosed ? runStepStatus === 'cancelled' : !isSubmitting && !finished;
const running = !finished && !cancelled;
const detachedStatusUnknown = backgroundHandle != null && progress == null && !isSubmitting;
/**
* Content parts for the dialog. Preference order:
@ -297,6 +312,7 @@ export default function SubagentCall({
const getHeaderText = () => {
if (hasError) return localize('com_ui_subagent_errored');
if (cancelled) return localize('com_ui_subagent_cancelled');
if (detachedStatusUnknown) return localize('com_ui_subagent_activity');
if (intent != null) return intent;
if (running) return localize('com_ui_subagent_running');
return localize('com_ui_subagent_complete');
@ -433,6 +449,30 @@ export default function SubagentCall({
setIsAtBottom(true);
}, []);
const openDetails = useCallback(() => {
if (backgroundHandle != null && canOpenDurablePanel) {
resetCurrentArtifactId();
setArtifactsVisible(false);
setSelectedSubagent({
parentConversationId,
threadId: backgroundHandle.subagent_thread_id,
taskId: backgroundHandle.background_task_id,
toolCallId,
subagentType: backgroundHandle.subagent_type,
});
return;
}
setOpen(true);
}, [
backgroundHandle,
canOpenDurablePanel,
parentConversationId,
resetCurrentArtifactId,
setArtifactsVisible,
setSelectedSubagent,
toolCallId,
]);
const renderDialogBody = () => {
if (contentParts.length > 0) {
return (
@ -498,10 +538,14 @@ export default function SubagentCall({
<>
<button
type="button"
onClick={() => setOpen(true)}
onClick={openDetails}
data-subagent-thread={
canOpenDurablePanel ? backgroundHandle?.subagent_thread_id : undefined
}
data-subagent-tool-call={canOpenDurablePanel ? toolCallId : undefined}
className={cn(
'group my-1.5 flex w-full flex-col gap-1 rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-left transition hover:bg-surface-tertiary',
running && 'animate-pulse-slow',
running && !detachedStatusUnknown && 'animate-pulse-slow',
)}
aria-label={headerText}
>
@ -557,76 +601,72 @@ export default function SubagentCall({
</ul>
</button>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogContent
className={cn(
'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-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
* displays noticeably wider than the default dialog. */
'w-[min(96vw,80rem)] max-w-[min(96vw,80rem)]',
)}
>
<div className="shrink-0 px-6 pb-3 pr-14 pt-6">
<div className="flex min-w-0 items-center justify-between gap-3">
<OGDialogTitle>
{isSelfSpawn
? localize('com_ui_subagent_dialog_title_self')
: localize('com_ui_subagent_dialog_title', { 0: subagentType })}
</OGDialogTitle>
{backgroundHandle != null && (
<SubagentThreadLink
threadId={backgroundHandle.subagent_thread_id}
relation="child"
/>
)}
</div>
<OGDialogDescription className="sr-only">
{localize('com_ui_subagent_dialog_description')}
</OGDialogDescription>
</div>
<div className="relative min-h-0 flex-1 border-t border-border-light bg-surface-primary">
{!isAtBottom && (
<Button
variant="ghost"
size="icon"
onClick={scrollDialogToBottom}
aria-label={localize('com_ui_subagent_scroll_to_bottom')}
className="absolute bottom-3 right-4 z-10 h-8 w-8 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>
{!canOpenDurablePanel && (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogContent
className={cn(
'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-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
* displays noticeably wider than the default dialog. */
'w-[min(96vw,80rem)] max-w-[min(96vw,80rem)]',
)}
<div
ref={scrollRef}
onScroll={handleScroll}
/** 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">
{prompt ? (
<SubagentPrompt
prompt={prompt}
expanded={promptExpanded}
onToggle={() => setPromptExpanded((expanded) => !expanded)}
/>
) : null}
{renderDialogBody()}
>
<div className="shrink-0 px-6 pb-3 pr-14 pt-6">
<div className="flex min-w-0 items-center justify-between gap-3">
<OGDialogTitle>
{isSelfSpawn
? localize('com_ui_subagent_dialog_title_self')
: localize('com_ui_subagent_dialog_title', { 0: subagentType })}
</OGDialogTitle>
</div>
<OGDialogDescription className="sr-only">
{localize('com_ui_subagent_dialog_description')}
</OGDialogDescription>
</div>
<div className="relative min-h-0 flex-1 border-t border-border-light bg-surface-primary">
{!isAtBottom && (
<Button
variant="ghost"
size="icon"
onClick={scrollDialogToBottom}
aria-label={localize('com_ui_subagent_scroll_to_bottom')}
className="absolute bottom-3 right-4 z-10 h-8 w-8 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>
)}
<div
ref={scrollRef}
onScroll={handleScroll}
/** 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">
{prompt ? (
<SubagentPrompt
prompt={prompt}
expanded={promptExpanded}
onToggle={() => setPromptExpanded((expanded) => !expanded)}
/>
) : null}
{renderDialogBody()}
</div>
</div>
</div>
</div>
</OGDialogContent>
</OGDialog>
</OGDialogContent>
</OGDialog>
)}
{!hideAttachments && attachments && attachments.length > 0 && (
<AttachmentGroup attachments={attachments} />

View file

@ -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<string, string> = {
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(<RecoilRoot>{null}</RecoilRoot>);
});
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(
<MemoryRouter>
<RecoilRoot>
<SubagentCall
toolCallId="call_detached"
initialProgress={1}
isSubmitting={false}
args={{ subagent_type: 'self', run_in_background: true }}
output={output}
/>
<SelectionObserver />
<MessageContext.Provider
value={{
messageId: 'parent-message',
conversationId: 'parent-conversation',
isExpanded: false,
}}
>
<SubagentCall
toolCallId="call_detached"
initialProgress={1}
isSubmitting={false}
args={{ subagent_type: 'self', run_in_background: true }}
output={output}
/>
</MessageContext.Provider>
</RecoilRoot>
</MemoryRouter>,
);
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();
});

View file

@ -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: () => <aside>{mockChildPanelLabel}</aside>,
}));
jest.mock('~/components/Chat/Input/Files/DragDropWrapper', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock('~/components/SidePanel', () => ({
SidePanelGroup: ({
artifacts,
children,
}: {
artifacts: React.ReactNode;
children: React.ReactNode;
}) => (
SidePanelGroup: ({ panel, children }: { panel: React.ReactNode; children: React.ReactNode }) => (
<div>
{children}
{artifacts}
{panel}
</div>
),
}));
@ -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 (
<button type="button" onClick={open}>
{mockOpenChildLabel}
</button>
);
};
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(
<RecoilRoot>
<Presentation>
<OpenSubagentPanel />
<OpenArtifactPanel />
</Presentation>
</RecoilRoot>,
);
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();
});
});

View file

@ -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<string | null>(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 (
<Suspense fallback={null}>
<SubagentThreadPanel selection={selectedSubagent} />
</Suspense>
);
}, [conversationId, selectedSubagent]);
const panelElement = artifactsElement ?? subagentElement;
return (
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
<SidePanelGroup artifacts={artifactsElement}>
<SidePanelGroup panel={panelElement}>
<main className="flex h-full flex-col overflow-y-auto" role="main">
{children}
</main>

View file

@ -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 (
<Button
@ -56,9 +36,8 @@ export default function SubagentThreadLink({
title={label}
onClick={() => navigateToConvo(targetConversation)}
>
{isParent && <Icon size={16} aria-hidden="true" />}
<ChevronLeft size={16} aria-hidden="true" />
<span className={labelClassName}>{label}</span>
{!isParent && <Icon size={16} aria-hidden="true" />}
</Button>
);
}

View file

@ -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 }) => <div>{content}</div>,
}));
jest.mock('@librechat/client', () => ({
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
<button {...props}>{children}</button>
),
Spinner: () => <span>{mockSpinnerLabel}</span>,
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(
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
<Observer />
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
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(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
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(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
});

View file

@ -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<SubagentThreadStatus, TranslationKeys> = {
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<HTMLDivElement>(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<HTMLElement>('[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 = (
<div className="flex h-full items-center justify-center" role="status">
<Spinner className="text-text-secondary" />
</div>
);
} else if (isError) {
panelBody = (
<div className="rounded-lg border border-status-error-border bg-status-error-subtle p-3 text-sm text-status-error">
{localize('com_ui_subagent_thread_load_error')}
</div>
);
} else if (data?.messages.length === 0) {
panelBody = (
<div className="rounded-lg border border-border-light bg-surface-secondary p-3 text-sm text-text-secondary">
{localize('com_ui_subagent_thread_empty')}
</div>
);
} else {
panelBody = (
<ol className="relative space-y-4 before:absolute before:bottom-3 before:left-[0.4375rem] before:top-3 before:w-px before:bg-border-medium">
{data?.historyTruncated === true && (
<li className="relative pl-7 text-xs text-text-secondary">
<span className="absolute left-1 top-1 h-2 w-2 rounded-full bg-border-heavy" />
{localize('com_ui_subagent_thread_history_truncated')}
</li>
)}
{data?.messages.map((message) => (
<li key={message.messageId} className="relative pl-7">
<span
className={cn(
'absolute left-0 top-1.5 flex h-3.5 w-3.5 items-center justify-center rounded-full ring-4 ring-surface-primary',
message.role === 'user' ? 'bg-status-info' : 'bg-status-success',
)}
aria-hidden="true"
/>
<article className="rounded-lg border border-border-light bg-surface-secondary px-3 py-2.5">
<div className="mb-1 text-xs font-medium text-text-secondary">
{message.role === 'user'
? localize('com_ui_subagent_thread_task')
: localize('com_ui_subagent_thread_response')}
</div>
<div className="prose-sm max-w-none break-words text-sm text-text-primary">
<MarkdownLite content={message.text} codeExecution={false} />
</div>
{message.textTruncated === true && (
<div className="mt-2 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_message_truncated')}
</div>
)}
</article>
</li>
))}
</ol>
);
}
return (
<aside
ref={panelRef}
role={isMobile ? 'dialog' : 'region'}
aria-modal={isMobile || undefined}
aria-label={localize('com_ui_subagent_thread_panel')}
className="flex h-full w-full flex-col overflow-hidden bg-surface-primary text-text-primary"
>
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b border-border-light px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-tertiary">
<Bot size={17} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold" title={title}>
{title}
</h2>
<div
className={cn(
'mt-0.5 flex items-center gap-1 text-xs text-text-secondary',
status === 'failed' || status === 'interrupted' ? 'text-status-error' : '',
)}
aria-live="polite"
>
<StatusIcon size={13} aria-hidden="true" />
<span>{localize(statusLabels[status])}</span>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={close}
aria-label={localize('com_ui_close')}
className="h-8 w-8 shrink-0"
>
<X size={17} aria-hidden="true" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">{panelBody}</div>
</aside>
);
}

View file

@ -0,0 +1 @@
export { default as SubagentThreadPanel } from './SubagentThreadPanel';

View file

@ -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: () => <span data-testid="left-icon" />,
ChevronRight: () => <span data-testid="right-icon" />,
}));
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(<SubagentThreadLink threadId="parent-thread" relation="parent" />);
renderLink(<SubagentThreadLink threadId="parent-thread" />);
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(<SubagentThreadLink threadId="child/thread" relation="child" />);
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(
<SubagentThreadLink threadId="provisional-child" relation="child" />,
);
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(<SubagentThreadLink threadId="failed-child" relation="child" />);
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(<SubagentThreadLink threadId=" " relation="child" />);
const { container } = renderLink(<SubagentThreadLink threadId=" " />);
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(<SubagentThreadLink threadId="child-thread" relation="child" />);
fireEvent.click(screen.getByRole('button', { name: 'Open child chat' }));
expect(mockNavigateToConvo).toHaveBeenCalledWith(child);
});
});

View file

@ -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 && (
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
)}
<ResizablePanel
@ -48,7 +48,7 @@ const ArtifactsPanel = memo(function ArtifactsPanel({
panelRef={artifactsPanelRef}
id="artifacts-panel"
>
<div className="h-full min-w-[400px] overflow-hidden">{artifacts}</div>
<div className="h-full min-w-[400px] overflow-hidden">{panel}</div>
</ResizablePanel>
</>
);

View file

@ -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 && (
<ArtifactsPanel
artifacts={artifacts}
panel={panel}
minSizeMain={minSizeMain}
shouldRender={shouldRenderArtifacts}
onRenderChange={setShouldRenderArtifacts}
shouldRender={shouldRenderPanel}
onRenderChange={setShouldRenderPanel}
/>
)}
</ResizablePanelGroup>
{artifacts != null && isSmallScreen && (
<div className="fixed inset-0 z-[100]">{artifacts}</div>
)}
{panel != null && isSmallScreen && <div className="fixed inset-0 z-[100]">{panel}</div>}
</>
);
});

View file

@ -0,0 +1 @@
export * from './queries';

View file

@ -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);
});
});

View file

@ -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<SubagentThreadView> & {
isReadinessPending: boolean;
};
export const useSubagentThreadQuery = (
parentConversationId: string,
threadId: string,
taskId: string,
config?: UseQueryOptions<SubagentThreadView>,
): 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<SubagentThreadView>(
[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),
};
};

View file

@ -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';

View file

@ -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",

View file

@ -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<ActiveSubagentPanel | null>({
key: 'activeSubagentPanel',
default: null,
});
/** Progress state keyed by parent tool_call_id. */
export const subagentProgressByToolCallId = atomFamily<SubagentProgress | null, string>({
key: 'subagentProgressByToolCallId',