mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🛰️ 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:
parent
634432b2ae
commit
876a087558
18 changed files with 768 additions and 200 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
165
client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
Normal file
165
client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
client/src/components/Chat/Subagents/index.ts
Normal file
1
client/src/components/Chat/Subagents/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as SubagentThreadPanel } from './SubagentThreadPanel';
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
1
client/src/data-provider/Subagents/index.ts
Normal file
1
client/src/data-provider/Subagents/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './queries';
|
||||
71
client/src/data-provider/Subagents/queries.test.ts
Normal file
71
client/src/data-provider/Subagents/queries.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
89
client/src/data-provider/Subagents/queries.ts
Normal file
89
client/src/data-provider/Subagents/queries.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue