diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx index 7bf86bab8f..540cc9cd4b 100644 --- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx @@ -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(null); + const panelId = useId(); + const collapseFrameRef = useRef(); + 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 ? ( @@ -35,15 +103,21 @@ export default function ActivityPhaseGroup({ return <>{children}; } const group = !hasContent ? ( -
+
) : ( -
- + -
{children}
-
+ +
+
+
{children}
+
+
+
); return ( <> diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 7656949a65..71a67abc27 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -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 | 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 && diff --git a/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx index 897b42eff0..efc2202d7b 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx @@ -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; 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( @@ -21,4 +74,55 @@ describe('ActivityPhaseGroup', () => { expect(container.querySelector('.result-thinking')).toBeInTheDocument(); }); + + test('compresses visible activity into a newly streamed parent label', () => { + render( + +
+ , + ); + + 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( + +
+ , + ); + + 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( + +
+ , + ); + + 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(); + }); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index df52bb72d7..206b3b84cc 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -69,8 +69,20 @@ jest.mock('../ToolCallGroup', () => ({ jest.mock('../ActivityPhaseGroup', () => ({ __esModule: true, - default: ({ children, showCursor }: { children: React.ReactNode; showCursor?: boolean }) => ( -
+ default: ({ + children, + showCursor, + animateEntrance, + }: { + children: React.ReactNode; + showCursor?: boolean; + animateEntrance?: boolean; + }) => ( +
{children}
), @@ -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(); 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'); }); });