mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🪶 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:
parent
9cee6f97cc
commit
c7e8b45419
18 changed files with 1002 additions and 198 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }) }),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue