diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts
index f0ca8b5dcd..23535ebfb6 100644
--- a/apps/studio/src/constants.ts
+++ b/apps/studio/src/constants.ts
@@ -5,6 +5,12 @@ export const SIDEBAR_WIDTH = 208;
export const SIDEBAR_MIN_WIDTH = 200;
export const SIDEBAR_MAX_WIDTH = 400;
export const MAIN_MIN_WIDTH = 712;
+// The agentic UI collapses to a single chat column — both the sidebar and the
+// preview panel can be closed — so it goes far narrower than the default
+// renderer's two-pane layout. The floor is what the site header still needs:
+// the macOS traffic lights, the site icon, and the Share + Sync actions, with
+// the site name free to truncate between them.
+export const AGENTIC_MIN_WIDTH = 420;
export const LOCAL_STORAGE_SIDEBAR_WIDTH_KEY = 'sidebar_width';
export const APP_CHROME_SPACING = 10;
export const MIN_WIDTH_CLASS_TO_MEASURE = 'app-measure-tabs-width';
diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts
index e8f9dee966..993256470f 100644
--- a/apps/studio/src/ipc-handlers.ts
+++ b/apps/studio/src/ipc-handlers.ts
@@ -1938,6 +1938,25 @@ export function toggleMinWindowWidth(
parentWindow.setSize( newWidth, currentHeight, true );
}
+export async function ensureMinWindowWidth(
+ event: IpcMainInvokeEvent,
+ minimumWidth: number
+): Promise< number | null > {
+ if ( ! Number.isFinite( minimumWidth ) || minimumWidth <= 0 ) {
+ return null;
+ }
+ const parentWindow = BrowserWindow.fromWebContents( event.sender );
+ if ( ! parentWindow || parentWindow.isDestroyed() || event.sender.isDestroyed() ) {
+ return null;
+ }
+ const [ currentWidth, currentHeight ] = parentWindow.getSize();
+ const nextWidth = Math.ceil( minimumWidth );
+ if ( currentWidth < nextWidth ) {
+ parentWindow.setSize( nextWidth, currentHeight );
+ }
+ return parentWindow.getSize()[ 0 ];
+}
+
/**
* Returns the absolute path of a file in the site's directory.
* Returns null if the file does not exist.
diff --git a/apps/studio/src/main-window.ts b/apps/studio/src/main-window.ts
index 0306652742..380a28b85c 100644
--- a/apps/studio/src/main-window.ts
+++ b/apps/studio/src/main-window.ts
@@ -12,6 +12,7 @@ import { portFinder } from '@studio/common/lib/port-finder';
import {
DEFAULT_HEIGHT,
DEFAULT_WIDTH,
+ AGENTIC_MIN_WIDTH,
MACOS_TRAFFIC_LIGHT_POSITION,
MAIN_MIN_HEIGHT,
MAIN_MIN_WIDTH,
@@ -90,6 +91,14 @@ async function loadRendererLocation( window: BrowserWindow, location: RendererLo
export async function loadMainWindowRenderer( window: BrowserWindow ): Promise< void > {
await loadRendererLocation( window, getRendererLocation( getPreferredStudioUiMode() ) );
+ // Switching renderers changes the floor. Growing it (agentic → default)
+ // also widens a window that is already below the new minimum.
+ const minWidth = getMinWindowWidth();
+ window.setMinimumSize( minWidth, MAIN_MIN_HEIGHT );
+ const [ width, height ] = window.getSize();
+ if ( width < minWidth ) {
+ window.setSize( minWidth, height, true );
+ }
if ( process.platform === 'win32' || process.platform === 'linux' ) {
window.setTitleBarOverlay( getTitleBarOverlayOptions() );
}
@@ -139,8 +148,14 @@ function initializePortFinder( sites: SiteDetails[] ) {
} );
}
+// Each renderer has its own floor, so the window can't be dragged narrower
+// than whichever one is on screen.
+function getMinWindowWidth(): number {
+ return getPreferredStudioUiMode() === 'agentic' ? AGENTIC_MIN_WIDTH : MAIN_MIN_WIDTH;
+}
+
function isValidWindowBounds( bounds: WindowBounds ): boolean {
- if ( bounds.width < MAIN_MIN_WIDTH || bounds.height < MAIN_MIN_HEIGHT ) {
+ if ( bounds.width < getMinWindowWidth() || bounds.height < MAIN_MIN_HEIGHT ) {
return false;
}
@@ -170,7 +185,7 @@ export async function createMainWindow(): Promise< BrowserWindow > {
width: DEFAULT_WIDTH,
backgroundColor: 'rgba(30, 30, 30, 1)',
minHeight: MAIN_MIN_HEIGHT,
- minWidth: MAIN_MIN_WIDTH,
+ minWidth: getMinWindowWidth(),
webPreferences: {
preload: path.join( __dirname, '../preload/preload.js' ),
webSecurity: process.env.NODE_ENV !== 'development',
diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts
index 319354ceec..0ef1d8ce86 100644
--- a/apps/studio/src/preload.ts
+++ b/apps/studio/src/preload.ts
@@ -138,6 +138,8 @@ const api: IpcApi = {
resetDefaultLocaleData: () => ipcRendererInvoke( 'resetDefaultLocaleData' ),
toggleMinWindowWidth: ( isSidebarVisible, currentSidebarWidth? ) =>
ipcRendererInvoke( 'toggleMinWindowWidth', isSidebarVisible, currentSidebarWidth ),
+ ensureMinWindowWidth: ( minimumWidth ) =>
+ ipcRendererInvoke( 'ensureMinWindowWidth', minimumWidth ),
getAbsolutePathFromSite: ( siteId, relativePath ) =>
ipcRendererInvoke( 'getAbsolutePathFromSite', siteId, relativePath ),
openFileInIDE: ( relativePath, siteId ) =>
diff --git a/apps/studio/src/tests/ipc-handlers.test.ts b/apps/studio/src/tests/ipc-handlers.test.ts
index 3a073744d7..3ca3dad5fb 100644
--- a/apps/studio/src/tests/ipc-handlers.test.ts
+++ b/apps/studio/src/tests/ipc-handlers.test.ts
@@ -1,7 +1,7 @@
/**
* @vitest-environment node
*/
-import { IpcMainInvokeEvent } from 'electron';
+import { BrowserWindow, IpcMainInvokeEvent } from 'electron';
import { existsSync } from 'fs';
import { normalize } from 'path';
import { resolveMigratedAiSessionsPath } from '@studio/common/ai/sessions/root-migration';
@@ -10,6 +10,7 @@ import { vol } from 'memfs';
import { vi } from 'vitest';
import {
createSite,
+ ensureMinWindowWidth,
getFileSize,
getXdebugEnabledSite,
isFullscreen,
@@ -188,6 +189,52 @@ describe( 'isFullscreen', () => {
} );
} );
+describe( 'ensureMinWindowWidth', () => {
+ it( 'grows the sender window while preserving its height', async () => {
+ const setSize = vi.fn();
+ let width = 420;
+ vi.mocked( BrowserWindow.fromWebContents ).mockReturnValueOnce( {
+ isDestroyed: () => false,
+ getSize: () => [ width, 700 ],
+ setSize: ( nextWidth: number, height: number ) => {
+ width = nextWidth;
+ setSize( nextWidth, height );
+ },
+ } as unknown as BrowserWindow );
+
+ const result = await ensureMinWindowWidth( mockIpcMainInvokeEvent, 640 );
+
+ expect( setSize ).toHaveBeenCalledWith( 640, 700 );
+ expect( result ).toBe( 640 );
+ } );
+
+ it( 'leaves an already-wide window unchanged', async () => {
+ const setSize = vi.fn();
+ vi.mocked( BrowserWindow.fromWebContents ).mockReturnValueOnce( {
+ isDestroyed: () => false,
+ getSize: () => [ 900, 700 ],
+ setSize,
+ } as unknown as BrowserWindow );
+
+ const result = await ensureMinWindowWidth( mockIpcMainInvokeEvent, 640 );
+
+ expect( setSize ).not.toHaveBeenCalled();
+ expect( result ).toBe( 900 );
+ } );
+
+ it( 'returns the width the window manager actually applied', async () => {
+ vi.mocked( BrowserWindow.fromWebContents ).mockReturnValueOnce( {
+ isDestroyed: () => false,
+ getSize: () => [ 600, 700 ],
+ setSize: vi.fn(),
+ } as unknown as BrowserWindow );
+
+ const result = await ensureMinWindowWidth( mockIpcMainInvokeEvent, 640 );
+
+ expect( result ).toBe( 600 );
+ } );
+} );
+
describe( 'getXdebugEnabledSite', () => {
it( 'should return null when no site has Xdebug enabled', async () => {
vi.mocked( SiteServer.getAllDetails ).mockReturnValue( [
diff --git a/apps/ui/src/components/preview-split-frame/index.test.tsx b/apps/ui/src/components/preview-split-frame/index.test.tsx
index c347360b5d..671a83f816 100644
--- a/apps/ui/src/components/preview-split-frame/index.test.tsx
+++ b/apps/ui/src/components/preview-split-frame/index.test.tsx
@@ -154,6 +154,60 @@ describe( 'PreviewSplitFrame', () => {
expect( screen.getByLabelText( 'Site preview' ) ).toBeVisible();
} );
+ it( 'reports the measured split width', async () => {
+ frameWidth = 639;
+ const onContainerWidthChange = vi.fn();
+
+ render(
+ }
+ onContainerWidthChange={ onContainerWidthChange }
+ >
+ Content
+
+ );
+
+ await waitFor( () => expect( onContainerWidthChange ).toHaveBeenLastCalledWith( 639 ) );
+ } );
+
+ it( 'reports no split width while the preview is closed', async () => {
+ const onContainerWidthChange = vi.fn();
+ const preview = () => ;
+ render(
+
+ Content
+
+ );
+
+ await waitFor( () => expect( onContainerWidthChange ).toHaveBeenLastCalledWith( null ) );
+ } );
+
+ it( 'reports no split width while the preview is fullscreen', async () => {
+ frameWidth = 420;
+ const onContainerWidthChange = vi.fn();
+
+ render(
+ }
+ onContainerWidthChange={ onContainerWidthChange }
+ >
+ Content
+
+ );
+
+ await waitFor( () =>
+ expect( getFrameRoot() ).toHaveStyle( '--preview-frame-content-width: 0px' )
+ );
+ expect( onContainerWidthChange ).toHaveBeenLastCalledWith( null );
+ } );
+
describe( 'keyboard and pointer resizing', () => {
async function renderOpenAndSettle() {
render(
@@ -177,8 +231,8 @@ describe( 'PreviewSplitFrame', () => {
it( 'expands the preview to its maximum width on End', async () => {
const handle = await renderOpenAndSettle();
fireEvent.keyDown( handle, { key: 'End' } );
- expect( handle ).toHaveAttribute( 'aria-valuenow', '720' );
- expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '280' );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '680' );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '320' );
} );
it( 'steps the preview width with arrow keys, using a larger step with Shift', async () => {
diff --git a/apps/ui/src/components/preview-split-frame/index.tsx b/apps/ui/src/components/preview-split-frame/index.tsx
index 0a2b5c6a6a..a7ac89f277 100644
--- a/apps/ui/src/components/preview-split-frame/index.tsx
+++ b/apps/ui/src/components/preview-split-frame/index.tsx
@@ -22,6 +22,7 @@ interface PreviewSplitFrameProps {
// Full preview: the preview takes the whole frame and the content column
// collapses to zero width (kept mounted so chat state survives).
previewFullscreen?: boolean;
+ onContainerWidthChange?: ( containerWidth: number | null ) => void;
children?: ReactNode;
}
@@ -29,13 +30,20 @@ export function PreviewSplitFrame( {
preview,
previewOpen = false,
previewFullscreen = false,
+ onContainerWidthChange,
children,
}: PreviewSplitFrameProps ) {
const showPreview = previewOpen && preview != null;
const showFullscreen = showPreview && previewFullscreen;
- const { rootRef, contentWidthVar, isResizing, handleProps } = usePreviewSplit( { showPreview } );
+ const { rootRef, containerWidth, contentWidthVar, isResizing, handleProps } = usePreviewSplit( {
+ showPreview,
+ } );
const isSidebarCollapsed = useSidebarCollapsed();
+ useEffect( () => {
+ onContainerWidthChange?.( showPreview && ! showFullscreen ? containerWidth : null );
+ }, [ containerWidth, onContainerWidthChange, showFullscreen, showPreview ] );
+
// Animate only open/close/fullscreen toggles of an already-mounted preview —
// never the initial layout, so a route loading with the preview visible
// doesn't replay the slide-in. The render-phase update lands the transition
diff --git a/apps/ui/src/components/sidebar-layout/index.test.tsx b/apps/ui/src/components/sidebar-layout/index.test.tsx
index af8e1e2edc..00231b7fbf 100644
--- a/apps/ui/src/components/sidebar-layout/index.test.tsx
+++ b/apps/ui/src/components/sidebar-layout/index.test.tsx
@@ -1,5 +1,5 @@
-import { act, render, screen } from '@testing-library/react';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { SidebarLayout } from './index';
import type { ReactNode } from 'react';
@@ -58,9 +58,12 @@ const useConnectorMock = vi.mocked( useConnector, { partial: true } );
describe( 'SidebarLayout', () => {
let toggleSidebarListener: ( () => void ) | undefined;
-
+ let originalInnerWidth: number;
beforeEach( () => {
vi.clearAllMocks();
+ vi.stubGlobal( 'ResizeObserver', undefined );
+ originalInnerWidth = window.innerWidth;
+ Object.defineProperty( window, 'innerWidth', { configurable: true, value: 1024 } );
toggleSidebarListener = undefined;
useConnectorMock.mockReturnValue( {
onToggleSidebar: vi.fn( ( listener ) => {
@@ -70,7 +73,15 @@ describe( 'SidebarLayout', () => {
} );
} );
- it( 'toggles the sidebar when the connector emits the shortcut command', () => {
+ afterEach( () => {
+ Object.defineProperty( window, 'innerWidth', {
+ configurable: true,
+ value: originalInnerWidth,
+ } );
+ vi.unstubAllGlobals();
+ } );
+
+ it( 'toggles the sidebar when the connector emits the shortcut command', async () => {
render(
Content
@@ -79,19 +90,24 @@ describe( 'SidebarLayout', () => {
expect( screen.queryByRole( 'button', { name: 'Show sidebar' } ) ).not.toBeInTheDocument();
- act( () => toggleSidebarListener?.() );
+ await act( async () => toggleSidebarListener?.() );
expect( screen.getByRole( 'button', { name: 'Show sidebar' } ) ).toBeInTheDocument();
- act( () => toggleSidebarListener?.() );
+ await act( async () => toggleSidebarListener?.() );
expect( screen.queryByRole( 'button', { name: 'Show sidebar' } ) ).not.toBeInTheDocument();
} );
it( 'hands the sidebar shortcut to the forcing feature while force-collapsed', () => {
const onForceCollapsedToggle = vi.fn();
+ const onExpand = vi.fn();
render(
-
+
Content
);
@@ -104,5 +120,72 @@ describe( 'SidebarLayout', () => {
act( () => toggleSidebarListener?.() );
expect( onForceCollapsedToggle ).toHaveBeenCalledTimes( 1 );
+ expect( onExpand ).not.toHaveBeenCalled();
+ } );
+
+ it( 'collapses when the window enters compact width', async () => {
+ render(
+
+ Content
+
+ );
+
+ Object.defineProperty( window, 'innerWidth', { configurable: true, value: 659 } );
+ await act( async () => window.dispatchEvent( new Event( 'resize' ) ) );
+
+ expect( screen.getByRole( 'button', { name: 'Show sidebar' } ) ).toBeInTheDocument();
+ } );
+
+ it( 'starts collapsed in a compact window', () => {
+ Object.defineProperty( window, 'innerWidth', { configurable: true, value: 420 } );
+
+ render(
+
+ Content
+
+ );
+
+ expect( screen.getByRole( 'button', { name: 'Show sidebar' } ) ).toBeInTheDocument();
+ } );
+
+ it( 'delegates reopening so the parent can coordinate the panels', () => {
+ Object.defineProperty( window, 'innerWidth', { configurable: true, value: 420 } );
+ const onExpand = vi.fn();
+ render(
+
+ Content
+
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Show sidebar' } ) );
+
+ expect( onExpand ).toHaveBeenCalledOnce();
+ } );
+
+ it( 'observes the rendered layout width while the window is being resized', () => {
+ let resizeCallback: ResizeObserverCallback | undefined;
+ class ResizeObserverMock {
+ constructor( callback: ResizeObserverCallback ) {
+ resizeCallback = callback;
+ }
+ observe() {}
+ disconnect() {}
+ }
+ vi.stubGlobal( 'ResizeObserver', ResizeObserverMock );
+
+ render(
+
+ Content
+
+ );
+
+ act( () => {
+ resizeCallback?.(
+ [ { contentRect: { width: 659 } } as unknown as ResizeObserverEntry ],
+ {} as ResizeObserver
+ );
+ } );
+
+ expect( screen.getByRole( 'button', { name: 'Show sidebar' } ) ).toBeInTheDocument();
} );
} );
diff --git a/apps/ui/src/components/sidebar-layout/index.tsx b/apps/ui/src/components/sidebar-layout/index.tsx
index bd994902ad..d6ed6ed65c 100644
--- a/apps/ui/src/components/sidebar-layout/index.tsx
+++ b/apps/ui/src/components/sidebar-layout/index.tsx
@@ -2,7 +2,7 @@ import { __ } from '@wordpress/i18n';
import { privateApis } from '@wordpress/theme';
import { IconButton } from '@wordpress/ui';
import { clsx } from 'clsx';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { AppMessageCards, AppMessageCardsDot } from '@/components/app-message-cards';
import { AppToasts } from '@/components/app-toasts';
import { ResizeHandle, ResizeOverlay } from '@/components/resize-handle';
@@ -16,7 +16,12 @@ import { useResizablePanel } from '@/hooks/use-resizable-panel';
import { SidebarCollapsedContext } from '@/hooks/use-sidebar-collapsed';
import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { drawerIcon } from '@/lib/icons';
-import { SIDEBAR_PANEL_CONFIG, SIDEBAR_PANEL_STORAGE_KEY } from '@/lib/resizable-panels';
+import {
+ getViewportWidth,
+ SIDEBAR_AUTO_COLLAPSE_BREAKPOINT,
+ SIDEBAR_PANEL_CONFIG,
+ SIDEBAR_PANEL_STORAGE_KEY,
+} from '@/lib/resizable-panels';
import { unlock } from '@/lock-unlock';
import styles from './style.module.css';
import type { CSSProperties, ReactNode } from 'react';
@@ -32,6 +37,9 @@ const CHROME_BG_DARK = '#161616';
interface SidebarLayoutProps {
children: ReactNode;
+ collapsed?: boolean;
+ onCollapsedChange?: ( collapsed: boolean ) => void;
+ onExpand?: () => void;
// Hides the sidebar without touching the user's own collapsed state, so
// clearing it restores whatever the sidebar was doing before (e.g. while
// the site preview is fullscreen). The floating "Show sidebar" toggle is
@@ -45,10 +53,18 @@ interface SidebarLayoutProps {
export function SidebarLayout( {
children,
+ collapsed: controlledCollapsed,
+ onCollapsedChange,
+ onExpand,
forceCollapsed = false,
onForceCollapsedToggle,
}: SidebarLayoutProps ) {
- const [ collapsed, setCollapsed ] = useState( false );
+ const [ internalCollapsed, setInternalCollapsed ] = useState(
+ () => getViewportWidth() < SIDEBAR_AUTO_COLLAPSE_BREAKPOINT
+ );
+ const collapsed = controlledCollapsed ?? internalCollapsed;
+ const wasCompactRef = useRef( getViewportWidth() < SIDEBAR_AUTO_COLLAPSE_BREAKPOINT );
+ const rootRef = useRef< HTMLDivElement >( null );
const effectiveCollapsed = collapsed || forceCollapsed;
const connector = useConnector();
const reserveTrafficLightSpace = useTrafficLightSpace().start;
@@ -59,23 +75,66 @@ export function SidebarLayout( {
edge: 'right',
storageKey: SIDEBAR_PANEL_STORAGE_KEY,
} );
+ const updateCollapsed = useCallback(
+ ( nextCollapsed: boolean ) => {
+ if ( controlledCollapsed === undefined ) {
+ setInternalCollapsed( nextCollapsed );
+ }
+ onCollapsedChange?.( nextCollapsed );
+ },
+ [ controlledCollapsed, onCollapsedChange ]
+ );
const toggleSidebar = useCallback( () => {
if ( forceCollapsed ) {
onForceCollapsedToggle?.();
- setCollapsed( false );
return;
}
- setCollapsed( ( value ) => ! value );
- }, [ forceCollapsed, onForceCollapsedToggle ] );
+ if ( collapsed ) {
+ if ( onExpand ) {
+ onExpand();
+ } else {
+ updateCollapsed( false );
+ }
+ return;
+ }
+ updateCollapsed( true );
+ }, [ collapsed, forceCollapsed, onExpand, onForceCollapsedToggle, updateCollapsed ] );
const sidebarStyle = effectiveCollapsed
? undefined
: ( { '--sidebar-width': `${ sidebarResize.width }px` } as CSSProperties );
useEffect( () => connector.onToggleSidebar( toggleSidebar ), [ connector, toggleSidebar ] );
+ useEffect( () => {
+ const collapseWhenEnteringCompactWidth = ( width: number ) => {
+ const isCompact = width < SIDEBAR_AUTO_COLLAPSE_BREAKPOINT;
+ if ( isCompact && ! wasCompactRef.current ) {
+ updateCollapsed( true );
+ }
+ wasCompactRef.current = isCompact;
+ };
+ const root = rootRef.current;
+ if ( root && typeof ResizeObserver !== 'undefined' ) {
+ const observer = new ResizeObserver( ( entries ) => {
+ const width = entries[ 0 ]?.contentRect.width;
+ if ( width ) {
+ collapseWhenEnteringCompactWidth( width );
+ }
+ } );
+ observer.observe( root );
+ return () => observer.disconnect();
+ }
+ const handleWindowResize = () => collapseWhenEnteringCompactWidth( getViewportWidth() );
+ window.addEventListener( 'resize', handleWindowResize );
+ return () => window.removeEventListener( 'resize', handleWindowResize );
+ }, [ updateCollapsed ] );
return (
-
+