diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx index f0cebe7393..0d786a21a5 100644 --- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx @@ -1,22 +1,53 @@ -import { useId, useCallback, useEffect, useRef, useState } from 'react'; +import { useId, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@librechat/client'; -import { ChevronDown, ListTree } from 'lucide-react'; +import { ChevronDown } 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 type { CSSProperties, ReactNode } from 'react'; +import { + useExpandCollapse, + scheduleMessageContentLayoutReconcile, + EXPAND_TRANSITION, +} from '~/hooks'; import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import { getActivityLabelText } from '~/utils/activityLabels'; import { EmptyText } from './Parts'; import Container from './Container'; import { cn } from '~/utils'; +/** Matches `EXPAND_TRANSITION` so the header, the panel, and the card chrome + * all resolve on the same curve — three properties animating on two different + * easings is what makes a fold read as two separate movements. */ +const FOLD_EASING = 'duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]'; + type ActivityPhasePart = Extract & { activity_label_type?: 'phase'; activity_start_index?: number; activity_end_index?: number; }; +/** + * Runs `callback` once the browser has painted the current styles. One frame + * is not enough: React can flush passive effects before paint, and a start + * value the compositor never saw produces an instant jump rather than a + * transition. Returns a canceller for whichever frame is still pending. + */ +function schedulePostPaint(callback: () => void): () => void { + let frameId: number | undefined; + frameId = window.requestAnimationFrame(() => { + frameId = window.requestAnimationFrame(() => { + frameId = undefined; + callback(); + }); + }); + return () => { + if (frameId != null) { + window.cancelAnimationFrame(frameId); + frameId = undefined; + } + }; +} + export default function ActivityPhaseGroup({ labelPart, children, @@ -35,38 +66,43 @@ export default function ActivityPhaseGroup({ 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. */ + * already-scheduled fold 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); + /** A filled phase marker lands on top of activity the reader is already + * looking at. The card therefore mounts in the shape of what was there + * BEFORE it — header at zero height, panel open, chrome transparent — and + * folds into the summary on the next painted frame. Growing the header + * while the panel collapses keeps the block's height strictly decreasing, + * so the content compresses upward instead of being shoved down by a + * header that appeared underneath it and then yanked back up. */ + const foldsIn = shouldAnimateEntrance && hasContent; + const [isExpanded, setIsExpanded] = useState(foldsIn); + const [isSettled, setIsSettled] = useState(!foldsIn); const rootRef = useRef(null); const panelId = useId(); - const collapseFrameRef = useRef(); + const cancelEntranceRef = useRef<(() => void) | null>(null); 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) { + if (!foldsIn || userOverrideRef.current) { return; } - collapseFrameRef.current = window.requestAnimationFrame(() => { - collapseFrameRef.current = undefined; - if (!userOverrideRef.current) { - setIsExpanded(false); + cancelEntranceRef.current = schedulePostPaint(() => { + cancelEntranceRef.current = null; + if (userOverrideRef.current) { + return; } + setIsSettled(true); + setIsExpanded(false); }); return () => { - if (collapseFrameRef.current != null) { - window.cancelAnimationFrame(collapseFrameRef.current); - collapseFrameRef.current = undefined; - } + cancelEntranceRef.current?.(); + cancelEntranceRef.current = null; }; - }, [shouldAnimateEntrance, hasContent]); + }, [foldsIn]); useEffect(() => { const wasExpanded = previousIsExpandedRef.current; @@ -86,12 +122,26 @@ export default function ActivityPhaseGroup({ const handleToggle = useCallback(() => { userOverrideRef.current = true; - if (collapseFrameRef.current != null) { - window.cancelAnimationFrame(collapseFrameRef.current); - collapseFrameRef.current = undefined; - } + cancelEntranceRef.current?.(); + cancelEntranceRef.current = null; + setIsSettled(true); setIsExpanded((expanded) => !expanded); }, []); + + /** Only the folding entrance drives the header off its natural height. + * History and reduced-motion render the plain, unstyled row. */ + const headerStyle = useMemo(() => { + if (!foldsIn) { + return undefined; + } + return { + display: 'grid', + gridTemplateRows: isSettled ? '1fr' : '0fr', + transition: EXPAND_TRANSITION, + opacity: isSettled ? 1 : 0, + }; + }, [foldsIn, isSettled]); + const cursor = showCursor ? ( @@ -103,18 +153,13 @@ export default function ActivityPhaseGroup({ const group = !hasContent ? (
-