From c7e8b45419eae773df5bf3c6bf6f32218aff79a1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 02:24:30 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=B6=20refactor:=20Polish=20Event=20Sub?= =?UTF-8?q?agent=20Activity=20(#15152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: polish event subagent activity * chore: satisfy static checks * fix: close subagent activity review gaps * test: satisfy activity selection types * fix: preserve subagent group layout scope * fix: close subagent activity polish gaps * fix: narrow edited activity anchor id * feat: present subagent turns as one thread * fix: keep subagent timeline pinned * fix: render sparse assistant content * fix: retain sparse initial activity cursor * fix: bound continuous subagent history * fix: type timeline prefix * chore: sort timeline imports --- .../Chat/Messages/Content/ContentParts.tsx | 11 +- .../Chat/Messages/Content/ParallelContent.tsx | 14 +- .../Content/__tests__/ContentParts.test.tsx | 35 ++- .../Content/__tests__/ParallelContent.test.ts | 26 ++- .../components/Chat/Messages/MultiMessage.tsx | 29 ++- .../Messages/__tests__/MultiMessage.spec.tsx | 125 +++++++++- .../Chat/Messages/ui/MessageRow.tsx | 19 +- .../EventSubagentActivityGroup.test.tsx | 121 +++++++++- .../Subagents/EventSubagentActivityGroup.tsx | 150 +++++++++--- .../Chat/Subagents/SubagentActivity.test.tsx | 56 ++++- .../Chat/Subagents/SubagentActivity.tsx | 171 ++++++++------ .../Subagents/SubagentThreadPanel.test.tsx | 149 +++++++++++- .../Chat/Subagents/SubagentThreadPanel.tsx | 221 ++++++++++++++---- .../Chat/Subagents/eventSelection.ts | 7 +- client/src/locales/en/translation.json | 16 +- client/src/store/subagents.ts | 2 + .../utils/__tests__/activityLabels.spec.ts | 22 ++ client/src/utils/activityLabels.ts | 26 +++ 18 files changed, 1002 insertions(+), 198 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 6c4a45b533..2324486d00 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -10,7 +10,7 @@ import type { ReactNode, ReactElement } from 'react'; import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils'; import WorkspaceChanges, { partitionWorkspaceChanges } from './Parts/WorkspaceChanges'; -import { groupActivityPhases, lastVisibleContentIdx } from '~/utils/activityLabels'; +import { groupActivityPhases, lastCursorContentIdx } from '~/utils/activityLabels'; import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; import MemoryArtifacts, { hasMemoryArtifacts } from './MemoryArtifacts'; import { MessageContext, SearchContext } from '~/Providers'; @@ -497,7 +497,7 @@ const ContentPartsBody = memo(function ContentPartsBody({ } if (phaseSegments != null) { - const relativeGlobalLastContentIdx = lastVisibleContentIdx(content ?? []); + const relativeGlobalLastContentIdx = lastCursorContentIdx(content ?? []); const globalLastContentIdx = relativeGlobalLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeGlobalLastContentIdx); const renderSegment = ( @@ -586,10 +586,9 @@ const ContentPartsBody = memo(function ContentPartsBody({ * empty TEXT after real parts keeps its flush in-flow cursor. */ const solitaryEmptyText = safeContent.length === 1 && isEmptyTextPart(safeContent[0]); const showEmptyCursor = (safeContent.length === 0 || solitaryEmptyText) && effectiveIsSubmitting; - /** Skips trailing BLANK label reservations — they render nothing, and - * counting one as last would strip the streaming cursor from the last - * VISIBLE part until the next delta. */ - const relativeLastContentIdx = lastVisibleContentIdx(safeContent); + /** Skips trailing blank label reservations and empty provider placeholders, + * keeping the cursor attached to the last visible output. */ + const relativeLastContentIdx = lastCursorContentIdx(safeContent); const lastContentIdx = relativeLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeLastContentIdx); // Parallel content: use dedicated renderer with columns (TMessageContentParts includes ContentMetadata) diff --git a/client/src/components/Chat/Messages/Content/ParallelContent.tsx b/client/src/components/Chat/Messages/Content/ParallelContent.tsx index 6b3dbbbbf5..6348e25870 100644 --- a/client/src/components/Chat/Messages/Content/ParallelContent.tsx +++ b/client/src/components/Chat/Messages/Content/ParallelContent.tsx @@ -4,7 +4,7 @@ import type { TMessageContentParts, SearchResultData, TAttachment } from 'librec import { getActivityLabelPart, getActivityLabelText, - lastVisibleContentIdx, + lastCursorContentIdx, } from '~/utils/activityLabels'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; @@ -179,6 +179,7 @@ export const ParallelColumns = memo(function ParallelColumns({ part?.type !== ContentTypes.ACTIVITY_LABEL || getActivityLabelText(getActivityLabelPart(part)).length > 0, ); + const lastColumnCursorIdx = lastParallelColumnCursorIdx(columnParts); // Show loading cursor if column has no content parts yet (empty array from placeholder) const showLoadingCursor = isSubmitting && columnParts.length === 0; @@ -200,7 +201,7 @@ export const ParallelColumns = memo(function ParallelColumns({ ) : ( columnParts.map(({ part, idx }) => { - const isLastInColumn = idx === columnParts[columnParts.length - 1]?.idx; + const isLastInColumn = idx === lastColumnCursorIdx; const isLastContent = idx === lastContentIdx; return renderPart(part, idx, isLastInColumn && isLastContent); }) @@ -212,6 +213,13 @@ export const ParallelColumns = memo(function ParallelColumns({ ); }); +export function lastParallelColumnCursorIdx( + parts: ReadonlyArray<{ part: TMessageContentParts; idx: number }>, +): number { + const relativeIdx = lastCursorContentIdx(parts.map(({ part }) => part)); + return relativeIdx < 0 ? -1 : (parts[relativeIdx]?.idx ?? -1); +} + type ParallelContentRendererProps = { content?: Array; messageId: string; @@ -261,7 +269,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({ /** Same walk-back as `ContentParts`: a trailing BLANK label reservation is * filtered out of every lane, so counting it as last would leave NO * rendered part with the last-part cursor until the label fills. */ - const relativeLastContentIdx = lastVisibleContentIdx(content); + const relativeLastContentIdx = lastCursorContentIdx(content); const lastContentIdx = relativeLastContentIdx < 0 ? -1 diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index f4e1fbc3bc..33191b8ff9 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -115,8 +115,20 @@ jest.mock('../Container', () => ({ jest.mock('../Part', () => ({ __esModule: true, - default: ({ part, idx }: { part: TMessageContentParts; idx: number }) => ( -
+ default: ({ + part, + idx, + showCursor, + }: { + part: TMessageContentParts; + idx: number; + showCursor?: boolean; + }) => ( +
), })); @@ -453,6 +465,25 @@ describe('ContentParts — post-steer author re-attribution', () => { }); describe('ContentParts — activity phase state', () => { + it('keeps a streaming cursor on visible text when a provider appends an empty placeholder', () => { + render( + , + ); + + const textParts = screen.getAllByTestId(`real-part-${ContentTypes.TEXT}`); + expect(textParts[0]).toHaveAttribute('data-show-cursor', 'true'); + expect(textParts[1]).toHaveAttribute('data-show-cursor', 'false'); + }); + it('renders a completion-appended parent before the final root text', () => { const tool = { type: ContentTypes.TOOL_CALL, diff --git a/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts b/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts index 1633d7a55d..ec1b821bfb 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts +++ b/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts @@ -1,6 +1,6 @@ import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts } from 'librechat-data-provider'; -import { groupParallelContent } from '../ParallelContent'; +import { groupParallelContent, lastParallelColumnCursorIdx } from '../ParallelContent'; describe('groupParallelContent', () => { test('reports absolute indices for a dense phase segment', () => { @@ -41,3 +41,27 @@ describe('groupParallelContent', () => { ]); }); }); + +describe('lastParallelColumnCursorIdx', () => { + test('keeps the lane cursor on visible output before an empty placeholder', () => { + const visible = { + type: ContentTypes.TEXT, + text: 'Visible answer', + groupId: 1, + agentId: 'agent-1', + } as unknown as TMessageContentParts; + const empty = { + type: ContentTypes.TEXT, + text: '', + groupId: 1, + agentId: 'agent-1', + } as unknown as TMessageContentParts; + + expect( + lastParallelColumnCursorIdx([ + { part: visible, idx: 7 }, + { part: empty, idx: 8 }, + ]), + ).toBe(7); + }); +}); diff --git a/client/src/components/Chat/Messages/MultiMessage.tsx b/client/src/components/Chat/Messages/MultiMessage.tsx index 20525561e1..5af59b7fa9 100644 --- a/client/src/components/Chat/Messages/MultiMessage.tsx +++ b/client/src/components/Chat/Messages/MultiMessage.tsx @@ -185,6 +185,22 @@ function MultiMessage({ } else { row = ; } + /** Event children may be persisted against the user request that launched + * the Director. Once its assistant response exists, present that activity + * after the response instead of interrupting the turn between user and + * assistant rows. Exact assistant-owned children remain in the same group. */ + let activityParentMessageIds: string[] = []; + if (message.isCreatedByUser) { + if (!message.children?.length) activityParentMessageIds = [message.messageId]; + } else { + activityParentMessageIds = [message.messageId, message.parentMessageId].filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ); + } + const isEditingActivityAnchor = + typeof currentEditId === 'string' && activityParentMessageIds.includes(currentEditId); + const hasParallelContent = + !message.isCreatedByUser && message.content?.some((part) => part?.groupId != null) === true; /** * The child recursion is a sibling of the row (not rendered inside it), so a @@ -196,14 +212,13 @@ function MultiMessage({ return ( <> {row} - {rowMounted && currentEditId !== message.messageId ? ( + {rowMounted && !isEditingActivityAnchor && activityParentMessageIds.length > 0 ? (
-
- -
+
) : null} ({ __esModule: true, default: createRowStub() jest.mock('../Message', () => ({ __esModule: true, default: createRowStub() })); jest.mock('~/components/Chat/Subagents/EventSubagentActivityGroup', () => ({ __esModule: true, - default: ({ parentMessageId }: { parentMessageId: string }) => ( -
+ default: ({ + parentMessageIds, + hasParallelContent, + }: { + parentMessageIds: string[]; + hasParallelContent?: boolean; + }) => ( +
), })); @@ -81,8 +91,8 @@ describe('MultiMessage sibling selection', () => { ); expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( - 'data-parent-message-id', - 'structured', + 'data-parent-message-ids', + 'structured,parent-1', ); view.rerender( @@ -96,8 +106,111 @@ describe('MultiMessage sibling selection', () => { , ); expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( - 'data-parent-message-id', - 'legacy', + 'data-parent-message-ids', + 'legacy,parent-1', + ); + }); + + it('places a user-anchored event group after the assistant response', () => { + const assistant = msg('assistant'); + const user = { + ...msg('user'), + isCreatedByUser: true, + parentMessageId: 'root', + children: [assistant], + } as TMessage; + assistant.parentMessageId = 'user'; + + render( + + + , + ); + + expect(screen.getAllByTestId('event-subagent-activity')).toHaveLength(1); + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-parent-message-ids', + 'assistant,user', + ); + expect( + screen + .getByText('assistant') + .compareDocumentPosition(screen.getByTestId('event-subagent-activity')), + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('hides merged event activity while its user anchor is being edited', () => { + const assistant = { ...msg('assistant'), parentMessageId: 'user' } as TMessage; + const user = { + ...msg('user'), + isCreatedByUser: true, + parentMessageId: 'root', + children: [assistant], + } as TMessage; + + render( + + + , + ); + + expect(screen.queryByTestId('event-subagent-activity')).not.toBeInTheDocument(); + }); + + it('matches the wider layout of a parallel assistant response', () => { + const assistant = { + ...msg('assistant'), + content: [{ type: 'text', text: 'answer', groupId: 'parallel-group' }], + } as unknown as TMessage; + + render( + + + , + ); + + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-has-parallel-content', + 'true', + ); + }); + + it('renders assistant content containing an undefined streaming placeholder', () => { + const assistant = { + ...msg('assistant'), + content: [undefined, { type: 'text', text: 'answer' }], + } as unknown as TMessage; + + render( + + + , + ); + + expect(screen.getByTestId('row')).toHaveTextContent('assistant'); + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-has-parallel-content', + 'false', ); }); diff --git a/client/src/components/Chat/Messages/ui/MessageRow.tsx b/client/src/components/Chat/Messages/ui/MessageRow.tsx index ffa20b37da..0aadb57df8 100644 --- a/client/src/components/Chat/Messages/ui/MessageRow.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRow.tsx @@ -20,6 +20,18 @@ type MessageRowProps = { className?: string; }; +export function getMessageRowWidthClass({ + fullWidth = false, + hasParallelContent = false, +}: { + fullWidth?: boolean; + hasParallelContent?: boolean; +} = {}) { + if (fullWidth) return 'w-full max-w-full sm:px-2'; + if (hasParallelContent) return 'w-full sm:px-2 md:max-w-[58rem] xl:max-w-[70rem]'; + return 'w-full sm:px-2 md:max-w-3xl xl:max-w-4xl'; +} + export default function MessageRow({ id, icon, @@ -38,12 +50,7 @@ export default function MessageRow({ }: MessageRowProps) { // Same column as ChatForm: max-width plus `sm:px-2`, so the body lines // up with the composer surface rather than the form's outer box. - let widthClass = 'w-full sm:px-2 md:max-w-3xl xl:max-w-4xl'; - if (fullWidth) { - widthClass = 'w-full max-w-full sm:px-2'; - } else if (hasParallelContent) { - widthClass = 'w-full sm:px-2 md:max-w-[58rem] xl:max-w-[70rem]'; - } + const widthClass = getMessageRowWidthClass({ fullWidth, hasParallelContent }); return (
(); jest.mock('./ParentSubagentsProvider', () => ({ useParentSubagents: () => ({ - byMessageId: new Map([['parent-message', [mockChild]]]), + byMessageId: mockChildrenByMessage, byThreadId: new Map([['event-thread', mockChild]]), refresh: mockRefresh, }), })); jest.mock('~/Providers', () => ({ - useAgentsMapContext: () => ({ 'agent-1': { id: 'agent-1', name: 'Visible Agent' } }), + useAgentsMapContext: () => ({ + 'agent-1': { id: 'agent-1', name: 'Visible Agent' }, + 'agent-2': { id: 'agent-2', name: 'Completed Agent' }, + }), })); jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key })); @@ -40,11 +54,15 @@ jest.mock('~/utils', () => ({ renderAgentAvatar: () => , })); jest.mock('@librechat/client', () => ({ + Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), cn: (...values: Array) => values.filter(Boolean).join(' '), })); jest.mock('lucide-react', () => ({ AlertCircle: () => null, Bot: () => null, + ChevronDown: () => null, Check: () => null, CheckCircle2: () => null, CircleAlert: () => null, @@ -57,6 +75,7 @@ jest.mock('lucide-react', () => ({ describe('EventSubagentActivityGroup', () => { beforeEach(() => { mockRefresh.mockReset().mockResolvedValue(undefined); + mockChildrenByMessage = new Map([['parent-message', [mockChild]]]); }); it('opens the durable event child under its owning parent message', () => { @@ -70,11 +89,16 @@ describe('EventSubagentActivityGroup', () => { , ); + expect( + screen.getByRole('region', { name: 'com_ui_subagent_activity' }).parentElement, + ).toHaveClass('px-4', 'sm:px-0', 'md:max-w-3xl', 'xl:max-w-4xl'); + expect(screen.queryByRole('button', { name: /Visible Agent/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); expect(mockRefresh).toHaveBeenCalledTimes(1); @@ -88,11 +112,99 @@ describe('EventSubagentActivityGroup', () => { event: { actorId: 'actor-a', progressKey: 'event-task:event-thread:task-1', + siblingParentMessageIds: ['parent-message'], }, }), ); }); + it('matches the width of a parallel assistant response', () => { + render( + + + , + ); + + expect( + screen.getByRole('region', { name: 'com_ui_subagent_activity' }).parentElement, + ).toHaveClass('md:max-w-[58rem]', 'xl:max-w-[70rem]'); + }); + + it('retains a merged anchor that has no children yet', () => { + let selection: ActiveSubagentPanel | null = null; + const Observer = () => { + selection = useRecoilValue(activeSubagentPanel); + return null; + }; + + render( + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); + fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); + + expect((selection as ActiveSubagentPanel | null)?.event?.siblingParentMessageIds).toEqual([ + 'parent-message', + 'empty-assistant-message', + ]); + }); + + it('preserves every merged message anchor and uses explicit plural status labels', () => { + mockChildrenByMessage = new Map([ + ['parent-message', [mockChild]], + [ + 'assistant-message', + [ + mockCompletedChild, + { + ...mockCompletedChild, + threadId: 'event-thread-3', + actorId: 'actor-c', + agentId: undefined, + title: 'Third actor', + }, + ], + ], + ]); + let selection: ActiveSubagentPanel | null = null; + const Observer = () => { + selection = useRecoilValue(activeSubagentPanel); + return null; + }; + + render( + + + + , + ); + + const summary = screen.getByRole('button', { name: /com_ui_subagent_activity/ }); + expect(summary).toHaveAccessibleName(/com_ui_subagent_count_running_one/); + expect(summary).toHaveAccessibleName(/com_ui_subagent_count_completed_other/); + fireEvent.click(summary); + fireEvent.click(screen.getByRole('button', { name: /Completed Agent/ })); + + expect((selection as ActiveSubagentPanel | null)?.event?.siblingParentMessageIds).toEqual([ + 'parent-message', + 'assistant-message', + ]); + }); + it('does not reopen a child after the user closes it while refresh is pending', async () => { let selection: ActiveSubagentPanel | null = null; let resolveRefresh!: (value: unknown) => void; @@ -115,11 +227,12 @@ describe('EventSubagentActivityGroup', () => { , ); + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); expect(selection).toEqual( expect.objectContaining({ durable: expect.objectContaining({ taskId: 'task-1' }) }), diff --git a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx index 40b1ded56e..62e82344bb 100644 --- a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx +++ b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx @@ -1,8 +1,9 @@ -import { useCallback } from 'react'; -import { Bot } from 'lucide-react'; -import { cn } from '@librechat/client'; -import { useResetRecoilState, useSetRecoilState } from 'recoil'; +import { useCallback, useId, useMemo, useState } from 'react'; +import { Button, cn } from '@librechat/client'; +import { Bot, ChevronDown } from 'lucide-react'; +import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; import type { ParentSubagentSummary } from 'librechat-data-provider'; +import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow'; import { subagentStatusIcon, subagentStatusLabelKey } from './status'; import { useParentSubagents } from './ParentSubagentsProvider'; import { eventSubagentSelection } from './eventSelection'; @@ -12,33 +13,83 @@ import { renderAgentAvatar } from '~/utils'; import { useLocalize } from '~/hooks'; import store from '~/store'; +const STATUS_COUNT_LABEL_KEYS = { + dispatched: { + one: 'com_ui_subagent_count_dispatched_one', + other: 'com_ui_subagent_count_dispatched_other', + }, + running: { + one: 'com_ui_subagent_count_running_one', + other: 'com_ui_subagent_count_running_other', + }, + completed: { + one: 'com_ui_subagent_count_completed_one', + other: 'com_ui_subagent_count_completed_other', + }, + failed: { + one: 'com_ui_subagent_count_failed_one', + other: 'com_ui_subagent_count_failed_other', + }, + interrupted: { + one: 'com_ui_subagent_count_interrupted_one', + other: 'com_ui_subagent_count_interrupted_other', + }, + cancelled: { + one: 'com_ui_subagent_count_cancelled_one', + other: 'com_ui_subagent_count_cancelled_other', + }, +} as const; + export default function EventSubagentActivityGroup({ conversationId, - parentMessageId, + parentMessageIds, + hasParallelContent = false, }: { conversationId: string; - parentMessageId: string; + parentMessageIds: string[]; + hasParallelContent?: boolean; }) { const { byMessageId } = useParentSubagents(); - const children = byMessageId.get(parentMessageId) ?? []; + const children = useMemo(() => { + const seen = new Set(); + return parentMessageIds + .flatMap((messageId) => byMessageId.get(messageId) ?? []) + .filter((child) => { + if (seen.has(child.threadId)) return false; + seen.add(child.threadId); + return true; + }); + }, [byMessageId, parentMessageIds]); + const fullWidth = useRecoilValue(store.maximizeChatSpace); + const siblingParentMessageIds = useMemo( + () => Array.from(new Set(parentMessageIds)), + [parentMessageIds], + ); if (children.length === 0) return null; return ( - +
+ +
); } function EventSubagentRows({ conversationId, - parentMessageId, eventChildren, + siblingParentMessageIds, }: { conversationId: string; - parentMessageId: string; eventChildren: ParentSubagentSummary[]; + siblingParentMessageIds: string[]; }) { const localize = useLocalize(); const agentsMap = useAgentsMapContext(); @@ -46,9 +97,27 @@ function EventSubagentRows({ const setSelected = useSetRecoilState(activeSubagentPanel); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const [expanded, setExpanded] = useState(false); + const panelId = useId(); + const counts = useMemo(() => { + const result = new Map(); + eventChildren.forEach((child) => result.set(child.status, (result.get(child.status) ?? 0) + 1)); + return result; + }, [eventChildren]); + const summary = [ + localize( + eventChildren.length === 1 ? 'com_ui_subagent_agent_count' : 'com_ui_subagent_agents_count', + { 0: String(eventChildren.length) }, + ), + ...Array.from(counts.entries()).map(([status, count]) => + localize(STATUS_COUNT_LABEL_KEYS[status][count === 1 ? 'one' : 'other'], { + 0: String(count), + }), + ), + ].join(' · '); const openChild = useCallback( (child: ParentSubagentSummary) => { - const selection = eventSubagentSelection(conversationId, child); + const selection = eventSubagentSelection(conversationId, child, siblingParentMessageIds); if (selection == null) return; resetCurrentArtifactId(); setArtifactsVisible(false); @@ -56,7 +125,11 @@ function EventSubagentRows({ void refresh().then((index) => { const fresh = index?.children.find((candidate) => candidate.threadId === child.threadId); if (fresh == null || fresh.latestTaskId === child.latestTaskId) return; - const freshSelection = eventSubagentSelection(conversationId, fresh); + const freshSelection = eventSubagentSelection( + conversationId, + fresh, + siblingParentMessageIds, + ); if (freshSelection != null) { setSelected((current) => { if ( @@ -70,18 +143,43 @@ function EventSubagentRows({ } }); }, - [conversationId, refresh, resetCurrentArtifactId, setArtifactsVisible, setSelected], + [ + conversationId, + refresh, + resetCurrentArtifactId, + setArtifactsVisible, + setSelected, + siblingParentMessageIds, + ], ); return (
-
- {localize('com_ui_subagent_activity')} -
-
+ +