🖼️ fix: Restore Shared Subagent Activity as a Read-Only View (#15108)

This commit is contained in:
Danny Avila 2026-08-21 20:58:14 -04:00 committed by GitHub
parent 199de92c51
commit 08c9cc3d3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 470 additions and 28 deletions

View file

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

View file

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

View file

@ -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 (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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