mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
feat: Animate activity phase transitions
This commit is contained in:
parent
bc6392d05b
commit
9bdfb84b07
4 changed files with 252 additions and 17 deletions
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue