🎛️ fix: Settle New Turns at the Bottom, Steady Status Dots, and Elapsed Time in the Panel (#15394)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* 🎛️ 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
This commit is contained in:
Danny Avila 2026-08-30 23:11:00 -04:00 • committed by GitHub
parent 14d4f2789d
commit 044894e8bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 146 additions and 68 deletions

View file

@ -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 <ElapsedTimer start={submissionStart ?? undefined} />;
});
/** 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(() => {

View file

@ -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({
</span>
) : null}
</span>
<span className="flex items-center gap-1 text-xs text-text-secondary">
<StatusIcon
size={14}
className={child.status === 'running' ? 'animate-spin' : undefined}
{/* 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. */}
<span className="flex w-24 shrink-0 items-center justify-end gap-1.5 text-xs text-text-secondary">
<span
className={cn(
'h-2 w-2 shrink-0 rounded-full border border-border-heavy/40',
subagentStatusDotClass(child.status),
)}
aria-hidden="true"
/>
{localize(subagentStatusLabelKey(child.status))}
<span className="truncate">{localize(subagentStatusLabelKey(child.status))}</span>
</span>
</button>
);

View file

@ -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(
<SubagentActivity
activity={{
@ -307,7 +307,9 @@ describe('SubagentActivity', () => {
);
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();
});

View file

@ -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')}
</div>
)}
{showDetailTruncationNotice && activityDetailsTruncated && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_activity_details_truncated')}
</div>
)}
{body}
</div>
);

View file

@ -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', {

View file

@ -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 = (
<Button type="button" variant="ghost" size="sm" onClick={onLoadDetails}>
{detailState === 'error'
@ -202,15 +198,16 @@ function ChildMessage({
: localize('com_ui_subagent_show_full_activity')}
</Button>
);
} 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 = (
<span className="italic">{localize('com_ui_subagent_activity_details_truncated')}</span>
);
limitedNotice = localize('com_ui_subagent_activity_details_unavailable');
}
let footer: ReactNode = null;
if (isAbnormalTerminalStatus(turn.activity.status)) {
footer = <SubagentStatus activity={turn.activity} />;
} 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 = <ElapsedTimer start={Number.isFinite(startedAt) ? startedAt : undefined} />;
}
const iconData = {
endpoint: EModelEndpoint.agents,
@ -230,11 +227,7 @@ function ChildMessage({
)
}
label={label}
footer={
isAbnormalTerminalStatus(turn.activity.status) ? (
<SubagentStatus activity={turn.activity} />
) : 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}
/>

View file

@ -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(
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, missingSelection)}>
<SubagentThreadPanel selection={missingSelection} />
</RecoilRoot>,
);
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,

View file

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

View file

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

View file

@ -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",