🪶 refactor: Polish Event Subagent Activity (#15152)

* 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
This commit is contained in:
Danny Avila 2026-08-24 02:24:30 -04:00 committed by GitHub
parent 9cee6f97cc
commit c7e8b45419
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1002 additions and 198 deletions

View file

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

View file

@ -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({
</Container>
) : (
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<TMessageContentParts | undefined>;
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

View file

@ -115,8 +115,20 @@ jest.mock('../Container', () => ({
jest.mock('../Part', () => ({
__esModule: true,
default: ({ part, idx }: { part: TMessageContentParts; idx: number }) => (
<div data-testid={`real-part-${part.type}`} data-index={idx} />
default: ({
part,
idx,
showCursor,
}: {
part: TMessageContentParts;
idx: number;
showCursor?: boolean;
}) => (
<div
data-testid={`real-part-${part.type}`}
data-index={idx}
data-show-cursor={String(showCursor === true)}
/>
),
}));
@ -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(
<ContentParts
{...baseProps}
content={[
{ type: ContentTypes.TEXT, text: 'Visible answer' } as TMessageContentParts,
{ type: ContentTypes.TEXT, text: '' } as TMessageContentParts,
]}
isLast
isSubmitting
isLatestMessage
/>,
);
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,

View file

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

View file

@ -185,6 +185,22 @@ function MultiMessage({
} else {
row = <Message {...sharedProps} />;
}
/** 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 ? (
<div className="w-full border-0 bg-transparent">
<div className="m-auto justify-center px-4 sm:px-0">
<EventSubagentActivityGroup
conversationId={message.conversationId ?? ''}
parentMessageId={message.messageId}
/>
</div>
<EventSubagentActivityGroup
conversationId={message.conversationId ?? ''}
parentMessageIds={activityParentMessageIds}
hasParallelContent={hasParallelContent}
/>
</div>
) : null}
<MemoizedMultiMessage

View file

@ -34,8 +34,18 @@ jest.mock('../MessageParts', () => ({ __esModule: true, default: createRowStub()
jest.mock('../Message', () => ({ __esModule: true, default: createRowStub() }));
jest.mock('~/components/Chat/Subagents/EventSubagentActivityGroup', () => ({
__esModule: true,
default: ({ parentMessageId }: { parentMessageId: string }) => (
<div data-testid="event-subagent-activity" data-parent-message-id={parentMessageId} />
default: ({
parentMessageIds,
hasParallelContent,
}: {
parentMessageIds: string[];
hasParallelContent?: boolean;
}) => (
<div
data-testid="event-subagent-activity"
data-parent-message-ids={parentMessageIds.join(',')}
data-has-parallel-content={String(hasParallelContent)}
/>
),
}));
@ -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', () => {
</RecoilRoot>,
);
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(
<RecoilRoot>
<MultiMessage
messageId="root"
messagesTree={[user]}
currentEditId={null}
setCurrentEditId={jest.fn()}
/>
</RecoilRoot>,
);
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(
<RecoilRoot>
<MultiMessage
messageId="root"
messagesTree={[user]}
currentEditId="user"
setCurrentEditId={jest.fn()}
/>
</RecoilRoot>,
);
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(
<RecoilRoot>
<MultiMessage
messageId="parent-1"
messagesTree={[assistant]}
currentEditId={null}
setCurrentEditId={jest.fn()}
/>
</RecoilRoot>,
);
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(
<RecoilRoot>
<MultiMessage
messageId="parent-1"
messagesTree={[assistant]}
currentEditId={null}
setCurrentEditId={jest.fn()}
/>
</RecoilRoot>,
);
expect(screen.getByTestId('row')).toHaveTextContent('assistant');
expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute(
'data-has-parallel-content',
'false',
);
});

View file

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

View file

@ -21,17 +21,31 @@ const mockChild: ParentSubagentSummary = {
tasks: [{ taskId: 'task-1', status: 'running' }],
tasksTruncated: false,
};
const mockCompletedChild: ParentSubagentSummary = {
...mockChild,
threadId: 'event-thread-2',
parentMessageId: 'assistant-message',
agentId: 'agent-2',
actorId: 'actor-b',
status: 'completed',
latestTaskId: 'task-2',
tasks: [{ taskId: 'task-2', status: 'completed' }],
};
let mockChildrenByMessage = new Map<string, ParentSubagentSummary[]>();
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: () => <span data-testid="agent-avatar" />,
}));
jest.mock('@librechat/client', () => ({
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
<button {...props}>{children}</button>
),
cn: (...values: Array<string | false | undefined>) => 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', () => {
<Observer />
<EventSubagentActivityGroup
conversationId="parent-conversation"
parentMessageId="parent-message"
parentMessageIds={['parent-message']}
/>
</RecoilRoot>,
);
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(
<RecoilRoot>
<EventSubagentActivityGroup
conversationId="parent-conversation"
parentMessageIds={['parent-message']}
hasParallelContent
/>
</RecoilRoot>,
);
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(
<RecoilRoot>
<Observer />
<EventSubagentActivityGroup
conversationId="parent-conversation"
parentMessageIds={['parent-message', 'empty-assistant-message']}
/>
</RecoilRoot>,
);
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(
<RecoilRoot>
<Observer />
<EventSubagentActivityGroup
conversationId="parent-conversation"
parentMessageIds={['parent-message', 'assistant-message']}
/>
</RecoilRoot>,
);
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', () => {
<ClosePanel />
<EventSubagentActivityGroup
conversationId="parent-conversation"
parentMessageId="parent-message"
parentMessageIds={['parent-message']}
/>
</RecoilRoot>,
);
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' }) }),

View file

@ -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<string>();
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 (
<EventSubagentRows
conversationId={conversationId}
parentMessageId={parentMessageId}
eventChildren={children}
/>
<div
className={cn(
'mx-auto min-w-0 flex-1 px-4 transition-[max-width] duration-theme-normal motion-reduce:transition-none sm:px-0',
getMessageRowWidthClass({ fullWidth, hasParallelContent }),
)}
>
<EventSubagentRows
conversationId={conversationId}
eventChildren={children}
siblingParentMessageIds={siblingParentMessageIds}
/>
</div>
);
}
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<ParentSubagentSummary['status'], number>();
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 (
<section
aria-label={localize('com_ui_subagent_activity')}
className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary"
data-event-subagent-group={parentMessageId}
className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary/40"
data-event-subagent-group={eventChildren[0]?.parentMessageId}
>
<div className="border-b border-border-light px-3 py-2 text-xs font-medium text-text-secondary">
{localize('com_ui_subagent_activity')}
</div>
<div className="divide-y divide-border-light">
<Button
variant="ghost"
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={panelId}
aria-label={`${localize('com_ui_subagent_activity')}: ${summary}`}
className="flex h-auto min-h-10 w-full items-center justify-start gap-2 rounded-lg px-3 py-2 text-left text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary focus-visible:ring-offset-0"
>
<Bot size={15} className="shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{summary}</span>
<ChevronDown
size={16}
className={cn('shrink-0 transition-transform', expanded && 'rotate-180')}
aria-hidden="true"
/>
</Button>
<div
id={panelId}
hidden={!expanded}
className="divide-y divide-border-light border-t border-border-light"
>
{eventChildren.map((child) => {
const StatusIcon = subagentStatusIcon(child.status);
const agent = child.agentId == null ? undefined : agentsMap?.[child.agentId];
@ -94,11 +192,11 @@ function EventSubagentRows({
disabled={!canOpen}
onClick={() => openChild(child)}
data-subagent-tool-call={`event-thread:${child.threadId}`}
data-subagent-parent-message={parentMessageId}
data-subagent-parent-message={child.parentMessageId}
data-subagent-part-index="0"
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm',
canOpen ? 'hover:bg-surface-tertiary' : 'cursor-default opacity-70',
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary',
canOpen ? 'hover:bg-surface-hover' : 'cursor-default opacity-70',
)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full">

View file

@ -1,8 +1,8 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { Agents } from 'librechat-data-provider';
import type { ChildActivity } from './adapters';
import SubagentActivity from './SubagentActivity';
import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
@ -335,6 +335,44 @@ describe('SubagentActivity', () => {
);
});
it('renders an embedded turn without creating a nested scroll surface', () => {
const { container } = render(<SubagentActivity activity={base} embedded />);
expect(container.querySelector('[data-subagent-thread-turn]')).toBeInTheDocument();
expect(container.querySelector('.overflow-y-auto')).not.toBeInTheDocument();
expect(screen.getByText('Final answer.')).toBeInTheDocument();
});
it('keeps the shared scroll surface pinned when activity grows at the bottom', () => {
let resize!: ResizeObserverCallback;
const resizeObserver = window.ResizeObserver as unknown as jest.Mock;
const originalImplementation = resizeObserver.getMockImplementation();
resizeObserver.mockImplementation((callback: ResizeObserverCallback) => {
resize = callback;
return { observe: jest.fn(), disconnect: jest.fn(), unobserve: jest.fn() };
});
const { container, unmount } = render(
<SubagentActivityScrollSurface padded={false}>
{/* eslint-disable-next-line i18next/no-literal-string */}
<div>Growing timeline</div>
</SubagentActivityScrollSurface>,
);
const surface = container.querySelector<HTMLElement>('[data-subagent-activity-scroll-surface]');
expect(surface).not.toBeNull();
Object.defineProperty(surface, 'scrollHeight', { configurable: true, value: 640 });
act(() => resize([], {} as ResizeObserver));
expect(surface?.scrollTop).toBe(640);
unmount();
if (originalImplementation == null) {
resizeObserver.mockReset();
} else {
resizeObserver.mockImplementation(originalImplementation);
}
});
it('renders a sanitized reasoning marker through regular ContentParts', () => {
render(
<SubagentActivity
@ -382,11 +420,13 @@ describe('SubagentActivity', () => {
expect(screen.queryByText('com_ui_subagent_waiting')).not.toBeInTheDocument();
});
it.each([
['error', 'com_ui_subagent_thread_load_error'],
['ready', 'com_ui_subagent_empty_result'],
] as const)('renders the %s state', (state, label) => {
render(<SubagentActivity activity={{ ...base, items: [] }} state={state} />);
expect(screen.getByText(label)).toBeInTheDocument();
it('renders the load error state', () => {
render(<SubagentActivity activity={{ ...base, items: [] }} state="error" />);
expect(screen.getByText('com_ui_subagent_thread_load_error')).toBeInTheDocument();
});
it('does not describe a completed tool-only child as missing a result', () => {
render(<SubagentActivity activity={{ ...base, status: 'completed', items: [] }} />);
expect(screen.queryByText('com_ui_subagent_empty_result')).not.toBeInTheDocument();
});
});

View file

@ -14,6 +14,65 @@ import { cn } from '~/utils';
const AT_BOTTOM_THRESHOLD_PX = 120;
export function SubagentActivityScrollSurface({
children,
padded = true,
}: {
children: React.ReactNode;
padded?: boolean;
}) {
const localize = useLocalize();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
useEffect(() => {
const scroll = scrollRef.current;
const content = contentRef.current;
if (scroll == null || content == null || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
if (isAtBottom) scroll.scrollTop = scroll.scrollHeight;
});
observer.observe(content);
return () => observer.disconnect();
}, [isAtBottom]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
setIsAtBottom(
element.scrollHeight - element.scrollTop - element.clientHeight <= AT_BOTTOM_THRESHOLD_PX,
);
}, []);
return (
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn('relative min-h-0 flex-1 overflow-y-auto', padded && 'px-4 py-4')}
data-subagent-activity-scroll-surface
>
{!isAtBottom && (
<Button
variant="ghost"
size="icon"
onClick={() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
});
setIsAtBottom(true);
}}
aria-label={localize('com_ui_subagent_scroll_to_bottom')}
className="sticky top-[calc(100%-2.75rem)] z-10 ml-auto h-8 w-8 rounded-full border border-border-light bg-surface-secondary text-text-secondary shadow-md"
>
<ArrowDown size={16} aria-hidden />
</Button>
)}
<div ref={contentRef}>{children}</div>
</div>
);
}
const toContentPart = (
item: ChildActivityItem,
reasoningMarkerLabel: string,
@ -122,15 +181,14 @@ export default function SubagentActivity({
activity,
activityId,
state = 'ready',
embedded = false,
}: {
activity: ChildActivity;
activityId?: string;
state?: 'ready' | 'loading' | 'error';
embedded?: boolean;
}) {
const localize = useLocalize();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const isSubmitting = activity.status === 'running' || activity.status === 'dispatched';
const StatusIcon = subagentStatusIcon(activity.status);
const reasoningMarkerLabel = localize('com_ui_subagent_ticker_reasoning');
@ -146,24 +204,6 @@ export default function SubagentActivity({
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
);
useEffect(() => {
const scroll = scrollRef.current;
const content = contentRef.current;
if (scroll == null || content == null || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
if (isAtBottom) scroll.scrollTop = scroll.scrollHeight;
});
observer.observe(content);
return () => observer.disconnect();
}, [isAtBottom]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
setIsAtBottom(
element.scrollHeight - element.scrollTop - element.clientHeight <= AT_BOTTOM_THRESHOLD_PX,
);
}, []);
let body: React.ReactNode;
if (state === 'loading') {
body = (
@ -182,11 +222,7 @@ export default function SubagentActivity({
<Container>
<EmptyText />
</Container>
) : (
<div className="rounded-lg border border-border-light bg-surface-secondary p-3 text-sm text-text-secondary">
{localize('com_ui_subagent_empty_result')}
</div>
);
) : null;
} else {
body = (
<ContentParts
@ -201,54 +237,47 @@ export default function SubagentActivity({
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border-light px-4 py-2">
<div
className={cn(
'flex items-center gap-1 text-xs text-text-secondary',
activity.status === 'failed' || activity.status === 'interrupted'
? 'text-status-error'
: '',
)}
aria-live="polite"
>
<StatusIcon size={13} aria-hidden />
<span>{localize(subagentStatusLabelKey(activity.status))}</span>
</div>
</div>
const statusHeader = (
<div className="shrink-0 border-b border-border-light px-4 py-2">
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative min-h-0 flex-1 overflow-y-auto px-4 py-4"
>
{!isAtBottom && (
<Button
variant="ghost"
size="icon"
onClick={() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
});
setIsAtBottom(true);
}}
aria-label={localize('com_ui_subagent_scroll_to_bottom')}
className="sticky top-[calc(100%-2.75rem)] z-10 ml-auto h-8 w-8 rounded-full border border-border-light bg-surface-secondary text-text-secondary shadow-md"
>
<ArrowDown size={16} aria-hidden />
</Button>
className={cn(
'flex items-center gap-1 text-xs text-text-secondary',
activity.status === 'failed' || activity.status === 'interrupted'
? 'text-status-error'
: '',
)}
<div ref={contentRef} className="flex max-w-full flex-col gap-0">
{activity.prompt != null && <SubagentPrompt prompt={activity.prompt} />}
{activityTruncated && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_history_truncated')}
</div>
)}
{body}
</div>
aria-live="polite"
>
<StatusIcon size={13} aria-hidden />
<span>{localize(subagentStatusLabelKey(activity.status))}</span>
</div>
</div>
);
const content = (
<div className="flex max-w-full flex-col gap-0">
{activity.prompt != null && <SubagentPrompt prompt={activity.prompt} />}
{activityTruncated && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_history_truncated')}
</div>
)}
{body}
</div>
);
if (embedded) {
return (
<section className="border-b border-border-light last:border-b-0" data-subagent-thread-turn>
{statusHeader}
<div className="px-4 py-4">{content}</div>
</section>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{statusHeader}
<SubagentActivityScrollSurface>{content}</SubagentActivityScrollSurface>
</div>
);
}

View file

@ -90,14 +90,24 @@ jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({
jest.mock('./SubagentActivity', () => ({
__esModule: true,
SubagentActivityScrollSurface: ({ children }: { children: React.ReactNode }) => (
<div data-testid="shared-scroll-surface">{children}</div>
),
default: ({
activity,
activityId,
state,
}: {
activity: { status: string; prompt?: string; items: Array<{ type: string; text?: string }> };
activityId?: string;
state: string;
}) => (
<div data-testid="shared-activity" data-state={state} data-status={activity.status}>
<div
data-testid="shared-activity"
data-activity-id={activityId}
data-state={state}
data-status={activity.status}
>
{activity.prompt}
{activity.items.map((item, index) => (
<span key={index}>{item.text ?? item.type}</span>
@ -550,6 +560,9 @@ describe('SubagentThreadPanel', () => {
{ refetchInterval: 2000 },
);
expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(eventSelection, true);
expect(
screen.queryByRole('combobox', { name: 'com_ui_subagent_turn' }),
).not.toBeInTheDocument();
await waitFor(() => expect(refetch).toHaveBeenCalled());
});
@ -627,7 +640,7 @@ describe('SubagentThreadPanel', () => {
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
it('navigates event actors and exact durable turns through the same panel', () => {
it('navigates event actors and renders exact durable turns as one chronological thread', () => {
const first: ParentSubagentSummary = {
threadId: 'child-thread',
parentMessageId: 'parent-message',
@ -656,7 +669,10 @@ describe('SubagentThreadPanel', () => {
tasks: [{ taskId: 'task-2', status: 'running' }],
status: 'running',
};
mockParentChildrenByMessage = new Map([['parent-message', [first, second]]]);
mockParentChildrenByMessage = new Map([
['parent-message', [first]],
['assistant-message', [second]],
]);
mockParentChildrenByThread = new Map([
[first.threadId, first],
[second.threadId, second],
@ -669,7 +685,11 @@ describe('SubagentThreadPanel', () => {
});
const eventSelection: ActiveSubagentPanel = {
...selection,
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task' },
event: {
actorId: 'actor-1',
progressKey: 'event-task:child-thread:task',
siblingParentMessageIds: ['parent-message', 'assistant-message'],
},
};
let active: ActiveSubagentPanel | null = eventSelection;
const Observer = () => {
@ -684,9 +704,19 @@ describe('SubagentThreadPanel', () => {
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('option', { name: 'com_ui_subagent_earlier_turn' }));
expect(active?.durable).toEqual({ threadId: 'child-thread', taskId: 'task-earlier' });
expect(active?.event?.progressKey).toBe('event-task:child-thread:task-earlier');
expect(screen.queryByRole('button', { name: 'com_ui_subagent_turn' })).not.toBeInTheDocument();
const turns = screen.getAllByTestId('shared-activity');
expect(turns).toHaveLength(2);
expect(turns[0]).toHaveAttribute(
'data-activity-id',
'parent-message\u0000tool-call\u0000task-earlier',
);
expect(turns[1]).toHaveAttribute('data-activity-id', 'parent-message\u0000tool-call\u0000task');
expect(mockUseSubagentThreadQuery).toHaveBeenCalledWith(
'parent-conversation',
'child-thread',
'task-earlier',
);
fireEvent.click(screen.getByRole('option', { name: /Analyst Two/ }));
expect(active).toEqual(
@ -695,10 +725,115 @@ describe('SubagentThreadPanel', () => {
event: {
actorId: 'actor-2',
progressKey: 'event-task:child-thread-2:task-2',
siblingParentMessageIds: ['parent-message', 'assistant-message'],
},
durable: { threadId: 'child-thread-2', taskId: 'task-2' },
}),
);
expect(mockRefreshParentChildren).toHaveBeenCalled();
});
it('follows a newly appended latest turn while preserving the continuous history', async () => {
const eventChild: ParentSubagentSummary = {
threadId: 'child-thread',
parentMessageId: 'parent-message',
subagentType: 'agent-1',
subagentKind: 'agent',
agentId: 'agent-1',
title: 'Actor',
origin: 'event',
actorId: 'actor-1',
status: 'running',
latestTaskId: 'task-new',
tasks: [
{ taskId: 'task-new', status: 'running' },
{ taskId: 'task-old', status: 'completed' },
],
tasksTruncated: false,
};
mockParentChildrenByMessage = new Map([['parent-message', [eventChild]]]);
mockParentChildrenByThread = new Map([[eventChild.threadId, eventChild]]);
mockUseSubagentThreadQuery.mockReturnValue({
data: completedView,
isLoading: false,
isError: false,
isReadinessPending: false,
});
const staleSelection: ActiveSubagentPanel = {
...selection,
durable: { threadId: 'child-thread', taskId: 'task-old' },
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task-old' },
};
let active: ActiveSubagentPanel | null = staleSelection;
const Observer = () => {
active = useRecoilValue(activeSubagentPanel);
return null;
};
render(
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, staleSelection)}>
<Observer />
<SubagentThreadPanel selection={staleSelection} />
</RecoilRoot>,
);
await waitFor(() =>
expect(active?.durable).toEqual({ threadId: 'child-thread', taskId: 'task-new' }),
);
expect(screen.getAllByTestId('shared-activity')).toHaveLength(2);
});
it('loads retained event history in bounded pages and marks an omitted beginning', () => {
const tasks = Array.from({ length: 5 }, (_, index) => ({
taskId: `task-${5 - index}`,
status: 'completed' as const,
}));
const eventChild: ParentSubagentSummary = {
threadId: 'child-thread',
parentMessageId: 'parent-message',
subagentType: 'agent-1',
subagentKind: 'agent',
agentId: 'agent-1',
title: 'Actor',
origin: 'event',
actorId: 'actor-1',
status: 'completed',
latestTaskId: 'task-5',
tasks,
tasksTruncated: true,
};
mockParentChildrenByMessage = new Map([['parent-message', [eventChild]]]);
mockParentChildrenByThread = new Map([[eventChild.threadId, eventChild]]);
mockUseSubagentThreadQuery.mockReturnValue({
data: completedView,
isLoading: false,
isError: false,
isReadinessPending: false,
});
const eventSelection: ActiveSubagentPanel = {
...selection,
durable: { threadId: 'child-thread', taskId: 'task-5' },
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task-5' },
};
render(
<RecoilRoot>
<SubagentThreadPanel selection={eventSelection} />
</RecoilRoot>,
);
expect(screen.getAllByTestId('shared-activity')).toHaveLength(3);
expect(new Set(mockUseSubagentThreadQuery.mock.calls.map((call) => call[2]))).toEqual(
new Set(['task-5', 'task-4', 'task-3']),
);
expect(screen.queryByText('com_ui_subagent_thread_history_truncated')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'com_ui_load_more' }));
expect(screen.getAllByTestId('shared-activity')).toHaveLength(5);
expect(new Set(mockUseSubagentThreadQuery.mock.calls.map((call) => call[2]))).toEqual(
new Set(['task-5', 'task-4', 'task-3', 'task-2', 'task-1']),
);
expect(screen.getByText('com_ui_subagent_thread_history_truncated')).toBeInTheDocument();
});
});

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bot, MessagesSquare, X } from 'lucide-react';
import { ForkOptions } from 'librechat-data-provider';
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
@ -12,6 +12,8 @@ import {
useMediaQuery,
useToastContext,
} from '@librechat/client';
import type { ParentSubagentTaskSummary } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { ActiveSubagentPanel } from '~/store/subagents';
import {
ACTIVE_THREAD_REFRESH_MS,
@ -25,14 +27,16 @@ import {
subagentProgressKey,
} from '~/store/subagents';
import useSubagentActivityStream from '~/data-provider/Subagents/useSubagentActivityStream';
import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity';
import { adaptDurableThreadActivity, adaptLivePersistedActivity } from './adapters';
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
import { eventSubagentSelection, eventTaskProgressKey } from './eventSelection';
import { useFocusTrap, useLocalize, useNavigateToConvo } from '~/hooks';
import { useParentSubagents } from './ParentSubagentsProvider';
import SubagentActivity from './SubagentActivity';
import { eventSubagentSelection } from './eventSelection';
import { useAgentsMapContext } from '~/Providers';
const EVENT_TASK_PAGE_SIZE = 3;
export default function SubagentThreadPanel({ selection }: { selection: ActiveSubagentPanel }) {
const localize = useLocalize();
const { showToast } = useToastContext();
@ -59,12 +63,47 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
const threadId = selection.durable?.threadId ?? '';
const taskId = selection.durable?.taskId ?? '';
const eventSummary = selection.event == null ? undefined : byThreadId.get(threadId);
const eventTaskCount = eventSummary?.tasks.length ?? 0;
const [eventTaskWindow, setEventTaskWindow] = useState(() => ({
threadId,
count: EVENT_TASK_PAGE_SIZE,
taskCount: eventSummary == null ? null : eventTaskCount,
}));
useEffect(() => {
setEventTaskWindow((current) => {
if (current.threadId !== threadId || current.taskCount == null) {
return { threadId, count: EVENT_TASK_PAGE_SIZE, taskCount: eventTaskCount };
}
const appended = Math.max(0, eventTaskCount - current.taskCount);
return {
threadId,
count: Math.min(eventTaskCount, current.count + appended),
taskCount: eventTaskCount,
};
});
}, [eventTaskCount, threadId]);
const visibleEventTaskCount = Math.min(
eventTaskCount,
eventTaskWindow.threadId === threadId ? eventTaskWindow.count : EVENT_TASK_PAGE_SIZE,
);
const visibleEventTasks = useMemo(
() => (eventSummary?.tasks ?? []).slice(0, visibleEventTaskCount).reverse(),
[eventSummary?.tasks, visibleEventTaskCount],
);
const hasEarlierRetainedTasks = visibleEventTaskCount < eventTaskCount;
const eventTaskRunning =
eventSummary?.tasks.find((task) => task.taskId === taskId)?.status === 'running';
const eventSiblings = useMemo(
() => (selection.event == null ? [] : (byMessageId.get(selection.parentMessageId) ?? [])),
[byMessageId, selection.event, selection.parentMessageId],
);
const eventSiblings = useMemo(() => {
if (selection.event == null) return [];
const seen = new Set<string>();
return (selection.event.siblingParentMessageIds ?? [selection.parentMessageId])
.flatMap((parentMessageId) => byMessageId.get(parentMessageId) ?? [])
.filter((child) => {
if (seen.has(child.threadId)) return false;
seen.add(child.threadId);
return true;
});
}, [byMessageId, selection.event, selection.parentMessageId]);
const { data, isLoading, isError, isReadinessPending, refetch } = useSubagentThreadQuery(
selection.parentConversationId,
threadId,
@ -99,6 +138,21 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
if (selection.event == null || !eventTaskRunning || !durableTerminal) return;
void refetch();
}, [durableTerminal, eventTaskRunning, refetch, selection.event]);
useEffect(() => {
if (
selection.event == null ||
eventSummary?.latestTaskId == null ||
eventSummary.latestTaskId === taskId
) {
return;
}
const nextSelection = eventSubagentSelection(
selection.parentConversationId,
eventSummary,
selection.event.siblingParentMessageIds,
);
if (nextSelection != null) setSelection(nextSelection);
}, [eventSummary, selection, setSelection, taskId]);
const detachedLiveSubmitting =
selection.durable != null &&
progress != null &&
@ -201,27 +255,19 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
(nextThreadId: string) => {
const next = eventSiblings.find((child) => child.threadId === nextThreadId);
if (next == null) return;
const nextSelection = eventSubagentSelection(selection.parentConversationId, next);
const nextSelection = eventSubagentSelection(
selection.parentConversationId,
next,
selection.event?.siblingParentMessageIds,
);
if (nextSelection != null) setSelection(nextSelection);
},
[eventSiblings, selection.parentConversationId, setSelection],
);
const selectTask = useCallback(
(nextTaskId: string) => {
if (selection.durable == null || selection.event == null) return;
const nextTask = eventSummary?.tasks.find((task) => task.taskId === nextTaskId);
setSelection({
...selection,
durable: { ...selection.durable, taskId: nextTaskId },
event: {
...selection.event,
progressKey: eventTaskProgressKey(selection.durable.threadId, nextTaskId),
},
initialProgress: nextTask?.status === 'completed' ? 1 : 0,
isSubmitting: nextTask?.status === 'running',
});
},
[eventSummary?.tasks, selection, setSelection],
[
eventSiblings,
selection.event?.siblingParentMessageIds,
selection.parentConversationId,
setSelection,
],
);
let panelState: 'ready' | 'loading' | 'error' = 'ready';
if (
@ -233,6 +279,53 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
} else if (selection.durable != null && liveActivity.items.length === 0 && isError) {
panelState = 'error';
}
const renderEventTask = (task: ParentSubagentTaskSummary) => {
if (task.taskId === taskId) {
return (
<SubagentActivity
key={task.taskId}
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${task.taskId}`}
activity={activity}
state={panelState}
embedded
/>
);
}
return (
<HistoricalEventTaskActivity
key={task.taskId}
selection={selection}
task={task}
title={activity.title}
/>
);
};
const loadEarlierEventTasks = () => {
setEventTaskWindow({
threadId,
count: Math.min(eventTaskCount, visibleEventTaskCount + EVENT_TASK_PAGE_SIZE),
taskCount: eventTaskCount,
});
};
let timelinePrefix: ReactNode = null;
if (hasEarlierRetainedTasks) {
timelinePrefix = (
<div className="flex justify-center border-b border-border-light px-4 py-2">
<Button type="button" variant="ghost" size="sm" onClick={loadEarlierEventTasks}>
{localize('com_ui_load_more')}
</Button>
</div>
);
} else if (eventSummary?.tasksTruncated) {
timelinePrefix = (
<div
role="note"
className="border-b border-border-light px-4 py-3 text-sm text-text-secondary"
>
{localize('com_ui_subagent_thread_history_truncated')}
</div>
);
}
return (
<aside
@ -302,22 +395,6 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
</SelectContent>
</Select>
</div>
<div className="w-32">
<Select value={taskId} onValueChange={selectTask}>
<SelectTrigger className="h-8" aria-label={localize('com_ui_subagent_turn')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(eventSummary?.tasks ?? []).map((task, index) => (
<SelectItem key={task.taskId} value={task.taskId}>
{index === 0
? localize('com_ui_subagent_latest_turn')
: localize('com_ui_subagent_earlier_turn', { 0: String(index) })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
@ -327,13 +404,61 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
<ApprovalProvider
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
>
<SubagentActivity
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activity={activity}
state={panelState}
/>
{selection.event != null && (eventSummary?.tasks.length ?? 0) > 1 ? (
<SubagentActivityScrollSurface padded={false}>
<div data-subagent-thread-timeline>
{timelinePrefix}
{visibleEventTasks.map(renderEventTask)}
</div>
</SubagentActivityScrollSurface>
) : (
<SubagentActivity
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activity={activity}
state={panelState}
/>
)}
</ApprovalProvider>
</aside>
);
}
function HistoricalEventTaskActivity({
selection,
task,
title,
}: {
selection: ActiveSubagentPanel;
task: ParentSubagentTaskSummary;
title: string;
}) {
const threadId = selection.durable?.threadId ?? '';
const { data, isLoading, isError, isReadinessPending } = useSubagentThreadQuery(
selection.parentConversationId,
threadId,
task.taskId,
);
const activity = useMemo(
() =>
data == null
? { title, status: task.status, items: [] }
: adaptDurableThreadActivity(data, task.taskId),
[data, task.status, task.taskId, title],
);
let state: 'ready' | 'loading' | 'error' = 'ready';
if (isError) {
state = 'error';
} else if (isLoading || isReadinessPending) {
state = 'loading';
}
return (
<SubagentActivity
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${task.taskId}`}
activity={activity}
state={state}
embedded
/>
);
}

View file

@ -7,6 +7,7 @@ export const eventTaskProgressKey = (threadId: string, taskId: string) =>
export const eventSubagentSelection = (
parentConversationId: string,
child: ParentSubagentSummary,
siblingParentMessageIds?: string[],
): ActiveSubagentPanel | null => {
const taskId = child.latestTaskId;
if (child.origin !== 'event' || child.actorId == null || taskId == null) return null;
@ -20,6 +21,10 @@ export const eventSubagentSelection = (
initialProgress: child.status === 'completed' ? 1 : 0,
isSubmitting: child.status === 'running',
durable: { threadId: child.threadId, taskId },
event: { actorId: child.actorId, progressKey: eventTaskProgressKey(child.threadId, taskId) },
event: {
actorId: child.actorId,
progressKey: eventTaskProgressKey(child.threadId, taskId),
...(siblingParentMessageIds == null ? {} : { siblingParentMessageIds }),
},
};
};

View file

@ -2179,15 +2179,27 @@
"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_agent_count": "{{0}} agent",
"com_ui_subagent_agents_count": "{{0}} agents",
"com_ui_subagent_count_dispatched_one": "{{0}} dispatched",
"com_ui_subagent_count_dispatched_other": "{{0}} dispatched",
"com_ui_subagent_count_running_one": "{{0}} running",
"com_ui_subagent_count_running_other": "{{0}} running",
"com_ui_subagent_count_completed_one": "{{0}} completed",
"com_ui_subagent_count_completed_other": "{{0}} completed",
"com_ui_subagent_count_failed_one": "{{0}} failed",
"com_ui_subagent_count_failed_other": "{{0}} failed",
"com_ui_subagent_count_interrupted_one": "{{0}} interrupted",
"com_ui_subagent_count_interrupted_other": "{{0}} interrupted",
"com_ui_subagent_count_cancelled_one": "{{0}} cancelled",
"com_ui_subagent_count_cancelled_other": "{{0}} cancelled",
"com_ui_subagent_actor": "Agent",
"com_ui_subagent_cancelled": "Cancelled agent",
"com_ui_subagent_complete": "Ran agent",
"com_ui_subagent_dialog_title": "\"{{0}}\" agent",
"com_ui_subagent_dialog_title_self": "Agent",
"com_ui_subagent_earlier_turn": "Earlier turn {{0}}",
"com_ui_subagent_empty_result": "No text returned.",
"com_ui_subagent_errored": "Agent errored",
"com_ui_subagent_latest_turn": "Latest turn",
"com_ui_subagent_no_result_yet": "Still running — no final result 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.",

View file

@ -271,6 +271,8 @@ export type ActiveSubagentPanel = {
actorId: string;
/** Task-specific live activity identity; the actor thread is reused across turns. */
progressKey: string;
/** Message anchors merged into the same parent-owned activity group. */
siblingParentMessageIds?: string[];
};
};

View file

@ -3,6 +3,7 @@ import type { TActivityLabelEvent, TMessage, TMessageContentParts } from 'librec
import {
applyActivityLabelPart,
groupActivityPhases,
lastCursorContentIdx,
lastVisibleContentIdx,
offsetActivityPhaseBoundary,
} from '../activityLabels';
@ -177,6 +178,27 @@ describe('lastVisibleContentIdx', () => {
});
});
describe('lastCursorContentIdx', () => {
it('keeps the cursor on visible output before a trailing empty provider placeholder', () => {
const text = { type: ContentTypes.TEXT, text: 'Visible answer' } as TMessageContentParts;
const emptyText = { type: ContentTypes.TEXT, text: '' } as TMessageContentParts;
expect(lastCursorContentIdx([text, emptyText])).toBe(0);
});
it('retains a solitary empty placeholder for the initial waiting state', () => {
const emptyText = { type: ContentTypes.TEXT, text: '' } as TMessageContentParts;
expect(lastCursorContentIdx([emptyText])).toBe(0);
});
it('retains a sparse empty placeholder when no visible output precedes it', () => {
const emptyText = { type: ContentTypes.TEXT, text: '' } as TMessageContentParts;
expect(lastCursorContentIdx([undefined, emptyText])).toBe(1);
});
});
describe('offsetActivityPhaseBoundary', () => {
it('folds only boundaries covered by the merged first completion part', () => {
expect(offsetActivityPhaseBoundary(0, 5, true)).toBe(4);

View file

@ -359,6 +359,32 @@ export function lastVisibleContentIdx(
return -1;
}
function isEmptyTextContentPart(part: TMessageContentParts | undefined): boolean {
if (part == null || part.type !== ContentTypes.TEXT) {
return false;
}
const text = typeof part.text === 'string' ? part.text : part.text?.value;
return (text ?? '').length === 0;
}
/**
* Last content index that should own the streaming cursor. A provider may
* append an empty TEXT placeholder after already-visible output; that
* placeholder must remain available for the initial waiting state without
* moving the cursor away from the visible part in either renderer.
*/
export function lastCursorContentIdx(
content: ReadonlyArray<TMessageContentParts | undefined> | undefined,
): number {
const parts = content ?? [];
const lastIdx = lastVisibleContentIdx(parts);
if (lastIdx > 0 && isEmptyTextContentPart(parts[lastIdx])) {
const precedingIdx = lastVisibleContentIdx(parts.slice(0, lastIdx));
return precedingIdx >= 0 ? precedingIdx : lastIdx;
}
return lastIdx;
}
/**
* Resolves the assistant response message an activity-label event targets.
* Exact-id assistant match when `responseMessageId` is present (a miss