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))} + ); 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'>) => ( - + ), + Skeleton: () => null, + ControlCombobox: ({ + items, + setValue, + ariaLabel, + displayValue, + onOpenChange, + disabled, + }: { + items: { value: string; label: string }[]; + setValue: (value: string) => void; + ariaLabel: string; + displayValue?: string; + onOpenChange?: (open: boolean) => void; + disabled?: boolean; + }) => ( +
+ - ), - SelectValue: () => null, - SelectContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - SelectItem: ({ - value, - children, - ...props - }: React.ComponentProps<'button'> & { value: string }) => { - const onValueChange = mockReact.useContext(MockSelectContext); - return ( - - ); - }, - composerSurfaceClasses: () => '', - composerSurfaceShadow: { focused: '', blurred: '', within: '' }, - TextareaAutosize: ({ - minRows: _minRows, - maxRows: _maxRows, - ...props - }: React.ComponentProps<'textarea'> & { minRows?: number; maxRows?: number }) => ( -