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( +