diff --git a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.test.tsx b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.test.tsx
index 3a68f906fc..a1fa1117d0 100644
--- a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.test.tsx
+++ b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.test.tsx
@@ -220,6 +220,40 @@ describe('EventSubagentActivityGroup', () => {
]);
});
+ it('pins the status color to one column and shimmers only a live row', () => {
+ mockChildrenByMessage = new Map([['parent-message', [mockChild, mockCompletedChild]]]);
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ }));
+
+ const rows = [
+ screen.getByRole('button', { name: /Visible Agent/ }),
+ screen.getByRole('button', { name: /Completed Agent/ }),
+ ];
+ const [running, completed] = rows.map((row) => row.lastElementChild as HTMLElement);
+
+ /** Label first, dot last, in every row: a longer status can only grow
+ * inboard, so the color never moves between rows. */
+ expect(running.firstElementChild).toHaveTextContent('com_ui_subagent_thread_status_running');
+ expect(running.lastElementChild).toHaveAttribute('aria-hidden', 'true');
+ expect(running.lastElementChild).toHaveClass('bg-status-info');
+ expect(running.firstElementChild).toHaveClass('shimmer');
+
+ expect(completed.firstElementChild).toHaveTextContent(
+ 'com_ui_subagent_thread_status_completed',
+ );
+ expect(completed.lastElementChild).toHaveClass('bg-status-success');
+ expect(completed.firstElementChild).not.toHaveClass('shimmer');
+ });
+
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;
diff --git a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx
index 6d9ff5435d..a31a476248 100644
--- a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx
+++ b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx
@@ -3,8 +3,8 @@ 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 { isLiveSubagentStatus, subagentStatusDotClass, subagentStatusLabelKey } from './status';
import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow';
-import { subagentStatusDotClass, subagentStatusLabelKey } from './status';
import { useParentSubagents } from './ParentSubagentsProvider';
import { eventSubagentSelection } from './eventSelection';
import { activeSubagentPanel } from '~/store/subagents';
@@ -217,10 +217,18 @@ function EventSubagentRows({
) : null}
- {/* Fixed-width slot: the dot gives at-a-glance color, the text
- keeps a non-color cue, and neither can shift the row as
- statuses change length. */}
+ {/* Fixed-width slot, dot last: the label absorbs every length
+ change inboard of it, so the color lands on the same pixel
+ column in every row and cannot drift as statuses change. */}
+
+ {localize(subagentStatusLabelKey(child.status))}
+
- {localize(subagentStatusLabelKey(child.status))}
);
diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx
index da435f03fa..262531949b 100644
--- a/client/src/components/Chat/Subagents/SubagentActivity.tsx
+++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx
@@ -7,7 +7,12 @@ import { CheckCircle2, Clock3, Maximize2, Minimize2, XCircle } from 'lucide-reac
import type { TMessageContentParts } from 'librechat-data-provider';
import type { ChildActivity, ChildActivityItem } from './adapters';
import type { TranslationKeys } from '~/hooks';
-import { isAbnormalTerminalStatus, subagentStatusIcon, subagentStatusLabelKey } from './status';
+import {
+ isAbnormalTerminalStatus,
+ isLiveSubagentStatus,
+ subagentStatusIcon,
+ subagentStatusLabelKey,
+} from './status';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import Container from '~/components/Chat/Messages/Content/Container';
@@ -297,6 +302,7 @@ export function SubagentActivityContent({
state = 'ready',
showPrompt = true,
conversationId = null,
+ underHeaderIcon = false,
onCancelControl,
}: {
activity: ChildActivity;
@@ -304,17 +310,21 @@ export function SubagentActivityContent({
state?: 'ready' | 'loading' | 'error';
showPrompt?: boolean;
conversationId?: string | null;
+ /** Set when this body sits directly beneath a `MessageRow` author header, so
+ * the streaming dot centers on the header icon's axis as it does in main
+ * chat (see `EmptyTextPart`). */
+ underHeaderIcon?: boolean;
onCancelControl?: (controlId: string) => void;
}) {
const localize = useLocalize();
- const isSubmitting = activity.status === 'running' || activity.status === 'dispatched';
+ const isSubmitting = isLiveSubagentStatus(activity.status);
const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]);
let body: React.ReactNode;
if (state === 'loading') {
body = (
-
+
);
} else if (state === 'error') {
@@ -326,7 +336,7 @@ export function SubagentActivityContent({
} else if (activity.items.length === 0) {
body = isSubmitting ? (
-
+
) : null;
} else {
diff --git a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx
index 3ff3a6990c..0c6278c352 100644
--- a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx
+++ b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx
@@ -40,7 +40,14 @@ jest.mock('~/components/Chat/Messages/Content/Container', () => ({
}));
jest.mock('~/components/Chat/Messages/Content/Parts', () => ({
- EmptyText: () =>
,
+ EmptyText: ({ underHeaderIcon }: { underHeaderIcon?: boolean }) => (
+
+ ),
+}));
+
+jest.mock('~/components/Chat/Messages/MessageIcon', () => ({
+ __esModule: true,
+ default: () => ,
}));
jest.mock('lucide-react', () => ({
@@ -114,7 +121,10 @@ describe('SubagentConversation', () => {
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_thread_status_running')).not.toBeInTheDocument();
- expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
+ /** Both halves of the main chat author column: the shared glyph, and the
+ * streaming dot inset onto that glyph's axis the way main chat insets it. */
+ expect(screen.getAllByTestId('message-icon')).toHaveLength(2);
+ expect(screen.getByTestId('thinking-cursor')).toHaveAttribute('data-under-header-icon', 'true');
expect(container.querySelectorAll('.message-render')).toHaveLength(4);
expect(container.querySelectorAll('.user-turn')).toHaveLength(2);
expect(container.querySelectorAll('.agent-turn')).toHaveLength(2);
diff --git a/client/src/components/Chat/Subagents/SubagentConversation.tsx b/client/src/components/Chat/Subagents/SubagentConversation.tsx
index 29982d619e..e9da61eeda 100644
--- a/client/src/components/Chat/Subagents/SubagentConversation.tsx
+++ b/client/src/components/Chat/Subagents/SubagentConversation.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
+import { ChevronDown, CornerDownRight, Radio } from 'lucide-react';
import { ContentTypes, EModelEndpoint } from 'librechat-data-provider';
-import { Bot, ChevronDown, CornerDownRight, Radio } from 'lucide-react';
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@librechat/client';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { ReactNode } from 'react';
@@ -9,12 +9,14 @@ import type { ChildConversationTurn } from './adapters';
import type { TranslationKeys } from '~/hooks';
import { SubagentActivityContent, SubagentStatus } from './SubagentActivity';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
+import { isAbnormalTerminalStatus, isLiveSubagentStatus } from './status';
+import { messageFooterClasses } from '~/components/Chat/Messages/styles';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import { ElapsedTimer } from '~/components/Chat/Messages/Elapsed';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
-import { isAbnormalTerminalStatus } from './status';
import { useAgentsMapContext } from '~/Providers';
import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
import store from '~/store';
const TRIGGER_LABELS = {
@@ -201,14 +203,21 @@ function ChildMessage({
} else {
limitedNotice = localize('com_ui_subagent_activity_details_unavailable');
}
- let footer: ReactNode = null;
+ let footerContent: ReactNode = null;
if (isAbnormalTerminalStatus(turn.activity.status)) {
- footer = ;
- } else if (turn.activity.status === 'running' || turn.activity.status === 'dispatched') {
+ footerContent = ;
+ } else if (isLiveSubagentStatus(turn.activity.status)) {
const triggeredAt = turn.trigger.createdAt ?? turn.trigger.externalEvent?.occurredAt;
const startedAt = triggeredAt == null ? NaN : Date.parse(triggeredAt);
- footer = ;
+ footerContent = ;
}
+ /** The main chat footer's own metrics, held whether or not anything occupies
+ * the slot: the timer leaving at completion must not step the turns below it
+ * upward, and the reading has to be sized by the same `text-xs` its main
+ * chat counterpart inherits rather than by the panel's body size. */
+ const footer = (
+ {footerContent}
+ );
const iconData = {
endpoint: EModelEndpoint.agents,
modelLabel: label,
@@ -217,15 +226,10 @@ function ChildMessage({
return (
-
-
- ) : (
-
- )
- }
+ /** The main chat author glyph, unconditionally: with no resolved agent it
+ * falls back to the endpoint icon there too, so an unresolved child does
+ * not get a differently-inset placeholder of its own. */
+ icon={}
label={label}
footer={footer}
ariaLabel={label}
@@ -239,6 +243,7 @@ function ChildMessage({
state={state}
showPrompt={false}
conversationId={conversationId}
+ underHeaderIcon
onCancelControl={onCancelControl}
/>
{detailsLimited && detailState !== 'loading' && (
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
index 51e1dfbf65..244a3fd4a1 100644
--- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
@@ -15,6 +15,8 @@ import {
} from '~/store/subagents';
import { initSubagentAggregatorState, initSubagentTickerState } from '~/utils/subagentContent';
import SubagentThreadPanel from './SubagentThreadPanel';
+import { getDraft } from '~/utils';
+import store from '~/store';
const mockUseSubagentThreadQuery = jest.fn();
const mockUseSubagentActivityStream = jest.fn();
@@ -75,7 +77,10 @@ jest.mock('~/data-provider/Subagents/useSubagentActivityStream', () => ({
}));
jest.mock('~/hooks', () => ({
- useFocusTrap: jest.fn(),
+ /** The real trap: its Escape listener is native and sits on the panel, so it
+ * runs before the panel's own React handler. Stubbing it made the panel's
+ * Escape assertions pass in both directions. */
+ useFocusTrap: jest.requireActual('~/hooks/useFocusTrap').default,
useLocalize: () => (key: string) => key,
useNavigateToConvo: () => ({ navigateToConvo: mockNavigateToConvo }),
}));
@@ -197,69 +202,140 @@ jest.mock('./SubagentConversation', () => ({
),
}));
-jest.mock('@librechat/client', () => {
- const mockReact = jest.requireActual('react');
- const MockSelectContext = mockReact.createContext((_value: string): void => {});
- return {
- Alert: ({ children, ...props }: React.ComponentProps<'div'>) => (
-
- {children}
-
- ),
- Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
-
- ),
- Select: ({
- children,
- onValueChange,
- }: {
- children: React.ReactNode;
- onValueChange: (value: string) => void;
- }) => {children},
- SelectTrigger: ({ children, ...props }: React.ComponentProps<'button'>) => (
-