Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/studio/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 18 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,24 @@ export function toggleMinWindowWidth(
parentWindow.setSize( newWidth, currentHeight, true );
}

export async function ensureMinWindowWidth(
event: IpcMainInvokeEvent,
minimumWidth: number
): Promise< void > {
if ( ! Number.isFinite( minimumWidth ) || minimumWidth <= 0 ) {
return;
}
const parentWindow = BrowserWindow.fromWebContents( event.sender );
if ( ! parentWindow || parentWindow.isDestroyed() || event.sender.isDestroyed() ) {
return;
}
const [ currentWidth, currentHeight ] = parentWindow.getSize();
const nextWidth = Math.ceil( minimumWidth );
if ( currentWidth < nextWidth ) {
parentWindow.setSize( nextWidth, currentHeight );
}
}

/**
* Returns the absolute path of a file in the site's directory.
* Returns null if the file does not exist.
Expand Down
19 changes: 17 additions & 2 deletions apps/studio/src/main-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() );
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,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 ) =>
Expand Down
31 changes: 30 additions & 1 deletion apps/studio/src/tests/ipc-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +10,7 @@ import { vol } from 'memfs';
import { vi } from 'vitest';
import {
createSite,
ensureMinWindowWidth,
getFileSize,
getXdebugEnabledSite,
isFullscreen,
Expand Down Expand Up @@ -188,6 +189,34 @@ describe( 'isFullscreen', () => {
} );
} );

describe( 'ensureMinWindowWidth', () => {
it( 'grows the sender window while preserving its height', async () => {
const setSize = vi.fn();
vi.mocked( BrowserWindow.fromWebContents ).mockReturnValueOnce( {
isDestroyed: () => false,
getSize: () => [ 420, 700 ],
setSize,
} as unknown as BrowserWindow );

await ensureMinWindowWidth( mockIpcMainInvokeEvent, 640 );

expect( setSize ).toHaveBeenCalledWith( 640, 700 );
} );

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 );

await ensureMinWindowWidth( mockIpcMainInvokeEvent, 640 );

expect( setSize ).not.toHaveBeenCalled();
} );
} );

describe( 'getXdebugEnabledSite', () => {
it( 'should return null when no site has Xdebug enabled', async () => {
vi.mocked( SiteServer.getAllDetails ).mockReturnValue( [
Expand Down
57 changes: 57 additions & 0 deletions apps/ui/src/components/preview-split-frame/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,63 @@ describe( 'PreviewSplitFrame', () => {
expect( screen.getByLabelText( 'Site preview' ) ).toBeVisible();
} );

it( 'reports when an open split is too narrow for both panel minimums', async () => {
frameWidth = 639;
const onSplitTooNarrow = vi.fn();

render(
<PreviewSplitFrame
previewOpen
preview={ () => <aside aria-label="Site preview" /> }
onSplitTooNarrow={ onSplitTooNarrow }
>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

await waitFor( () => expect( onSplitTooNarrow ).toHaveBeenCalledWith( 639, 'resized' ) );
} );

it( 'reports that a narrow split was explicitly opened', async () => {
frameWidth = 420;
const onSplitTooNarrow = vi.fn();
const preview = () => <aside aria-label="Site preview" />;
const { rerender } = render(
<PreviewSplitFrame previewOpen={ false } preview={ preview }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

rerender(
<PreviewSplitFrame previewOpen preview={ preview } onSplitTooNarrow={ onSplitTooNarrow }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

await waitFor( () => expect( onSplitTooNarrow ).toHaveBeenCalledWith( 420, 'opened' ) );
} );

it( 'allows a narrow preview when it is fullscreen', async () => {
frameWidth = 420;
const onSplitTooNarrow = vi.fn();

render(
<PreviewSplitFrame
previewOpen
previewFullscreen
preview={ () => <aside aria-label="Site preview" /> }
onSplitTooNarrow={ onSplitTooNarrow }
>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

await waitFor( () =>
expect( getFrameRoot() ).toHaveStyle( '--preview-frame-content-width: 0px' )
);
expect( onSplitTooNarrow ).not.toHaveBeenCalled();
} );

describe( 'keyboard and pointer resizing', () => {
async function renderOpenAndSettle() {
render(
Expand Down
46 changes: 44 additions & 2 deletions apps/ui/src/components/preview-split-frame/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { __ } from '@wordpress/i18n';
import { clsx } from 'clsx';
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import { ResizeHandle, ResizeOverlay } from '@/components/resize-handle';
import { usePreviewSplit } from '@/hooks/use-preview-split';
import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
import { PREVIEW_SPLIT_MIN_WIDTH } from '@/lib/resizable-panels';
import styles from './style.module.css';

// Keep in sync with the content-column transition duration in style.module.css.
Expand All @@ -13,6 +21,8 @@ export interface PreviewSplitFramePreviewProps {
collapsed: boolean;
}

export type PreviewSplitTooNarrowReason = 'opened' | 'resized';

interface PreviewSplitFrameProps {
// The preview panel content. Kept mounted while closed so the webview stays
// warm. The split geometry is owned by usePreviewSplit; this component only
Expand All @@ -22,19 +32,51 @@ 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;
// Called when an open split can no longer preserve both panel minimums.
onSplitTooNarrow?: ( containerWidth: number, reason: PreviewSplitTooNarrowReason ) => void;
children?: ReactNode;
}

export function PreviewSplitFrame( {
preview,
previewOpen = false,
previewFullscreen = false,
onSplitTooNarrow,
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();
const previousShowPreviewRef = useRef( showPreview );
const openedSinceMeasurementRef = useRef( false );
const reportedTooNarrowRef = useRef( false );
useLayoutEffect( () => {
openedSinceMeasurementRef.current = showPreview && ! previousShowPreviewRef.current;
previousShowPreviewRef.current = showPreview;
}, [ showPreview ] );

useEffect( () => {
if ( ! showPreview || showFullscreen || containerWidth === null ) {
reportedTooNarrowRef.current = false;
return;
}
const reason: PreviewSplitTooNarrowReason = openedSinceMeasurementRef.current
? 'opened'
: 'resized';
openedSinceMeasurementRef.current = false;
const tooNarrow = containerWidth < PREVIEW_SPLIT_MIN_WIDTH;
if ( ! tooNarrow ) {
reportedTooNarrowRef.current = false;
return;
}
if ( ! reportedTooNarrowRef.current ) {
reportedTooNarrowRef.current = true;
onSplitTooNarrow?.( containerWidth, reason );
}
}, [ containerWidth, onSplitTooNarrow, 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
Expand Down
54 changes: 52 additions & 2 deletions apps/ui/src/components/sidebar-layout/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,18 +54,30 @@ const useConnectorMock = vi.mocked( useConnector, { partial: true } );

describe( 'SidebarLayout', () => {
let toggleSidebarListener: ( () => void ) | undefined;
let originalInnerWidth: number;
const ensureWindowWidth = vi.fn().mockResolvedValue( undefined );

beforeEach( () => {
vi.clearAllMocks();
originalInnerWidth = window.innerWidth;
Object.defineProperty( window, 'innerWidth', { configurable: true, value: 1024 } );
toggleSidebarListener = undefined;
useConnectorMock.mockReturnValue( {
ensureWindowWidth,
onToggleSidebar: vi.fn( ( listener ) => {
toggleSidebarListener = listener;
return vi.fn();
} ),
} );
} );

afterEach( () => {
Object.defineProperty( window, 'innerWidth', {
configurable: true,
value: originalInnerWidth,
} );
} );

it( 'toggles the sidebar when the connector emits the shortcut command', () => {
render(
<SidebarLayout>
Expand Down Expand Up @@ -101,4 +113,42 @@ describe( 'SidebarLayout', () => {

expect( onForceCollapsedToggle ).toHaveBeenCalledTimes( 1 );
} );

it( 'collapses when the window enters compact width', async () => {
render(
<SidebarLayout>
<div>Content</div>
</SidebarLayout>
);

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(
<SidebarLayout>
<div>Content</div>
</SidebarLayout>
);

expect( screen.getByRole( 'button', { name: 'Show sidebar' } ) ).toBeInTheDocument();
} );

it( 'grows a compact window when reopening the sidebar', () => {
Object.defineProperty( window, 'innerWidth', { configurable: true, value: 420 } );
render(
<SidebarLayout>
<div>Content</div>
</SidebarLayout>
);

fireEvent.click( screen.getByRole( 'button', { name: 'Show sidebar' } ) );

expect( ensureWindowWidth ).toHaveBeenCalledWith( 660 );
} );
} );
Loading