Skip to content

Commit 2d5c856

Browse files
fix(playground): animate memory panel resizing (#19337)
Opening Observational Memory details resized the shared agent panel to 50% immediately, which made the layout visibly jump. https://github.com/user-attachments/assets/8ef2ba3d-ed68-4570-a1ce-f16cb53812f5 ## Summary - Add a shared `PanelGroup` that couples smooth programmatic resize behavior with its colocated stylesheet. - Animate panel width through `flex-grow` and `flex-basis`, keep pointer-driven resizing immediate, and honor reduced-motion preferences entirely in CSS. - Use the panel width as the only content transition: collapsible content stays mounted and clipped while the panel shrinks, then becomes statically hidden at zero width. - Remove the separate collapsible-content fade/slide state, custom event subscriptions, `matchMedia` handling, mount-ready DOM mutation, and the panel-resize `requestAnimationFrame`. - Keep the transition rules loaded only by the `resize/panel-group` package entry. - Preserve the existing Observational Memory 50% expansion and previous-width restoration behavior. ## Validation - Playground UI typecheck and focused collapsible-panel tests - Playground UI and Playground production builds - Targeted ESLint and Prettier checks - React Doctor: 100/100 for both changed packages - Confirmed the transition selector is absent from global `dist/style.css` and emitted only in `dist/panel-group.css` - Real-browser verification: Observational Memory expands from 300px to about 583px and restores to 300px; workflow content is progressively clipped through the width transition and hidden only at zero; persisted collapsed state does not flash; no console errors <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 When opening Observational Memory details, the side panel now smoothly grows instead of suddenly jumping to a new size. The change also respects reduced-motion preferences and keeps existing resize and restore behavior intact. ## Summary - Added a shared `PanelGroup` with scoped CSS for smooth programmatic panel resizing. - Preserved immediate pointer-driven resizing and 50% Observational Memory expansion/restoration. - Honors `prefers-reduced-motion` and disables transitions during active separator dragging. - Simplified collapsible panel behavior by removing redundant transition, mount, event, and animation-frame logic. - Applied `PanelGroup` to agent, workflow, and topics layouts. - Updated affected tests and added a patch changeset for `@mastra/playground-ui`. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 1781612 commit 2d5c856

10 files changed

Lines changed: 65 additions & 127 deletions

File tree

.changeset/slimy-cloths-grab.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/playground-ui': patch
3+
---
4+
5+
Improved side panel resizing so programmatic layout changes animate smoothly and respect reduced-motion preferences.

packages/playground-ui/src/ee/signals/components/__tests__/signal-details-page.test.tsx

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,21 +98,6 @@ function useLiveDataHandlers() {
9898
}
9999

100100
beforeAll(() => {
101-
// jsdom does not implement matchMedia; CollapsiblePanel reads it to detect the
102-
// reduced-motion preference. Provide a minimal stub so the real first-party
103-
// layout components render without a third-party DOM API gap.
104-
window.matchMedia ??= (query: string) =>
105-
({
106-
matches: false,
107-
media: query,
108-
onchange: null,
109-
addEventListener: () => {},
110-
removeEventListener: () => {},
111-
addListener: () => {},
112-
removeListener: () => {},
113-
dispatchEvent: () => false,
114-
}) as unknown as MediaQueryList;
115-
116101
server.listen({ onUnhandledRequest: 'error' });
117102
});
118103

packages/playground-ui/src/ee/topics/components/topics-layout.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Panel, Group } from 'react-resizable-panels';
1+
import { Panel } from 'react-resizable-panels';
22
import { CollapsiblePanel } from '@/lib/resize/collapsible-panel';
3+
import { PanelGroup } from '@/lib/resize/panel-group';
34
import { PanelSeparator } from '@/lib/resize/separator';
45

56
export interface TopicsLayoutProps {
@@ -17,7 +18,7 @@ export function TopicsLayout({ sidebar, children, tracePanel, contentPadding = t
1718
{sidebar ? <aside className="w-88 min-h-0 shrink-0 border-r border-border1">{sidebar}</aside> : null}
1819
{hasContent ? (
1920
<main className={contentPadding ? 'min-w-0 flex-1 p-4' : 'min-w-0 flex-1'}>
20-
<Group className="size-full min-h-0 min-w-0" direction="horizontal">
21+
<PanelGroup className="size-full min-h-0 min-w-0" orientation="horizontal">
2122
{children ? (
2223
<Panel id="topic-main" className="min-w-0 pr-2" minSize={35}>
2324
{children}
@@ -40,7 +41,7 @@ export function TopicsLayout({ sidebar, children, tracePanel, contentPadding = t
4041
</CollapsiblePanel>
4142
</>
4243
) : null}
43-
</Group>
44+
</PanelGroup>
4445
</main>
4546
) : null}
4647
</div>

packages/playground-ui/src/lib/resize/collapsible-panel.test.tsx

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ vi.mock('react-resizable-panels', () => ({
2525
className?: string;
2626
collapsedSize?: number;
2727
elementRef?: Ref<HTMLDivElement>;
28-
onResize?: (size: MockPanelSize, previousSize: MockPanelSize, panel: unknown) => void;
28+
onResize?: (size: MockPanelSize, id: string | number | undefined, previousSize: MockPanelSize | undefined) => void;
2929
style?: CSSProperties;
3030
}) => {
3131
const assignRef = (node: HTMLDivElement | null) => {
@@ -43,12 +43,12 @@ vi.mock('react-resizable-panels', () => ({
4343
<button
4444
type="button"
4545
data-testid="resize-collapsed"
46-
onClick={() => onResize?.({ inPixels: collapsedSize ?? 0 }, { inPixels: 320 }, {})}
46+
onClick={() => onResize?.({ inPixels: collapsedSize ?? 0 }, undefined, undefined)}
4747
/>
4848
<button
4949
type="button"
5050
data-testid="resize-open"
51-
onClick={() => onResize?.({ inPixels: 320 }, { inPixels: collapsedSize ?? 0 }, {})}
51+
onClick={() => onResize?.({ inPixels: 320 }, undefined, { inPixels: collapsedSize ?? 0 })}
5252
/>
5353
{children}
5454
</section>
@@ -92,15 +92,6 @@ const renderPanel = (direction: 'left' | 'right' = 'left') =>
9292
describe('CollapsiblePanel', () => {
9393
beforeEach(() => {
9494
panelMocks.expand.mockReset();
95-
Object.defineProperty(window, 'matchMedia', {
96-
configurable: true,
97-
writable: true,
98-
value: vi.fn().mockReturnValue({
99-
matches: false,
100-
addEventListener: vi.fn(),
101-
removeEventListener: vi.fn(),
102-
}),
103-
});
10495
Object.defineProperty(window, 'requestAnimationFrame', {
10596
configurable: true,
10697
writable: true,
@@ -120,7 +111,7 @@ describe('CollapsiblePanel', () => {
120111
const { container } = renderPanel();
121112

122113
expect(screen.getByTestId('panel').style.overflow).toBe('hidden');
123-
expect(screen.getByTestId('panel-content').parentElement?.dataset.state).toBe('open');
114+
expect(screen.getByTestId('panel-content').parentElement?.hasAttribute('hidden')).toBe(false);
124115
expect(screen.queryByRole('button', { name: 'Expand panel' })).toBeNull();
125116
expect(container.querySelector('button[aria-hidden="true"]')).toBeNull();
126117
});
@@ -132,8 +123,7 @@ describe('CollapsiblePanel', () => {
132123

133124
const contentWrapper = screen.getByTestId('panel-content').parentElement;
134125
expect(screen.getByTestId('panel').style.overflow).toBe('visible');
135-
expect(contentWrapper?.dataset.state).toBe('collapsed');
136-
expect(contentWrapper?.getAttribute('inert')).toBe('');
126+
expect(contentWrapper?.getAttribute('hidden')).toBe('');
137127

138128
fireEvent.click(screen.getByRole('button', { name: 'Expand panel' }));
139129

packages/playground-ui/src/lib/resize/collapsible-panel.tsx

Lines changed: 8 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -19,64 +19,6 @@ const PILL_EDGE_MARGIN = 22;
1919

2020
type PanelElementRef = RefObject<HTMLDivElement | null>;
2121

22-
const usePanelSizeTransitions = (elementRef: PanelElementRef) => {
23-
const [booted, setBooted] = useState(false);
24-
const bootedRef = useRef(false);
25-
const bootScheduled = useRef(false);
26-
const enableSizeTransitionsRef = useRef<() => void>(() => {});
27-
28-
const enableSizeTransitions = useCallback(() => {
29-
enableSizeTransitionsRef.current();
30-
}, []);
31-
32-
const boot = useCallback(() => {
33-
if (bootScheduled.current) return;
34-
bootScheduled.current = true;
35-
requestAnimationFrame(() => {
36-
bootedRef.current = true;
37-
setBooted(true);
38-
enableSizeTransitionsRef.current();
39-
});
40-
}, []);
41-
42-
useEffect(() => {
43-
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
44-
const group = elementRef.current?.parentElement;
45-
if (!group) return;
46-
const panels = Array.from(group.children).filter(
47-
(child): child is HTMLElement => child instanceof HTMLElement && child.hasAttribute('data-panel'),
48-
);
49-
const separators = Array.from(group.children).filter(
50-
(child): child is HTMLElement => child instanceof HTMLElement && child.hasAttribute('data-separator'),
51-
);
52-
53-
const enable = () => {
54-
if (!bootedRef.current) return;
55-
panels.forEach(
56-
panel =>
57-
(panel.style.transition =
58-
'flex-grow 300ms var(--ease-out-custom, ease), flex-basis 300ms var(--ease-out-custom, ease)'),
59-
);
60-
};
61-
const disable = () => panels.forEach(panel => (panel.style.transition = 'none'));
62-
63-
enable();
64-
enableSizeTransitionsRef.current = enable;
65-
separators.forEach(separator => separator.addEventListener('pointerdown', disable));
66-
window.addEventListener('pointerup', enable);
67-
window.addEventListener('pointercancel', enable);
68-
return () => {
69-
enableSizeTransitionsRef.current = () => {};
70-
separators.forEach(separator => separator.removeEventListener('pointerdown', disable));
71-
window.removeEventListener('pointerup', enable);
72-
window.removeEventListener('pointercancel', enable);
73-
panels.forEach(panel => (panel.style.transition = ''));
74-
};
75-
}, [elementRef]);
76-
77-
return { booted, boot, enableSizeTransitions };
78-
};
79-
8022
const useCollapsedEdgePill = ({
8123
collapsed,
8224
direction,
@@ -112,6 +54,8 @@ const useCollapsedEdgePill = ({
11254
if (!pill) return;
11355
beginPillTracking(point);
11456
pill.style.transitionProperty = 'opacity, translate';
57+
// Re-enable top transitions after the browser commits the pill's new
58+
// starting position; otherwise it travels in from its previous position.
11559
requestAnimationFrame(() => {
11660
pill.style.transitionProperty = '';
11761
});
@@ -169,18 +113,13 @@ export const CollapsiblePanel = ({
169113
const [collapsed, setCollapsed] = useState(false);
170114
const panelRef = usePanelRef();
171115
const elementRef = useRef<HTMLDivElement | null>(null);
172-
const { booted, boot, enableSizeTransitions } = usePanelSizeTransitions(elementRef);
173116
const { endPillTracking, expandButtonRef, stripRef, pillRef, spawnPill, trackPillPosition } = useCollapsedEdgePill({
174117
collapsed,
175118
direction,
176119
elementRef,
177120
});
178121

179-
const expand = useCallback(() => {
180-
enableSizeTransitions();
181-
if (!panelRef.current) return;
182-
panelRef.current.expand();
183-
}, [enableSizeTransitions, panelRef]);
122+
const expand = () => panelRef.current?.expand();
184123

185124
const numericMinSize = typeof minSize === 'number' ? minSize : null;
186125

@@ -199,29 +138,16 @@ export const CollapsiblePanel = ({
199138
} as CSSProperties
200139
}
201140
{...props}
202-
onResize={(size, previousSize, panel) => {
203-
onResize?.(size, previousSize, panel);
141+
onResize={(size, id, previousSize) => {
142+
onResize?.(size, id, previousSize);
204143
if (typeof collapsedSize !== 'number') return;
205144
setCollapsed(size.inPixels <= collapsedSize);
206-
boot();
207145
}}
208146
>
209147
<div
210-
inert={collapsed}
211-
data-state={collapsed ? 'collapsed' : 'open'}
212-
data-direction={direction}
213-
style={{
214-
minWidth: 'var(--panel-min-w)',
215-
opacity: collapsed ? 0 : undefined,
216-
translate: collapsed ? (direction === 'left' ? '-100% 0' : '100% 0') : undefined,
217-
}}
218-
className={cn(
219-
'absolute inset-y-0 w-full overflow-hidden',
220-
'transition-[opacity,translate] duration-300 ease-out-custom motion-reduce:transition-none',
221-
'data-[direction=left]:left-0 data-[direction=right]:right-0',
222-
'data-[state=collapsed]:z-10',
223-
!booted && 'transition-none',
224-
)}
148+
hidden={collapsed}
149+
style={{ minWidth: 'var(--panel-min-w)' }}
150+
className={cn('absolute inset-y-0 w-full overflow-hidden', direction === 'left' ? 'left-0' : 'right-0')}
225151
>
226152
{children}
227153
</div>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@media (prefers-reduced-motion: no-preference) {
2+
.panel-group-resize-transition > [data-panel] {
3+
transition-property: flex-grow, flex-basis;
4+
transition-duration: var(--duration-slow, 300ms);
5+
transition-timing-function: var(--ease-out-custom, ease);
6+
}
7+
8+
.panel-group-resize-transition:has(> [data-separator='active']) > [data-panel] {
9+
transition: none;
10+
}
11+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { GroupProps } from 'react-resizable-panels';
2+
import { Group } from 'react-resizable-panels';
3+
import { cn } from '@/lib/utils';
4+
import './panel-group.css';
5+
6+
export type PanelGroupProps = GroupProps;
7+
8+
/**
9+
* A resizable panel group with smooth programmatic resizing. CSS keeps pointer
10+
* dragging immediate and disables motion when the user prefers reduced motion.
11+
*/
12+
export function PanelGroup({ className, ...props }: PanelGroupProps) {
13+
return <Group className={cn('panel-group-resize-transition', className)} {...props} />;
14+
}

packages/playground/src/domains/agents/components/__tests__/resizable-layouts.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react';
22
import type { ReactNode, Ref } from 'react';
33
import { afterEach, describe, expect, it, vi } from 'vitest';
44
import { WorkflowLayout } from '../../../workflows/components/workflow-layout';
5-
import type * as AgentsContext from '../../context';
5+
import type * as MemoryTimelineContext from '../../context/memory-timeline-context';
66
import { AgentLayout } from '../agent-layout';
77

88
const resizeLeftPanel = vi.hoisted(() => vi.fn());
@@ -57,8 +57,8 @@ vi.mock('react-resizable-panels', () => ({
5757
},
5858
}));
5959

60-
vi.mock('../../context', async () => {
61-
const actual = await vi.importActual<typeof AgentsContext>('../../context');
60+
vi.mock('../../context/memory-timeline-context', async () => {
61+
const actual = await vi.importActual<typeof MemoryTimelineContext>('../../context/memory-timeline-context');
6262

6363
return {
6464
...actual,

packages/playground/src/domains/agents/components/agent-layout.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useIsMobile } from '@mastra/playground-ui/hooks/use-is-mobile';
22
import { PanelDrawer } from '@mastra/playground-ui/resize/panel-drawer';
3+
import { PanelGroup } from '@mastra/playground-ui/resize/panel-group';
34
import { PanelSeparator } from '@mastra/playground-ui/resize/separator';
45
import { useEffect, useRef } from 'react';
5-
import { Panel, useDefaultLayout, Group } from 'react-resizable-panels';
6+
import { Panel, useDefaultLayout } from 'react-resizable-panels';
67
import type { PanelImperativeHandle } from 'react-resizable-panels';
7-
import { useMemoryTimeline } from '../context';
8+
import { useMemoryTimeline } from '../context/memory-timeline-context';
89

910
export interface AgentLayoutProps {
1011
agentId: string;
@@ -82,7 +83,11 @@ export const AgentLayout = ({
8283

8384
return (
8485
<div className="relative h-full w-full overflow-hidden">
85-
<Group className="h-full min-h-0 w-full min-w-0" defaultLayout={defaultLayout} onLayoutChange={onLayoutChange}>
86+
<PanelGroup
87+
className="h-full min-h-0 w-full min-w-0"
88+
defaultLayout={defaultLayout}
89+
onLayoutChange={onLayoutChange}
90+
>
8691
{leftSlot && (
8792
<Panel
8893
id="left-slot"
@@ -108,7 +113,7 @@ export const AgentLayout = ({
108113
</Panel>
109114
</>
110115
)}
111-
</Group>
116+
</PanelGroup>
112117
{/* Browser modal overlay - center view mode */}
113118
{browserOverlay}
114119
</div>

packages/playground/src/domains/workflows/components/workflow-layout.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useIsMobile } from '@mastra/playground-ui/hooks/use-is-mobile';
22
import { CollapsiblePanel } from '@mastra/playground-ui/resize/collapsible-panel';
33
import { PanelDrawer } from '@mastra/playground-ui/resize/panel-drawer';
4+
import { PanelGroup } from '@mastra/playground-ui/resize/panel-group';
45
import { PanelSeparator } from '@mastra/playground-ui/resize/separator';
56
import { useState } from 'react';
67
import type { CSSProperties } from 'react';
7-
import { Panel, useDefaultLayout, Group } from 'react-resizable-panels';
8+
import { Panel, useDefaultLayout } from 'react-resizable-panels';
89

910
export interface WorkflowLayoutProps {
1011
workflowId: string;
@@ -55,7 +56,7 @@ export const WorkflowLayout = ({ workflowId, children, leftSlot, rightSlot }: Wo
5556
<div className="absolute inset-0 min-w-0 overflow-y-auto">{children}</div>
5657

5758
{leftSlot && (
58-
<Group
59+
<PanelGroup
5960
className="pointer-events-none absolute inset-0 z-10 h-full min-h-0 w-full min-w-0 bg-transparent"
6061
defaultLayout={defaultLayout}
6162
onLayoutChange={onLayoutChange}
@@ -75,11 +76,11 @@ export const WorkflowLayout = ({ workflowId, children, leftSlot, rightSlot }: Wo
7576
</CollapsiblePanel>
7677
<PanelSeparator />
7778
<Panel id="left-overlay-filler" className="pointer-events-none min-w-0 bg-transparent" />
78-
</Group>
79+
</PanelGroup>
7980
)}
8081

8182
{rightSlot && (
82-
<Group
83+
<PanelGroup
8384
className="pointer-events-none absolute inset-0 z-10 h-full min-h-0 w-full min-w-0 bg-transparent"
8485
defaultLayout={defaultLayout}
8586
onLayoutChange={onLayoutChange}
@@ -98,7 +99,7 @@ export const WorkflowLayout = ({ workflowId, children, leftSlot, rightSlot }: Wo
9899
>
99100
{rightSlot}
100101
</CollapsiblePanel>
101-
</Group>
102+
</PanelGroup>
102103
)}
103104
</div>
104105
);

0 commit comments

Comments
 (0)