feat: Animate activity phase transitions

This commit is contained in:
Danny Avila 2026-08-14 09:15:53 -04:00
parent bc6392d05b
commit 9bdfb84b07
4 changed files with 252 additions and 17 deletions

View file

@ -1,7 +1,11 @@
import { useId, useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@librechat/client';
import { ChevronDown, ListTree } from 'lucide-react';
import { ContentTypes } from 'librechat-data-provider';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { useExpandCollapse, scheduleMessageContentLayoutReconcile } from '~/hooks';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import { getActivityLabelText } from '~/utils/activityLabels';
import { EmptyText } from './Parts';
import Container from './Container';
@ -18,14 +22,78 @@ export default function ActivityPhaseGroup({
children,
hasContent,
showCursor = false,
animateEntrance = false,
}: {
labelPart: ActivityPhasePart;
children: ReactNode;
hasContent: boolean;
showCursor?: boolean;
animateEntrance?: boolean;
}) {
const label = getActivityLabelText(labelPart);
const hasFailure = labelPart.status === 'failed' || labelPart.status === 'partial';
const smoothStreaming = useSmoothStreaming();
/** Capture the marker's arrival state. The parent renderer records the new
* marker after this commit; a later sibling update must not cancel the
* already-scheduled compression before its first animation frame. */
const [shouldAnimateEntrance] = useState(
smoothStreaming && animateEntrance && label.length > 0,
);
/** A newly filled phase replaces activity that was already visible in the
* transcript. Keep that content in place for the first painted frame,
* then compress it into the summary instead of replacing it with a closed
* disclosure in one jarring cut. Historical phases remain closed. */
const [isExpanded, setIsExpanded] = useState(shouldAnimateEntrance && hasContent);
const rootRef = useRef<HTMLDivElement | null>(null);
const panelId = useId();
const collapseFrameRef = useRef<number>();
const cancelLayoutReconcileRef = useRef<(() => void) | null>(null);
const previousIsExpandedRef = useRef(isExpanded);
const userOverrideRef = useRef(false);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
useEffect(() => {
if (!shouldAnimateEntrance || !hasContent || userOverrideRef.current) {
return;
}
collapseFrameRef.current = window.requestAnimationFrame(() => {
collapseFrameRef.current = undefined;
if (!userOverrideRef.current) {
setIsExpanded(false);
}
});
return () => {
if (collapseFrameRef.current != null) {
window.cancelAnimationFrame(collapseFrameRef.current);
collapseFrameRef.current = undefined;
}
};
}, [shouldAnimateEntrance, hasContent]);
useEffect(() => {
const wasExpanded = previousIsExpandedRef.current;
previousIsExpandedRef.current = isExpanded;
if (wasExpanded && !isExpanded) {
cancelLayoutReconcileRef.current?.();
cancelLayoutReconcileRef.current = scheduleMessageContentLayoutReconcile(rootRef.current);
}
}, [isExpanded]);
useEffect(
() => () => {
cancelLayoutReconcileRef.current?.();
},
[],
);
const handleToggle = useCallback(() => {
userOverrideRef.current = true;
if (collapseFrameRef.current != null) {
window.cancelAnimationFrame(collapseFrameRef.current);
collapseFrameRef.current = undefined;
}
setIsExpanded((expanded) => !expanded);
}, []);
const cursor = showCursor ? (
<Container>
<EmptyText />
@ -35,15 +103,21 @@ export default function ActivityPhaseGroup({
return <>{children}</>;
}
const group = !hasContent ? (
<div className="my-2 flex min-h-10 w-full items-center gap-2 rounded-lg border border-border-light bg-surface-secondary/40 px-3 py-2 text-text-secondary">
<div
className={cn(
'my-2 flex min-h-10 w-full items-center gap-2 rounded-lg border border-border-light bg-surface-secondary/40 px-3 py-2 text-text-secondary',
shouldAnimateEntrance &&
'duration-300 animate-in fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none',
)}
>
<ListTree
className={cn('size-4 shrink-0', hasFailure && 'text-amber-600 dark:text-amber-400')}
className={cn('size-4 shrink-0', hasFailure && 'text-text-warning')}
aria-hidden="true"
/>
<span
className={cn(
'min-w-0 flex-1 truncate text-sm font-medium',
hasFailure && 'text-amber-600 dark:text-amber-400',
hasFailure && 'text-text-warning',
)}
role="status"
title={label}
@ -52,32 +126,56 @@ export default function ActivityPhaseGroup({
</span>
</div>
) : (
<details className="group/activity-phase my-2 w-full rounded-lg border border-border-light bg-surface-secondary/40">
<summary
className="flex min-h-10 cursor-pointer list-none items-center gap-2 px-3 py-2 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary [&::-webkit-details-marker]:hidden"
<div
className="my-2 w-full rounded-lg border border-border-light bg-surface-secondary/40"
ref={rootRef}
>
<Button
variant="ghost"
type="button"
className={cn(
'flex h-auto min-h-10 w-full items-center justify-start gap-2 rounded-lg bg-transparent px-3 py-2 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',
shouldAnimateEntrance &&
'duration-300 animate-in fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none',
)}
onClick={handleToggle}
aria-expanded={isExpanded}
aria-controls={panelId}
aria-label={label}
title={label}
>
<ListTree
className={cn('size-4 shrink-0', hasFailure && 'text-amber-600 dark:text-amber-400')}
className={cn('size-4 shrink-0', hasFailure && 'text-text-warning')}
aria-hidden="true"
/>
<span
className={cn(
'min-w-0 flex-1 truncate text-sm font-medium',
hasFailure && 'text-amber-600 dark:text-amber-400',
hasFailure && 'text-text-warning',
)}
role="status"
>
{label}
</span>
<ChevronDown
className="size-4 shrink-0 transition-transform duration-200 group-open/activity-phase:rotate-180"
className={cn(
'size-4 shrink-0 transition-transform duration-200 ease-out',
isExpanded && 'rotate-180',
)}
aria-hidden="true"
/>
</summary>
<div className="border-t border-border-light px-3 py-2">{children}</div>
</details>
</Button>
<div
id={panelId}
style={expandStyle}
aria-hidden={!isExpanded}
data-testid="activity-phase-panel"
>
<div className="overflow-hidden" ref={expandRef}>
<div className="border-t border-border-light px-3 py-2">{children}</div>
</div>
</div>
</div>
);
return (
<>

View file

@ -1,4 +1,4 @@
import { memo, useRef, useMemo, useCallback, Fragment } from 'react';
import { memo, useRef, useMemo, useEffect, useCallback, Fragment } from 'react';
import { ContentTypes } from 'librechat-data-provider';
import type {
TMessageContentParts,
@ -215,6 +215,20 @@ const ContentParts = memo(function ContentParts({
(localIndex: number) => contentIndices?.[localIndex] ?? localIndex + contentIndexOffset,
[contentIndexOffset, contentIndices],
);
const phaseSegments = nestedActivityPhase ? undefined : groupActivityPhases(content);
const completedPhaseIndices = new Set(
phaseSegments?.flatMap((segment) => (segment.type === 'phase' ? [segment.labelIndex] : [])) ??
[],
);
/** A phase label can finish after the root text stream settles, so
* `isSubmitting` is not a reliable entrance signal. Compare committed
* phase markers instead: a marker that appears after this renderer has
* mounted is live; markers present on the first render are history. */
const previousPhaseIndicesRef = useRef<Set<number> | null>(null);
const previousPhaseIndices = previousPhaseIndicesRef.current;
useEffect(() => {
previousPhaseIndicesRef.current = completedPhaseIndices;
});
const handleGroupExpansionChange = useCallback(
(groupId: string, state: ToolCallGroupExpansionState) => {
@ -452,7 +466,6 @@ const ContentParts = memo(function ContentParts({
);
}
const phaseSegments = nestedActivityPhase ? undefined : groupActivityPhases(content);
if (phaseSegments != null) {
const relativeGlobalLastContentIdx = lastVisibleContentIdx(content ?? []);
const globalLastContentIdx =
@ -500,6 +513,9 @@ const ContentParts = memo(function ContentParts({
key={`activity-phase-${messageId}-${segment.labelIndex}`}
labelPart={segment.labelPart}
hasContent={segment.hasContent}
animateEntrance={
previousPhaseIndices != null && !previousPhaseIndices.has(segment.labelIndex)
}
showCursor={
isLast &&
effectiveIsSubmitting &&

View file

@ -1,8 +1,24 @@
import { render } from '@testing-library/react';
import { ContentTypes } from 'librechat-data-provider';
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { TMessageContentParts } from 'librechat-data-provider';
import ActivityPhaseGroup from '../ActivityPhaseGroup';
const mockUseSmoothStreaming = jest.fn(() => true);
const mockScheduleLayoutReconcile = jest.fn((_target: HTMLElement | null) => jest.fn());
jest.mock('~/hooks/Messages/useSmoothStreaming', () => ({
__esModule: true,
default: () => mockUseSmoothStreaming(),
}));
jest.mock('~/hooks', () => {
return {
useExpandCollapse: jest.requireActual('~/hooks/Messages/useExpandCollapse').default,
scheduleMessageContentLayoutReconcile: (target: HTMLElement | null) =>
mockScheduleLayoutReconcile(target),
};
});
const labelPart = {
type: ContentTypes.ACTIVITY_LABEL,
[ContentTypes.ACTIVITY_LABEL]: 'Compared both release paths',
@ -12,6 +28,43 @@ const labelPart = {
} as unknown as Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }>;
describe('ActivityPhaseGroup', () => {
let collapseFrame: FrameRequestCallback | undefined;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
beforeEach(() => {
collapseFrame = undefined;
mockUseSmoothStreaming.mockReturnValue(true);
mockScheduleLayoutReconcile.mockClear();
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: jest.fn((callback: FrameRequestCallback) => {
collapseFrame = callback;
return 1;
}),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: jest.fn(),
});
});
afterEach(() => {
jest.restoreAllMocks();
if (originalRequestAnimationFrame == null) {
Reflect.deleteProperty(window, 'requestAnimationFrame');
} else {
window.requestAnimationFrame = originalRequestAnimationFrame;
}
if (originalCancelAnimationFrame == null) {
Reflect.deleteProperty(window, 'cancelAnimationFrame');
} else {
window.cancelAnimationFrame = originalCancelAnimationFrame;
}
});
test('renders a streaming cursor after an active tail phase', () => {
const { container } = render(
<ActivityPhaseGroup labelPart={labelPart} hasContent showCursor>
@ -21,4 +74,55 @@ describe('ActivityPhaseGroup', () => {
expect(container.querySelector('.result-thinking')).toBeInTheDocument();
});
test('compresses visible activity into a newly streamed parent label', () => {
render(
<ActivityPhaseGroup labelPart={labelPart} hasContent animateEntrance>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
const trigger = screen.getByRole('button', { name: 'Compared both release paths' });
const panel = screen.getByTestId('activity-phase-panel');
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(panel).toHaveAttribute('aria-hidden', 'false');
expect(trigger).toHaveClass('animate-in', 'fade-in-0', 'slide-in-from-bottom-1');
act(() => collapseFrame?.(0));
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(panel).toHaveAttribute('aria-hidden', 'true');
expect(mockScheduleLayoutReconcile).toHaveBeenCalledTimes(1);
});
test('keeps historical phases closed without replaying the entrance', () => {
render(
<ActivityPhaseGroup labelPart={labelPart} hasContent>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
const trigger = screen.getByRole('button', { name: 'Compared both release paths' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(trigger).not.toHaveClass('animate-in');
expect(collapseFrame).toBeUndefined();
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
test('honors the smooth-streaming preference', () => {
mockUseSmoothStreaming.mockReturnValue(false);
render(
<ActivityPhaseGroup labelPart={labelPart} hasContent animateEntrance>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
const trigger = screen.getByRole('button', { name: 'Compared both release paths' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(trigger).not.toHaveClass('animate-in');
expect(collapseFrame).toBeUndefined();
});
});

View file

@ -69,8 +69,20 @@ jest.mock('../ToolCallGroup', () => ({
jest.mock('../ActivityPhaseGroup', () => ({
__esModule: true,
default: ({ children, showCursor }: { children: React.ReactNode; showCursor?: boolean }) => (
<div data-testid="activity-phase-group" data-show-cursor={String(showCursor === true)}>
default: ({
children,
showCursor,
animateEntrance,
}: {
children: React.ReactNode;
showCursor?: boolean;
animateEntrance?: boolean;
}) => (
<div
data-testid="activity-phase-group"
data-show-cursor={String(showCursor === true)}
data-animate-entrance={String(animateEntrance === true)}
>
{children}
</div>
),
@ -350,6 +362,7 @@ describe('ContentParts — activity phase state', () => {
const parent = screen.getByTestId('activity-phase-group');
const finalPart = screen.getByTestId(`real-part-${ContentTypes.TEXT}`);
expect(parent.compareDocumentPosition(finalPart)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(parent).toHaveAttribute('data-animate-entrance', 'false');
expect(finalPart).toHaveAttribute('data-index', '2');
});
@ -448,6 +461,10 @@ describe('ContentParts — activity phase state', () => {
rerender(<ContentParts {...baseProps} content={[...tools, completedPhase]} />);
expect(screen.getByTestId('activity-phase-group')).toBeInTheDocument();
expect(screen.getByTestId('activity-phase-group')).toHaveAttribute(
'data-animate-entrance',
'true',
);
expect(screen.getByTestId('tool-call-group')).toHaveAttribute('data-initial-expanded', 'false');
});
});