From 044894e8bbc74bd5b11603af68fc1cff517618d9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 23:11:00 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20fix:=20Settle=20New=20T?= =?UTF-8?q?urns=20at=20the=20Bottom,=20Steady=20Status=20Dots,=20and=20Ela?= =?UTF-8?q?psed=20Time=20in=20the=20Panel=20(#15394)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🎛️ fix: Settle New Turns at the Bottom, Steady Status Dots, and Elapsed Time in the Panel * 🎛️ fix: Anchor Panel Timers, Order Turns by Trigger Time, Keep a Non-Color Status Cue --- .../src/components/Chat/Messages/Elapsed.tsx | 11 +++- .../Subagents/EventSubagentActivityGroup.tsx | 18 +++--- .../Chat/Subagents/SubagentActivity.test.tsx | 6 +- .../Chat/Subagents/SubagentActivity.tsx | 17 ------ .../Subagents/SubagentConversation.test.tsx | 5 +- .../Chat/Subagents/SubagentConversation.tsx | 36 +++++------ .../Subagents/SubagentThreadPanel.test.tsx | 61 +++++++++++++++++++ .../Chat/Subagents/SubagentThreadPanel.tsx | 49 ++++++++++----- .../src/components/Chat/Subagents/status.ts | 10 +++ client/src/locales/en/translation.json | 1 - 10 files changed, 146 insertions(+), 68 deletions(-) diff --git a/client/src/components/Chat/Messages/Elapsed.tsx b/client/src/components/Chat/Messages/Elapsed.tsx index 62c330ce2b..2029d68598 100644 --- a/client/src/components/Chat/Messages/Elapsed.tsx +++ b/client/src/components/Chat/Messages/Elapsed.tsx @@ -45,11 +45,18 @@ export const shouldShowElapsed = ({ * so the tick never announces. */ const Elapsed = memo(function Elapsed({ index }: { index: number }) { + const submissionStart = useRecoilValue(store.submissionStartFamily(index)); + return ; +}); + +/** The bare ticker behind `Elapsed`, for surfaces whose start time does not + * come from the chat submission store (e.g. subagent turns started by their + * trigger). Falls back to mount time when no start is known. */ +export const ElapsedTimer = memo(function ElapsedTimer({ start: startAt }: { start?: number }) { const localize = useLocalize(); const { i18n } = useTranslation(); - const submissionStart = useRecoilValue(store.submissionStartFamily(index)); const [mountTime] = useState(() => Date.now()); - const start = submissionStart ?? mountTime; + const start = startAt ?? mountTime; const [seconds, setSeconds] = useState(() => elapsedSeconds(start)); useEffect(() => { diff --git a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx index 8d7c5e9cc2..6d9ff5435d 100644 --- a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx +++ b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx @@ -4,7 +4,7 @@ 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 { subagentStatusDotClass, subagentStatusLabelKey } from './status'; import { useParentSubagents } from './ParentSubagentsProvider'; import { eventSubagentSelection } from './eventSelection'; import { activeSubagentPanel } from '~/store/subagents'; @@ -185,7 +185,6 @@ function EventSubagentRows({ 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]; const label = agent?.name || child.actorId || child.title; const canOpen = child.latestTaskId != null; @@ -218,13 +217,18 @@ function EventSubagentRows({ ) : null} - - + ); diff --git a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx index d3b5df11cd..4f3fa5f425 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx @@ -287,7 +287,7 @@ describe('SubagentActivity', () => { expect(screen.getByTestId('tool-approval')).toBeInTheDocument(); }); - it('marks bounded tool details as shortened without claiming history is missing', () => { + it('renders bounded tool details without any shortening caption', () => { render( { ); expect(screen.queryByText('bounded input')).not.toBeInTheDocument(); - expect(screen.getByText('com_ui_subagent_activity_details_truncated')).toBeInTheDocument(); + expect( + screen.queryByText('com_ui_subagent_activity_details_truncated'), + ).not.toBeInTheDocument(); expect(screen.queryByText('com_ui_subagent_thread_history_truncated')).not.toBeInTheDocument(); }); diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx index 23b2e955b2..da435f03fa 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx @@ -291,21 +291,11 @@ function SubagentPrompt({ prompt }: { prompt: string }) { ); } -export const hasTruncatedActivityDetails = (activity: ChildActivity): boolean => - activity.items.some( - (item) => - (item.type === 'writing' && item.textTruncated === true) || - (item.type === 'reasoning' && item.textTruncated === true) || - (item.type === 'activity_label' && item.labelTruncated === true) || - (item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)), - ); - export function SubagentActivityContent({ activity, activityId, state = 'ready', showPrompt = true, - showDetailTruncationNotice = true, conversationId = null, onCancelControl, }: { @@ -313,14 +303,12 @@ export function SubagentActivityContent({ activityId?: string; state?: 'ready' | 'loading' | 'error'; showPrompt?: boolean; - showDetailTruncationNotice?: boolean; conversationId?: string | null; onCancelControl?: (controlId: string) => void; }) { const localize = useLocalize(); const isSubmitting = activity.status === 'running' || activity.status === 'dispatched'; const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]); - const activityDetailsTruncated = hasTruncatedActivityDetails(activity); let body: React.ReactNode; if (state === 'loading') { @@ -367,11 +355,6 @@ export function SubagentActivityContent({ {localize('com_ui_subagent_control_history_truncated')} )} - {showDetailTruncationNotice && activityDetailsTruncated && ( -
- {localize('com_ui_subagent_activity_details_truncated')} -
- )} {body} ); diff --git a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx index 8c20c728bb..3ff3a6990c 100644 --- a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx @@ -120,10 +120,13 @@ describe('SubagentConversation', () => { expect(container.querySelectorAll('.agent-turn')).toHaveLength(2); expect(container.querySelector('[data-subagent-conversation]')).toBeInTheDocument(); expect(screen.queryByText('com_ui_prompt')).not.toBeInTheDocument(); - expect(screen.getByText('com_ui_subagent_activity_details_truncated')).toBeInTheDocument(); + expect( + screen.queryByText('com_ui_subagent_activity_details_truncated'), + ).not.toBeInTheDocument(); expect( screen.queryByText('com_ui_subagent_activity_details_unavailable'), ).not.toBeInTheDocument(); + expect(screen.getByTestId('stream-elapsed')).toBeInTheDocument(); fireEvent.click( screen.getByRole('button', { diff --git a/client/src/components/Chat/Subagents/SubagentConversation.tsx b/client/src/components/Chat/Subagents/SubagentConversation.tsx index c6a7f8b4d0..29982d619e 100644 --- a/client/src/components/Chat/Subagents/SubagentConversation.tsx +++ b/client/src/components/Chat/Subagents/SubagentConversation.tsx @@ -7,13 +7,10 @@ import type { TMessageContentParts } from 'librechat-data-provider'; import type { ReactNode } from 'react'; import type { ChildConversationTurn } from './adapters'; import type { TranslationKeys } from '~/hooks'; -import { - hasTruncatedActivityDetails, - SubagentActivityContent, - SubagentStatus, -} from './SubagentActivity'; +import { SubagentActivityContent, SubagentStatus } from './SubagentActivity'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; 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'; @@ -191,10 +188,9 @@ function ChildMessage({ const agentsMap = useAgentsMapContext(); const agent = agentId == null ? undefined : agentsMap?.[agentId]; const label = agent?.name ?? turn.activity.title; - const wholeActivityTruncated = turn.activity.activityTruncated === true; - const detailsLimited = wholeActivityTruncated || hasTruncatedActivityDetails(turn.activity); + const detailsLimited = turn.activity.activityTruncated === true; let limitedNotice: ReactNode; - if (wholeActivityTruncated && onLoadDetails != null && detailState !== 'unavailable') { + if (detailsLimited && onLoadDetails != null && detailState !== 'unavailable') { limitedNotice = ( ); - } else if (wholeActivityTruncated) { - limitedNotice = localize('com_ui_subagent_activity_details_unavailable'); } else { - /** Only item-level fields were shortened for display; the run's full - * activity is otherwise present, so avoid the alarming "unavailable" - * framing there. */ - limitedNotice = ( - {localize('com_ui_subagent_activity_details_truncated')} - ); + limitedNotice = localize('com_ui_subagent_activity_details_unavailable'); + } + let footer: ReactNode = null; + if (isAbnormalTerminalStatus(turn.activity.status)) { + footer = ; + } else if (turn.activity.status === 'running' || turn.activity.status === 'dispatched') { + const triggeredAt = turn.trigger.createdAt ?? turn.trigger.externalEvent?.occurredAt; + const startedAt = triggeredAt == null ? NaN : Date.parse(triggeredAt); + footer = ; } const iconData = { endpoint: EModelEndpoint.agents, @@ -230,11 +227,7 @@ function ChildMessage({ ) } label={label} - footer={ - isAbnormalTerminalStatus(turn.activity.status) ? ( - - ) : null - } + footer={footer} ariaLabel={label} headerPrefix="" isCreatedByUser={false} @@ -245,7 +238,6 @@ function ChildMessage({ activityId={`${turn.taskId}:assistant`} state={state} showPrompt={false} - showDetailTruncationNotice={false} conversationId={conversationId} onCancelControl={onCancelControl} /> diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index 9a6d50efd7..51e1dfbf65 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -1483,6 +1483,67 @@ describe('SubagentThreadPanel', () => { expect(screen.queryByText('com_ui_subagent_depth')).not.toBeInTheDocument(); }); + it.each([ + ['newest known task', 'task-new', 'last'], + ['displaced older task', 'task-old-displaced', 'first'], + ] as const)( + 'places a turn missing from the durable window per its identity: %s', + (_label, selectedTaskId, position) => { + mockParentChildrenByThread = new Map([ + [ + 'child-thread', + { + threadId: 'child-thread', + parentMessageId: 'parent-message', + parentToolCallId: 'tool-call', + subagentType: 'researcher', + subagentKind: 'agent', + title: 'Research child', + origin: 'tool', + status: 'running', + latestTaskId: 'task-new', + tasks: [{ taskId: 'task-new', status: 'running' }], + tasksTruncated: false, + } as ParentSubagentSummary, + ], + ]); + mockUseSubagentThreadQuery.mockReturnValue({ + data: { + ...completedView, + turns: [ + { + taskId: 'task-durable', + trigger: { kind: 'parent_dispatch', summary: 'Durable window turn' }, + status: 'completed', + activity: [{ type: 'writing', text: 'Durable result' }], + activityTruncated: false, + messages: [], + }, + ], + }, + isLoading: false, + isError: false, + isReadinessPending: false, + }); + const missingSelection: ActiveSubagentPanel = { + ...selection, + prompt: 'Synthesized window turn', + durable: { threadId: 'child-thread', taskId: selectedTaskId }, + }; + render( + set(activeSubagentPanel, missingSelection)}> + + , + ); + + const turns = screen.getAllByTestId('conversation-turn'); + expect(turns).toHaveLength(2); + const synthesizedIndex = position === 'last' ? 1 : 0; + expect(turns[synthesizedIndex]).toHaveTextContent('Synthesized window turn'); + expect(turns[1 - synthesizedIndex]).toHaveTextContent('Durable window turn'); + }, + ); + it('loads an exact older turn projection only after the local disclosure is opened', async () => { const truncatedView: SubagentThreadView = { ...completedView, diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx index b39f5fa375..281ba5ca26 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx @@ -796,23 +796,38 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu return override == null ? selected : { ...selected, activity: override }; }); } - // The API keeps the exact selected activity even when its bounded - // chronological turn is the first item removed from the response. - // Preserve that selection ahead of the retained newer continuation. - const retained = [ - { - taskId: taskId || `${selection.parentMessageId}:${selection.toolCallId}`, - trigger: { - kind: - selection.event == null - ? ('parent_continuation' as const) - : ('external_event' as const), - summary: selection.prompt ?? activity.prompt ?? '', - }, - activity, + /** A turn absent from the durable window is either the thread's newest + * delivery whose fetch has not landed yet (place it at the END, where + * it will settle — a new event must not appear at the top and then + * jump to the bottom) or an older selection displaced from the bounded + * window (keep it ahead of the retained newer continuation). */ + const indexedChild = byThreadId.get(threadId); + const selectedTaskCreatedAt = indexedChild?.tasks.find( + (task) => task.taskId === taskId, + )?.createdAt; + const synthesizedTurn = { + taskId: taskId || `${selection.parentMessageId}:${selection.toolCallId}`, + trigger: { + kind: + selection.event == null + ? ('parent_continuation' as const) + : ('external_event' as const), + summary: selection.prompt ?? activity.prompt ?? '', + ...(selectedTaskCreatedAt == null ? {} : { createdAt: selectedTaskCreatedAt }), }, - ...durableTurns, - ]; + activity, + }; + /** Order by trigger time when both sides carry one; the separately + * polled discovery index is only the fallback authority, since it can + * briefly lag or lead the thread view. */ + const lastDurableCreatedAt = durableTurns[durableTurns.length - 1]?.trigger.createdAt; + const appendSynthesized = + selectedTaskCreatedAt != null && lastDurableCreatedAt != null + ? selectedTaskCreatedAt >= lastDurableCreatedAt + : indexedChild?.latestTaskId === taskId; + const retained = appendSynthesized + ? [...durableTurns, synthesizedTurn] + : [synthesizedTurn, ...durableTurns]; return retained.map((turn) => { const override = turnDetailOverrides.get(turn.taskId); return override == null ? turn : { ...turn, activity: override }; @@ -831,12 +846,14 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu ]; }, [ activity, + byThreadId, latestConversationTurns, movingWindowTurns, olderTurns, postRebaseTurns, rebaseTurns, retainedTurnsValid, + threadId, selection, taskId, turnDetailOverrides, diff --git a/client/src/components/Chat/Subagents/status.ts b/client/src/components/Chat/Subagents/status.ts index 51160caa42..951086bdd9 100644 --- a/client/src/components/Chat/Subagents/status.ts +++ b/client/src/components/Chat/Subagents/status.ts @@ -24,3 +24,13 @@ export const subagentStatusLabelKey = (status: SubagentThreadStatus) => cancelled: 'com_ui_subagent_thread_status_cancelled', }) as const )[status]; + +/** Fixed-size status dot classes: a stable-width indicator that cannot make + * rows shift as status text changes length. */ +export const subagentStatusDotClass = (status: SubagentThreadStatus): string => { + if (status === 'completed') return 'bg-status-success'; + if (status === 'running') return 'animate-pulse bg-status-info'; + if (status === 'failed' || status === 'interrupted') return 'bg-status-error'; + if (status === 'cancelled') return 'bg-status-warning'; + return 'bg-text-tertiary'; +}; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 83c279259b..7e27e2ad98 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -2233,7 +2233,6 @@ "com_ui_subagent_control_cancel_message": "Withdraw message", "com_ui_subagent_control_history": "Control history", "com_ui_subagent_control_history_truncated": "Earlier control activity is not shown.", - "com_ui_subagent_activity_details_truncated": "Some activity details were shortened.", "com_ui_subagent_activity_details_unavailable": "Additional activity details are unavailable.", "com_ui_subagent_show_full_activity": "Show full activity", "com_ui_subagent_load_earlier_activity": "Load earlier activity",