mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🪄 feat: Smooth Activity Phase Transitions (#14832)
* feat: Animate activity phase transitions
* style: Match activity phase formatting
* 🪄 fix: Fold activity phase entrance in one direction, flush-left label
The phase header replaced <summary> with <button>, which brought the UA
`text-align: center` with it — the label span is `flex-1`, so the text
filled the row and centered inside it. Left-align it and drop the leading
glyph: the card's border and fill already carry the weight, and the child
tool groups keep their own icons.
The entrance also read as two movements. The card, header and inset all
hard-cut in at full size, displacing the transcript below by ~57px, then
folded back up past the header that had just pushed it down. The card now
mounts in the shape of what was already on screen — zero-height header,
transparent chrome, no inset — and grows the header as the panel collapses,
so the block's height only ever decreases. Chrome, padding and both heights
share one curve.
The collapse also waits for a painted start value; a single rAF can land
before paint, and a start value the compositor never saw snaps rather than
transitions.
- Restore the e2e parent-phase selectors, which still matched `summary`
- Memoize the hoisted `groupActivityPhases` pass and its phase-index set
- Finish the amber -> `text-text-warning` sweep in ToolCallGroup and Part
* 🩹 fix: Scope phase-entrance history and resolve media queries at mount
Addresses both Codex findings on #14832.
`MultiMessage` renders siblings without a key, so `ContentParts` survives a
sibling switch with its refs intact. The recorded phase-marker set outlived
the message it described, and any phase in the newly selected sibling whose
index was absent from the previous sibling's set was read as a live arrival —
already-loaded history mounted expanded and collapsed itself. Scope the set
to its messageId and treat a mismatch as a fresh mount.
`useMediaQuery` initialized to `false` and resolved only in a passive effect,
so the first render always reported "no match". Anything branching once at
mount — the frozen entrance flag here, and every other first-paint decision
across its call sites — never saw the correction, which is how a
`prefers-reduced-motion: reduce` user still got the fold. Read the query
synchronously in the state initializer and guard both paths for environments
without `matchMedia`.
* ♿ fix: Honor reduced motion on manual phase disclosure
The entrance already respected the preference, but manually opening or
closing a phase did not: `useExpandCollapse` writes its transition as an
inline style, which cannot carry a `prefers-reduced-motion` media query,
and there is no global reduced-motion reset in the stylesheet. Before this
PR the phase used `<details>`, which had no animation at all — so the swap
to an animated disclosure handed reduced-motion readers a 300ms fold they
did not have.
Resolve the preference in the hook and drop the transition outright. Every
expanding panel in the message content shares it, so tool calls, thinking
blocks, attachments and web-search sources are covered by the same change.
The chevron and the fold's own utility classes get `motion-reduce`
overrides, which the inline styles cannot express.
* 🩹 fix: Keep the collapse completion signal under reduced motion
`transition: none` emits no `transitionend`, and ToolCallGroup waits on
that event to drop `shouldRenderBody`. Removing the transition therefore
left every collapsed tool subtree mounted indefinitely — expensive and
stateful children retained for exactly the readers who asked for less
work, not more.
Shorten the duration to 0.01ms instead. It is imperceptible, still fires
the event, and keeps the hook the single place that knows about the
preference. Caught by Codex on 3b9bd2181d.
This commit is contained in:
parent
c06fbff475
commit
5d3edeb383
11 changed files with 631 additions and 47 deletions
|
|
@ -1,31 +1,147 @@
|
|||
import { ChevronDown, ListTree } from 'lucide-react';
|
||||
import { useId, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@librechat/client';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
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)] motion-reduce:transition-none';
|
||||
|
||||
type ActivityPhasePart = Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }> & {
|
||||
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,
|
||||
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 fold before its first animation frame. */
|
||||
const [shouldAnimateEntrance] = useState(smoothStreaming && animateEntrance && label.length > 0);
|
||||
/** 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<HTMLDivElement | null>(null);
|
||||
const panelId = useId();
|
||||
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 (!foldsIn || userOverrideRef.current) {
|
||||
return;
|
||||
}
|
||||
cancelEntranceRef.current = schedulePostPaint(() => {
|
||||
cancelEntranceRef.current = null;
|
||||
if (userOverrideRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsSettled(true);
|
||||
setIsExpanded(false);
|
||||
});
|
||||
return () => {
|
||||
cancelEntranceRef.current?.();
|
||||
cancelEntranceRef.current = null;
|
||||
};
|
||||
}, [foldsIn]);
|
||||
|
||||
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;
|
||||
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<CSSProperties | undefined>(() => {
|
||||
if (!foldsIn) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
display: 'grid',
|
||||
gridTemplateRows: isSettled ? '1fr' : '0fr',
|
||||
transition: EXPAND_TRANSITION,
|
||||
opacity: isSettled ? 1 : 0,
|
||||
};
|
||||
}, [foldsIn, isSettled]);
|
||||
|
||||
const cursor = showCursor ? (
|
||||
<Container>
|
||||
<EmptyText />
|
||||
|
|
@ -35,15 +151,16 @@ 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">
|
||||
<ListTree
|
||||
className={cn('size-4 shrink-0', hasFailure && 'text-amber-600 dark:text-amber-400')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'my-2 flex min-h-10 w-full items-center rounded-lg border border-border-light bg-surface-secondary/40 px-3 py-2 text-text-secondary',
|
||||
shouldAnimateEntrance && `animate-in fade-in-0 motion-reduce:animate-none ${FOLD_EASING}`,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-sm font-medium',
|
||||
hasFailure && 'text-amber-600 dark:text-amber-400',
|
||||
'min-w-0 flex-1 truncate text-left text-sm font-medium',
|
||||
hasFailure && 'text-text-warning',
|
||||
)}
|
||||
role="status"
|
||||
title={label}
|
||||
|
|
@ -52,32 +169,68 @@ 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"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
<div
|
||||
className={cn(
|
||||
'my-2 w-full rounded-lg border transition-colors',
|
||||
FOLD_EASING,
|
||||
isSettled ? 'border-border-light bg-surface-secondary/40' : 'border-transparent',
|
||||
)}
|
||||
ref={rootRef}
|
||||
>
|
||||
<div style={headerStyle}>
|
||||
<div className="overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="flex h-auto min-h-10 w-full items-center justify-start gap-2 rounded-lg bg-transparent px-3 py-2 text-left 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"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={panelId}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-left text-sm font-medium',
|
||||
hasFailure && 'text-text-warning',
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id={panelId}
|
||||
style={expandStyle}
|
||||
aria-hidden={!isExpanded}
|
||||
data-testid="activity-phase-panel"
|
||||
>
|
||||
<ListTree
|
||||
className={cn('size-4 shrink-0', hasFailure && 'text-amber-600 dark:text-amber-400')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-sm font-medium',
|
||||
hasFailure && 'text-amber-600 dark:text-amber-400',
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="size-4 shrink-0 transition-transform duration-200 group-open/activity-phase:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
<div className="border-t border-border-light px-3 py-2">{children}</div>
|
||||
</details>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
{/** Padding and the divider ride the same curve as the fold: the
|
||||
* children occupy the exact position they held before the marker
|
||||
* arrived and settle into the card as it materializes, instead of
|
||||
* stepping sideways by the card's inset on the first frame. */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-t transition-[border-color,padding]',
|
||||
FOLD_EASING,
|
||||
isSettled ? 'border-border-light px-3 py-2' : 'border-transparent px-0 py-0',
|
||||
)}
|
||||
>
|
||||
{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,37 @@ const ContentParts = memo(function ContentParts({
|
|||
(localIndex: number) => contentIndices?.[localIndex] ?? localIndex + contentIndexOffset,
|
||||
[contentIndexOffset, contentIndices],
|
||||
);
|
||||
/** Hoisted above the early returns to feed the entrance-detection hook
|
||||
* below, so it is memoized rather than re-walked on every unrelated
|
||||
* re-render of a message that has no phases at all. */
|
||||
const phaseSegments = useMemo(
|
||||
() => (nestedActivityPhase ? undefined : groupActivityPhases(content)),
|
||||
[nestedActivityPhase, content],
|
||||
);
|
||||
const completedPhaseIndices = useMemo(() => {
|
||||
const indices = new Set<number>();
|
||||
for (const segment of phaseSegments ?? []) {
|
||||
if (segment.type === 'phase') {
|
||||
indices.add(segment.labelIndex);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}, [phaseSegments]);
|
||||
/** 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.
|
||||
*
|
||||
* The recorded set is scoped to the message it described. `MultiMessage`
|
||||
* renders siblings without a key, so this instance survives a sibling
|
||||
* switch with its refs intact — an unscoped set would report the previous
|
||||
* sibling's phases and animate the newly selected sibling's history. */
|
||||
const previousPhaseRef = useRef<{ messageId: string; indices: Set<number> } | null>(null);
|
||||
const previousPhaseIndices =
|
||||
previousPhaseRef.current?.messageId === messageId ? previousPhaseRef.current.indices : null;
|
||||
useEffect(() => {
|
||||
previousPhaseRef.current = { messageId, indices: completedPhaseIndices };
|
||||
}, [messageId, completedPhaseIndices]);
|
||||
|
||||
const handleGroupExpansionChange = useCallback(
|
||||
(groupId: string, state: ToolCallGroupExpansionState) => {
|
||||
|
|
@ -452,7 +483,6 @@ const ContentParts = memo(function ContentParts({
|
|||
);
|
||||
}
|
||||
|
||||
const phaseSegments = nestedActivityPhase ? undefined : groupActivityPhases(content);
|
||||
if (phaseSegments != null) {
|
||||
const relativeGlobalLastContentIdx = lastVisibleContentIdx(content ?? []);
|
||||
const globalLastContentIdx =
|
||||
|
|
@ -500,6 +530,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 &&
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ const Part = memo(function Part({
|
|||
const failed = part.status === 'failed' || part.status === 'partial';
|
||||
return (
|
||||
<div
|
||||
className={`my-1 break-words pl-1 text-sm italic ${failed ? 'text-amber-600 dark:text-amber-400' : 'text-text-secondary'}`}
|
||||
className={`my-1 break-words pl-1 text-sm italic ${failed ? 'text-text-warning' : 'text-text-secondary'}`}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ export default function ToolCallGroup({
|
|||
<span
|
||||
className={cn(
|
||||
'tool-status-text min-w-0 truncate font-medium',
|
||||
activityFailed && 'text-amber-600 dark:text-amber-400',
|
||||
activityFailed && 'text-text-warning',
|
||||
)}
|
||||
role="status"
|
||||
title={groupLabel}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,86 @@
|
|||
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', () => {
|
||||
const expandCollapse = jest.requireActual('~/hooks/Messages/useExpandCollapse');
|
||||
return {
|
||||
useExpandCollapse: expandCollapse.default,
|
||||
EXPAND_TRANSITION: expandCollapse.EXPAND_TRANSITION,
|
||||
scheduleMessageContentLayoutReconcile: (target: HTMLElement | null) =>
|
||||
mockScheduleLayoutReconcile(target),
|
||||
};
|
||||
});
|
||||
|
||||
const LABEL = 'Compared both release paths';
|
||||
|
||||
const labelPart = {
|
||||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
[ContentTypes.ACTIVITY_LABEL]: 'Compared both release paths',
|
||||
[ContentTypes.ACTIVITY_LABEL]: LABEL,
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
pending: false,
|
||||
} as unknown as Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }>;
|
||||
|
||||
describe('ActivityPhaseGroup', () => {
|
||||
let frames: Array<FrameRequestCallback | undefined>;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
|
||||
/** The fold waits for a painted start value, so it spans more than one
|
||||
* frame. Drain the queue the way a real compositor would. */
|
||||
const flushFrames = () =>
|
||||
act(() => {
|
||||
for (let index = 0; index < frames.length; index += 1) {
|
||||
const frame = frames[index];
|
||||
frames[index] = undefined;
|
||||
frame?.(index);
|
||||
}
|
||||
});
|
||||
|
||||
const pendingFrames = () => frames.filter((frame) => frame != null).length;
|
||||
|
||||
beforeEach(() => {
|
||||
frames = [];
|
||||
mockUseSmoothStreaming.mockReturnValue(true);
|
||||
mockScheduleLayoutReconcile.mockClear();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: jest.fn((callback: FrameRequestCallback) => frames.push(callback)),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: jest.fn((handle: number) => {
|
||||
frames[handle - 1] = undefined;
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
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 +90,106 @@ describe('ActivityPhaseGroup', () => {
|
|||
|
||||
expect(container.querySelector('.result-thinking')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the label flush left with no leading glyph', () => {
|
||||
render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: LABEL });
|
||||
expect(trigger).toHaveClass('justify-start', 'text-left');
|
||||
expect(screen.getByText(LABEL)).toHaveClass('flex-1', 'text-left');
|
||||
expect(trigger.querySelectorAll('svg')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('mounts in the pre-marker shape, then folds the activity into the summary', () => {
|
||||
const { container } = render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent animateEntrance>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
const card = container.querySelector('.my-2') as HTMLElement;
|
||||
const trigger = screen.getByRole('button', { name: LABEL });
|
||||
const header = trigger.parentElement?.parentElement as HTMLElement;
|
||||
const panel = screen.getByTestId('activity-phase-panel');
|
||||
|
||||
/** Frame zero must be indistinguishable from the layout the marker
|
||||
* replaced: no chrome, no header height, activity still open. */
|
||||
expect(card).toHaveClass('border-transparent');
|
||||
expect(card).not.toHaveClass('bg-surface-secondary/40');
|
||||
expect(header).toHaveStyle({ gridTemplateRows: '0fr', opacity: '0' });
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||||
|
||||
flushFrames();
|
||||
|
||||
expect(header).toHaveStyle({ gridTemplateRows: '1fr', opacity: '1' });
|
||||
expect(card).toHaveClass('border-border-light', 'bg-surface-secondary/40');
|
||||
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: LABEL });
|
||||
const header = trigger.parentElement?.parentElement as HTMLElement;
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(header.getAttribute('style')).toBeNull();
|
||||
expect(pendingFrames()).toBe(0);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('honors the smooth-streaming preference', () => {
|
||||
mockUseSmoothStreaming.mockReturnValue(false);
|
||||
|
||||
const { container } = render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent animateEntrance>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: LABEL });
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(container.querySelector('.my-2')).toHaveClass('border-border-light');
|
||||
expect(pendingFrames()).toBe(0);
|
||||
});
|
||||
|
||||
test('a click during the entrance wins over the scheduled fold', () => {
|
||||
render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent animateEntrance>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: LABEL });
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
flushFrames();
|
||||
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('a phase without activity renders a label-only header', () => {
|
||||
render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent={false} animateEntrance>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(LABEL)).toHaveClass('text-left');
|
||||
expect(pendingFrames()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,47 @@ 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');
|
||||
});
|
||||
|
||||
test('does not replay the entrance when switching to a sibling with its own phases', () => {
|
||||
const phase = (label: string) =>
|
||||
({
|
||||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
[ContentTypes.ACTIVITY_LABEL]: label,
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_count: 1,
|
||||
pending: false,
|
||||
}) as unknown as TMessageContentParts;
|
||||
|
||||
const tool = {
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: { id: 'tool-1', name: 'search', args: {}, output: 'one' },
|
||||
} as unknown as TMessageContentParts;
|
||||
|
||||
const { rerender } = render(
|
||||
<ContentParts {...baseProps} messageId="sibling-a" content={[tool, phase('First')]} />,
|
||||
);
|
||||
expect(screen.getByTestId('activity-phase-group')).toHaveAttribute(
|
||||
'data-animate-entrance',
|
||||
'false',
|
||||
);
|
||||
|
||||
/** MultiMessage swaps siblings without a key, so this instance keeps its
|
||||
* refs while messageId and content change wholesale. The incoming
|
||||
* sibling's phase is history and must not animate. */
|
||||
rerender(
|
||||
<ContentParts {...baseProps} messageId="sibling-b" content={[tool, tool, phase('Second')]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('activity-phase-group')).toHaveAttribute(
|
||||
'data-animate-entrance',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import useExpandCollapse, {
|
||||
EXPAND_TRANSITION,
|
||||
REDUCED_MOTION_EXPAND_TRANSITION,
|
||||
} from '../useExpandCollapse';
|
||||
|
||||
function stubReducedMotion(reduce: boolean) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('prefers-reduced-motion') ? reduce : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
describe('useExpandCollapse', () => {
|
||||
const original = window.matchMedia;
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = original;
|
||||
});
|
||||
|
||||
test('animates the disclosure by default', () => {
|
||||
stubReducedMotion(false);
|
||||
|
||||
const { result } = renderHook(() => useExpandCollapse(true));
|
||||
|
||||
expect(result.current.style.transition).toBe(EXPAND_TRANSITION);
|
||||
expect(result.current.style.gridTemplateRows).toBe('1fr');
|
||||
expect(result.current.style.opacity).toBe(1);
|
||||
});
|
||||
|
||||
/** The transition is an inline style, so it cannot carry a media query.
|
||||
* Without this the panel still folds over 300ms for a reader who asked
|
||||
* the platform for no motion. */
|
||||
test('collapses the duration under prefers-reduced-motion', () => {
|
||||
stubReducedMotion(true);
|
||||
|
||||
const { result } = renderHook(() => useExpandCollapse(true));
|
||||
|
||||
expect(result.current.style.transition).toBe(REDUCED_MOTION_EXPAND_TRANSITION);
|
||||
expect(result.current.style.transition).not.toBe(EXPAND_TRANSITION);
|
||||
});
|
||||
|
||||
/** `transition: none` would emit no `transitionend`, and ToolCallGroup
|
||||
* waits on that event to unmount a collapsed body. Keeping a real (if
|
||||
* imperceptible) transition preserves that completion signal. */
|
||||
test('keeps a transition that still emits transitionend', () => {
|
||||
stubReducedMotion(true);
|
||||
|
||||
const { result } = renderHook(() => useExpandCollapse(false));
|
||||
|
||||
expect(result.current.style.transition).not.toBe('none');
|
||||
expect(result.current.style.transition).toContain('grid-template-rows');
|
||||
expect(result.current.style.transition).toContain('opacity');
|
||||
});
|
||||
|
||||
test('still collapses to a zero row under reduced motion', () => {
|
||||
stubReducedMotion(true);
|
||||
|
||||
const { result } = renderHook(() => useExpandCollapse(false));
|
||||
|
||||
expect(result.current.style.gridTemplateRows).toBe('0fr');
|
||||
expect(result.current.style.opacity).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,32 @@
|
|||
import { useRef, useLayoutEffect, useMemo } from 'react';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const EXPAND_TRANSITION =
|
||||
'grid-template-rows 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
|
||||
|
||||
/**
|
||||
* Reduced motion shortens the duration instead of removing the transition.
|
||||
* `transition: none` emits no `transitionend`, and consumers wait on that
|
||||
* event to unmount a collapsed subtree — dropping it would leave every closed
|
||||
* panel's children mounted for exactly the readers who opted out of motion.
|
||||
* 0.01ms is imperceptible and still fires.
|
||||
*/
|
||||
export const REDUCED_MOTION_EXPAND_TRANSITION =
|
||||
'grid-template-rows 0.01ms linear, opacity 0.01ms linear';
|
||||
|
||||
/**
|
||||
* The disclosure motion is an inline style, so it cannot carry a
|
||||
* `prefers-reduced-motion` media query the way a utility class can. Resolve
|
||||
* the preference here instead and drop the transition outright — every
|
||||
* expanding panel in the message content shares this hook.
|
||||
*/
|
||||
export default function useExpandCollapse(isExpanded: boolean): {
|
||||
style: CSSProperties;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
} {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
|
|
@ -27,10 +45,10 @@ export default function useExpandCollapse(isExpanded: boolean): {
|
|||
() => ({
|
||||
display: 'grid',
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
transition: EXPAND_TRANSITION,
|
||||
transition: reducedMotion ? REDUCED_MOTION_EXPAND_TRANSITION : EXPAND_TRANSITION,
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
}),
|
||||
[isExpanded],
|
||||
[isExpanded, reducedMotion],
|
||||
);
|
||||
|
||||
return { style, ref };
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ test.describe('parent activity phases', () => {
|
|||
const finalTextIndex = content.findIndex((part) => contentPartText(part).includes(finalText));
|
||||
expect(finalTextIndex).toBe(phasePart?.activity_end_index);
|
||||
|
||||
const parent = messagesView(page).locator(`summary[aria-label="${PARENT_LABEL}"]`);
|
||||
const parent = messagesView(page).getByRole('button', { name: PARENT_LABEL, exact: true });
|
||||
await expect(parent).toBeVisible({ timeout: 30000 });
|
||||
await expect(messagesView(page).getByText(finalText)).toBeVisible({ timeout: 30000 });
|
||||
await parent.click();
|
||||
|
|
@ -241,7 +241,10 @@ test.describe('parent activity phases', () => {
|
|||
).toBe(true);
|
||||
|
||||
await page.reload();
|
||||
const reloadedParent = messagesView(page).locator(`summary[aria-label="${PARENT_LABEL}"]`);
|
||||
const reloadedParent = messagesView(page).getByRole('button', {
|
||||
name: PARENT_LABEL,
|
||||
exact: true,
|
||||
});
|
||||
await expect(reloadedParent).toBeVisible({ timeout: 30000 });
|
||||
await expect(messagesView(page).getByText(finalText)).toBeVisible();
|
||||
await reloadedParent.click();
|
||||
|
|
|
|||
66
packages/client/src/hooks/__tests__/useMediaQuery.spec.tsx
Normal file
66
packages/client/src/hooks/__tests__/useMediaQuery.spec.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import useMediaQuery from '../useMediaQuery';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/** The shared setup installs a writable (not configurable) `matchMedia`, so
|
||||
* these stubs assign over it rather than redefining the property. */
|
||||
function stubMatchMedia(matches: boolean) {
|
||||
const listeners = new Set<Listener>();
|
||||
const media = {
|
||||
matches,
|
||||
addEventListener: (_event: string, listener: Listener) => listeners.add(listener),
|
||||
removeEventListener: (_event: string, listener: Listener) => listeners.delete(listener),
|
||||
};
|
||||
window.matchMedia = jest.fn(() => media) as unknown as typeof window.matchMedia;
|
||||
return (next: boolean) => {
|
||||
media.matches = next;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('useMediaQuery', () => {
|
||||
const original = window.matchMedia;
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = original;
|
||||
});
|
||||
|
||||
/** Callers that branch once at mount — freezing an entrance animation,
|
||||
* choosing a layout before paint — only ever see the first render. */
|
||||
test('reports a match on the first render, before any effect runs', () => {
|
||||
stubMatchMedia(true);
|
||||
|
||||
const { result } = renderHook(() => useMediaQuery('(prefers-reduced-motion: reduce)'));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
test('reports no match on the first render when the query does not match', () => {
|
||||
stubMatchMedia(false);
|
||||
|
||||
const { result } = renderHook(() => useMediaQuery('(prefers-reduced-motion: reduce)'));
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
test('tracks later changes to the query', () => {
|
||||
const change = stubMatchMedia(false);
|
||||
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'));
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => change(true));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
test('falls back to no match where matchMedia is unavailable', () => {
|
||||
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||
|
||||
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'));
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,25 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
function readMatches(query: string): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the query on the FIRST render rather than after a passive effect.
|
||||
* Callers that branch once at mount — freezing an entrance animation, picking
|
||||
* a layout before paint — read the deferred value as "no match" and never see
|
||||
* the correction, which is how `prefers-reduced-motion` came to be ignored.
|
||||
*/
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
const [matches, setMatches] = useState(() => readMatches(query));
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue