mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🖼️ fix: Restore Shared Subagent Activity as a Read-Only View (#15108)
This commit is contained in:
parent
199de92c51
commit
08c9cc3d3d
16 changed files with 470 additions and 28 deletions
|
|
@ -14,7 +14,9 @@ import store, {
|
|||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
} from '~/store';
|
||||
import { adaptLivePersistedActivity } from '~/components/Chat/Subagents/adapters';
|
||||
import { MessageContext } from '~/Providers/MessageContext';
|
||||
import { useShareContext } from '~/Providers/ShareContext';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { parseSubagentBackgroundHandle } from './handle';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
|
@ -166,6 +168,7 @@ export default function SubagentCall({
|
|||
hideAttachments = false,
|
||||
}: SubagentCallProps) {
|
||||
const localize = useLocalize();
|
||||
const { isSharedConvo, shareId } = useShareContext();
|
||||
const parentMessageContext = useContext(MessageContext);
|
||||
const parentMessageId = parentMessageContext.messageId?.trim() ?? '';
|
||||
const partIndex = parentMessageContext.partIndex ?? 0;
|
||||
|
|
@ -181,7 +184,8 @@ export default function SubagentCall({
|
|||
[output, args],
|
||||
);
|
||||
const parentConversationId = parentMessageContext.conversationId?.trim() ?? '';
|
||||
const canOpenDurablePanel = backgroundHandle != null && parentConversationId !== '';
|
||||
const canOpenDurablePanel =
|
||||
isSharedConvo !== true && backgroundHandle != null && parentConversationId !== '';
|
||||
|
||||
const subagentType = progress?.subagentType ?? extractSubagentType(args);
|
||||
const isSelfSpawn = subagentType === 'self';
|
||||
|
|
@ -277,8 +281,26 @@ export default function SubagentCall({
|
|||
* the name isn't resolvable (agent map miss). */
|
||||
const subagentNameLabel = !isSelfSpawn && subagentAgent?.name ? subagentAgent.name : '';
|
||||
|
||||
const canOpenDetails = useMemo(
|
||||
() =>
|
||||
isSharedConvo !== true ||
|
||||
adaptLivePersistedActivity({
|
||||
title: '',
|
||||
progress: null,
|
||||
persistedContent,
|
||||
legacyOutput: backgroundHandle == null ? output : undefined,
|
||||
initialProgress,
|
||||
isSubmitting: false,
|
||||
runStepStatus,
|
||||
approvalVisibility: 'hidden',
|
||||
}).items.length > 0,
|
||||
[backgroundHandle, initialProgress, isSharedConvo, output, persistedContent, runStepStatus],
|
||||
);
|
||||
|
||||
const panelSelection = useMemo(
|
||||
() => ({
|
||||
host: isSharedConvo === true ? ('share' as const) : ('conversation' as const),
|
||||
...(isSharedConvo === true && shareId != null ? { shareId } : {}),
|
||||
parentConversationId,
|
||||
parentMessageId,
|
||||
toolCallId,
|
||||
|
|
@ -303,6 +325,7 @@ export default function SubagentCall({
|
|||
backgroundHandle,
|
||||
canOpenDurablePanel,
|
||||
initialProgress,
|
||||
isSharedConvo,
|
||||
isSubmitting,
|
||||
output,
|
||||
parentConversationId,
|
||||
|
|
@ -311,6 +334,7 @@ export default function SubagentCall({
|
|||
persistedContent,
|
||||
prompt,
|
||||
runStepStatus,
|
||||
shareId,
|
||||
subagentType,
|
||||
toolCallId,
|
||||
],
|
||||
|
|
@ -327,16 +351,24 @@ export default function SubagentCall({
|
|||
}, [panelSelection, parentMessageId, partIndex, setSelectedSubagent, toolCallId]);
|
||||
|
||||
const openDetails = useCallback(() => {
|
||||
if (!canOpenDetails) return;
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
setSelectedSubagent(panelSelection);
|
||||
}, [panelSelection, resetCurrentArtifactId, setArtifactsVisible, setSelectedSubagent]);
|
||||
}, [
|
||||
canOpenDetails,
|
||||
panelSelection,
|
||||
resetCurrentArtifactId,
|
||||
setArtifactsVisible,
|
||||
setSelectedSubagent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDetails}
|
||||
disabled={!canOpenDetails}
|
||||
data-subagent-thread={
|
||||
canOpenDurablePanel ? backgroundHandle?.subagent_thread_id : undefined
|
||||
}
|
||||
|
|
@ -344,7 +376,8 @@ export default function SubagentCall({
|
|||
data-subagent-parent-message={parentMessageId}
|
||||
data-subagent-part-index={partIndex}
|
||||
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',
|
||||
'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',
|
||||
canOpenDetails ? 'group hover:bg-surface-tertiary' : 'cursor-default opacity-80',
|
||||
running && !detachedStatusUnknown && 'animate-pulse-slow',
|
||||
)}
|
||||
aria-label={headerText}
|
||||
|
|
@ -384,11 +417,13 @@ export default function SubagentCall({
|
|||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="shrink-0 text-text-secondary transition group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{canOpenDetails && (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="shrink-0 text-text-secondary transition group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="w-full space-y-0.5 pl-5 font-mono text-xs text-text-secondary">
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ const OpenSubagentPanel = () => {
|
|||
const open = () => {
|
||||
setConversation({ conversationId: 'parent-conversation' } as TConversation);
|
||||
setSelection({
|
||||
host: 'conversation',
|
||||
parentConversationId: 'parent-conversation',
|
||||
parentMessageId: 'parent-message',
|
||||
toolCallId: 'tool-call',
|
||||
|
|
|
|||
|
|
@ -96,7 +96,11 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
|||
}, [artifactsElement, resetSelectedSubagent, selectedSubagent]);
|
||||
|
||||
const subagentElement = useMemo(() => {
|
||||
if (selectedSubagent == null || selectedSubagent.parentConversationId !== conversationId) {
|
||||
if (
|
||||
selectedSubagent == null ||
|
||||
selectedSubagent.host !== 'conversation' ||
|
||||
selectedSubagent.parentConversationId !== conversationId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import SubagentCall from '~/components/Chat/Messages/Content/Parts/SubagentCall';
|
||||
import SharedSubagentActivityDialog from './SharedSubagentActivityDialog';
|
||||
import { MessageContext } from '~/Providers/MessageContext';
|
||||
import { ShareContext } from '~/Providers/ShareContext';
|
||||
|
||||
const mockUseSubagentThreadQuery = jest.fn();
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useSubagentThreadQuery: (...args: unknown[]) => mockUseSubagentThreadQuery(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize:
|
||||
() =>
|
||||
(key: string, values?: Record<number, string>): string => {
|
||||
if (key === 'com_ui_subagent_dialog_title') return `Agent ${values?.[0] ?? ''}`;
|
||||
if (key === 'com_ui_subagent_complete') return 'Ran agent';
|
||||
if (key === 'com_ui_subagent_activity') return 'Agent activity';
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({ useAgentsMapContext: () => ({}) }));
|
||||
jest.mock('~/components/Share/MessageIcon', () => ({ __esModule: true, default: () => null }));
|
||||
jest.mock('~/hooks/MCP', () => ({ useMCPServerNames: () => [] }));
|
||||
|
||||
jest.mock('./SubagentActivity', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
activity,
|
||||
}: {
|
||||
activity: { title: string; items: Array<{ type: string; text?: string }> };
|
||||
}) => (
|
||||
<div data-testid="shared-subagent-activity">
|
||||
<span>{activity.title}</span>
|
||||
{activity.items.map((item, index) => (
|
||||
<span key={index}>{item.text ?? item.type}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const persistedContent = (text: string): TMessageContentParts[] => [
|
||||
{ type: ContentTypes.TEXT, text } as TMessageContentParts,
|
||||
];
|
||||
|
||||
const detachedOutput = JSON.stringify({
|
||||
background_task_id: 'task-1',
|
||||
subagent_thread_id: 'thread-1',
|
||||
tool: 'subagent',
|
||||
subagent_type: 'researcher',
|
||||
status: 'running',
|
||||
message:
|
||||
'Started subagent "researcher" background task. Poll the host background-task tool with background_task_id "task-1".',
|
||||
});
|
||||
|
||||
function renderSharedCall(input: {
|
||||
output?: string;
|
||||
persistedContent?: TMessageContentParts[];
|
||||
detached?: boolean;
|
||||
}) {
|
||||
return render(
|
||||
<RecoilRoot>
|
||||
<ShareContext.Provider value={{ isSharedConvo: true, shareId: 'share-1' }}>
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
conversationId: 'shared-conversation',
|
||||
messageId: 'shared-parent',
|
||||
isExpanded: false,
|
||||
}}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId="shared-call"
|
||||
initialProgress={1}
|
||||
args={{
|
||||
subagent_type: 'researcher',
|
||||
description: 'Review the release.',
|
||||
run_in_background: input.detached === true,
|
||||
}}
|
||||
output={input.output}
|
||||
persistedContent={input.persistedContent}
|
||||
/>
|
||||
<SharedSubagentActivityDialog shareId="share-1" />
|
||||
</MessageContext.Provider>
|
||||
</ShareContext.Provider>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('SharedSubagentActivityDialog', () => {
|
||||
beforeEach(() => mockUseSubagentThreadQuery.mockClear());
|
||||
|
||||
it('opens readable foreground activity from the shared message payload and restores focus', async () => {
|
||||
renderSharedCall({
|
||||
output: 'Legacy fallback.',
|
||||
persistedContent: persistedContent('Shared review complete.'),
|
||||
});
|
||||
const trigger = screen.getByRole('button', { name: 'Ran agent' });
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shared review complete.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it('renders detached persisted activity without performing the private durable query', () => {
|
||||
renderSharedCall({
|
||||
output: detachedOutput,
|
||||
persistedContent: persistedContent('Detached work survived refresh.'),
|
||||
detached: true,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Agent activity' }));
|
||||
|
||||
expect(screen.getByText('Detached work survived refresh.')).toBeInTheDocument();
|
||||
expect(mockUseSubagentThreadQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes a detached shared card without persisted activity explicitly noninteractive', () => {
|
||||
renderSharedCall({ output: detachedOutput, detached: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Agent activity' })).toBeDisabled();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(mockUseSubagentThreadQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import { adaptLivePersistedActivity } from './adapters';
|
||||
import SubagentActivity from './SubagentActivity';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/** Public-share fallback for subagent activity already embedded in the shared message payload. */
|
||||
export default function SharedSubagentActivityDialog({ shareId }: { shareId?: string }) {
|
||||
const localize = useLocalize();
|
||||
const selected = useRecoilValue(activeSubagentPanel);
|
||||
const resetSelection = useResetRecoilState(activeSubagentPanel);
|
||||
const selection = selected?.host === 'share' && selected.shareId === shareId ? selected : null;
|
||||
const restoreSelectionRef = useRef(selection);
|
||||
if (selection != null) restoreSelectionRef.current = selection;
|
||||
const title =
|
||||
selection?.subagentType === 'self'
|
||||
? localize('com_ui_subagent_dialog_title_self')
|
||||
: localize('com_ui_subagent_dialog_title', { 0: selection?.subagentType ?? '' });
|
||||
const activity = useMemo(
|
||||
() =>
|
||||
adaptLivePersistedActivity({
|
||||
title,
|
||||
prompt: selection?.prompt,
|
||||
progress: null,
|
||||
persistedContent: selection?.persistedContent,
|
||||
legacyOutput: selection?.legacyOutput,
|
||||
initialProgress: selection?.initialProgress ?? 1,
|
||||
isSubmitting: false,
|
||||
runStepStatus: selection?.runStepStatus,
|
||||
approvalVisibility: 'hidden',
|
||||
}),
|
||||
[selection, title],
|
||||
);
|
||||
|
||||
const restoreTriggerFocus = useCallback((event: Event) => {
|
||||
event.preventDefault();
|
||||
const selectionToRestore = restoreSelectionRef.current;
|
||||
if (selectionToRestore == null) return;
|
||||
requestAnimationFrame(() => {
|
||||
const trigger = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-subagent-tool-call]'),
|
||||
).find(
|
||||
(element) =>
|
||||
element.dataset.subagentToolCall === selectionToRestore.toolCallId &&
|
||||
element.dataset.subagentParentMessage === selectionToRestore.parentMessageId &&
|
||||
element.dataset.subagentPartIndex === String(selectionToRestore.partIndex),
|
||||
);
|
||||
trigger?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => resetSelection(), [resetSelection]);
|
||||
useEffect(() => {
|
||||
if (selected?.host === 'share' && selected.shareId !== shareId) resetSelection();
|
||||
}, [resetSelection, selected, shareId]);
|
||||
|
||||
return (
|
||||
<OGDialog open={selection != null} onOpenChange={(open) => !open && resetSelection()}>
|
||||
<OGDialogContent
|
||||
className="flex h-[min(90vh,48rem)] w-11/12 max-w-3xl flex-col gap-0 overflow-hidden p-0"
|
||||
onCloseAutoFocus={restoreTriggerFocus}
|
||||
>
|
||||
<OGDialogHeader className="shrink-0 border-b border-border-light px-5 py-4 pr-14">
|
||||
<OGDialogTitle className="truncate text-left text-base" title={activity.title}>
|
||||
{activity.title}
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<SubagentActivity activity={activity} />
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -78,6 +78,7 @@ jest.mock('lucide-react', () => ({
|
|||
}));
|
||||
|
||||
const selection: ActiveSubagentPanel = {
|
||||
host: 'conversation',
|
||||
parentConversationId: 'parent-conversation',
|
||||
parentMessageId: 'parent-message',
|
||||
toolCallId: 'tool-call',
|
||||
|
|
@ -179,6 +180,7 @@ describe('SubagentThreadPanel', () => {
|
|||
isReadinessPending: false,
|
||||
});
|
||||
const foreground: ActiveSubagentPanel = {
|
||||
host: 'conversation',
|
||||
parentConversationId: 'parent-conversation',
|
||||
parentMessageId: 'parent-message',
|
||||
toolCallId: 'foreground-call',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,34 @@ describe('child activity adapters', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps shared-message activity read-only by omitting approval controls', () => {
|
||||
const activity = adaptLivePersistedActivity({
|
||||
title: 'researcher',
|
||||
progress: null,
|
||||
persistedContent: [
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: {
|
||||
id: 'tool',
|
||||
name: 'protected_tool',
|
||||
args: '{}',
|
||||
output: '',
|
||||
progress: 0.1,
|
||||
approval: { expires_at: 123 },
|
||||
},
|
||||
},
|
||||
] as unknown as TMessageContentParts[],
|
||||
initialProgress: 1,
|
||||
isSubmitting: false,
|
||||
approvalVisibility: 'hidden',
|
||||
});
|
||||
|
||||
expect(activity.items[0]).toEqual(
|
||||
expect.objectContaining({ type: 'tool', name: 'protected_tool' }),
|
||||
);
|
||||
expect(activity.items[0]).not.toHaveProperty('approval');
|
||||
});
|
||||
|
||||
it('uses the exact assistant row as terminal authority for an older API response', () => {
|
||||
const oldView = {
|
||||
threadId: 'thread',
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ type ContentToolCall = {
|
|||
const contentPartsToActivity = (
|
||||
parts: TMessageContentParts[],
|
||||
reasoningVisibility: 'visible' | 'marker',
|
||||
approvalVisibility: 'visible' | 'hidden',
|
||||
): ChildActivityItem[] =>
|
||||
parts.flatMap((part, index): ChildActivityItem[] => {
|
||||
if (part.type === ContentTypes.TEXT) {
|
||||
|
|
@ -85,7 +86,9 @@ const contentPartsToActivity = (
|
|||
...(tool.args == null ? {} : { input: tool.args }),
|
||||
...(tool.output == null ? {} : { output: tool.output }),
|
||||
status: runStepStatus ?? (completed ? 'completed' : 'running'),
|
||||
...(tool.approval == null ? {} : { approval: tool.approval }),
|
||||
...(tool.approval == null || approvalVisibility === 'hidden'
|
||||
? {}
|
||||
: { approval: tool.approval }),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
@ -129,11 +132,16 @@ export function adaptLivePersistedActivity(input: {
|
|||
isSubmitting: boolean;
|
||||
runStepStatus?: PartMetadata['runStepStatus'];
|
||||
reasoningVisibility?: 'visible' | 'marker';
|
||||
approvalVisibility?: 'visible' | 'hidden';
|
||||
}): ChildActivity {
|
||||
const persisted = input.persistedContent ?? [];
|
||||
const live = (input.progress?.contentParts ?? []) as TMessageContentParts[];
|
||||
const parts = persisted.length > 0 ? persisted : live;
|
||||
const items = contentPartsToActivity(parts, input.reasoningVisibility ?? 'visible');
|
||||
const items = contentPartsToActivity(
|
||||
parts,
|
||||
input.reasoningVisibility ?? 'visible',
|
||||
input.approvalVisibility ?? 'visible',
|
||||
);
|
||||
if (items.length === 0 && input.legacyOutput != null && input.legacyOutput !== '') {
|
||||
items.push({ type: 'writing', text: input.legacyOutput });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
TooltipAnchor,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import SharedSubagentActivityDialog from '~/components/Chat/Subagents/SharedSubagentActivityDialog';
|
||||
import { cn, DEFAULT_APP_TITLE, getResponseStatus, selectActiveBranchTail } from '~/utils';
|
||||
import { ThemeSelector, LangSelector } from '~/components/Appearance';
|
||||
import { ShareMessagesProvider } from './ShareMessagesProvider';
|
||||
|
|
@ -239,6 +240,7 @@ function SharedView() {
|
|||
{artifactsContainer}
|
||||
</main>
|
||||
</div>
|
||||
<SharedSubagentActivityDialog shareId={shareId} />
|
||||
</ShareContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface SubagentProgress {
|
|||
|
||||
/** One child invocation selected for the shared read-only activity panel. */
|
||||
export type ActiveSubagentPanel = {
|
||||
host: 'conversation' | 'share';
|
||||
shareId?: string;
|
||||
parentConversationId: string;
|
||||
parentMessageId: string;
|
||||
toolCallId: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue