diff --git a/apps/studio/src/main-window.ts b/apps/studio/src/main-window.ts
index 93b741efc3..f6bf484029 100644
--- a/apps/studio/src/main-window.ts
+++ b/apps/studio/src/main-window.ts
@@ -9,6 +9,7 @@ import fs from 'fs';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { portFinder } from '@studio/common/lib/port-finder';
+import { normalizeStudioUiMode } from '@studio/common/lib/studio-ui-mode';
import {
DEFAULT_HEIGHT,
DEFAULT_WIDTH,
@@ -27,7 +28,7 @@ import {
loadWindowBounds,
saveWindowBounds,
} from 'src/storage/user-data';
-import type { StudioUiMode } from '@studio/common/types/desk';
+import type { StoredStudioUiMode, StudioUiMode } from '@studio/common/types/desk';
import type { UserData, WindowBounds } from 'src/storage/storage-types';
let mainWindow: BrowserWindow | null;
@@ -40,8 +41,7 @@ interface RendererLocation {
}
export function getPreferredStudioUiMode( userData: Pick< UserData, 'desks' > ): StudioUiMode {
- const preferredMode = userData.desks?.defaultUiMode;
- return preferredMode === 'desks' || preferredMode === 'agentic' ? preferredMode : 'default';
+ return normalizeStudioUiMode( userData.desks?.defaultUiMode );
}
function getRendererFilePath( mode: StudioUiMode ) {
@@ -120,13 +120,14 @@ async function loadRendererLocation( window: BrowserWindow, location: RendererLo
export async function loadMainWindowRenderer(
window: BrowserWindow,
- mode?: StudioUiMode
+ mode?: StoredStudioUiMode
): Promise< void > {
const userData = await loadUserData();
+ const normalizedMode = normalizeStudioUiMode( mode );
const location = getRendererLocation( {
desks: {
...userData.desks,
- ...( mode ? { defaultUiMode: mode } : {} ),
+ ...( mode ? { defaultUiMode: normalizedMode } : {} ),
},
} );
await loadRendererLocation( window, location );
diff --git a/apps/studio/src/modules/desks/lib/ipc-handlers.test.ts b/apps/studio/src/modules/desks/lib/ipc-handlers.test.ts
new file mode 100644
index 0000000000..4481c99701
--- /dev/null
+++ b/apps/studio/src/modules/desks/lib/ipc-handlers.test.ts
@@ -0,0 +1,102 @@
+/**
+ * @vitest-environment node
+ */
+import { BrowserWindow, type IpcMainInvokeEvent } from 'electron';
+import { vi } from 'vitest';
+import { loadMainWindowRenderer } from 'src/main-window';
+import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data';
+import { getStudioUiMode, setStudioUiMode } from './ipc-handlers';
+import type { UserData } from 'src/storage/storage-types';
+
+vi.mock( 'electron', () => ( {
+ BrowserWindow: {
+ fromWebContents: vi.fn(),
+ },
+ dialog: {
+ showOpenDialog: vi.fn(),
+ showSaveDialog: vi.fn(),
+ },
+} ) );
+
+vi.mock( 'src/main-window', () => ( {
+ loadMainWindowRenderer: vi.fn().mockResolvedValue( undefined ),
+} ) );
+
+vi.mock( 'src/storage/user-data', () => ( {
+ loadUserData: vi.fn(),
+ lockAppdata: vi.fn().mockResolvedValue( undefined ),
+ saveUserData: vi.fn().mockResolvedValue( undefined ),
+ unlockAppdata: vi.fn().mockResolvedValue( undefined ),
+} ) );
+
+const mockEvent = {
+ sender: {},
+ frameId: 1,
+} as IpcMainInvokeEvent;
+
+const mockUserData: UserData = {
+ version: 1,
+ siteMetadata: {},
+};
+
+describe( 'desk Studio UI mode IPC handlers', () => {
+ beforeEach( () => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ vi.mocked( loadUserData ).mockResolvedValue( mockUserData );
+ vi.mocked( BrowserWindow.fromWebContents ).mockReturnValue( {
+ isDestroyed: vi.fn().mockReturnValue( false ),
+ } as unknown as BrowserWindow );
+ } );
+
+ it.each( [
+ [ 'default', 'default' ],
+ [ 'studio2', 'studio2' ],
+ [ 'agentic', 'studio2' ],
+ [ 'desks', 'studio2' ],
+ ] )( 'returns %s stored mode as %s', async ( storedMode, expectedMode ) => {
+ vi.mocked( loadUserData ).mockResolvedValue( {
+ ...mockUserData,
+ desks: {
+ defaultUiMode: storedMode as never,
+ },
+ } );
+
+ await expect( getStudioUiMode( mockEvent ) ).resolves.toBe( expectedMode );
+ } );
+
+ it( 'normalizes legacy mode before persisting and reloading', async () => {
+ vi.useFakeTimers();
+ vi.mocked( loadUserData ).mockResolvedValue( {
+ ...mockUserData,
+ desks: {
+ defaultUiMode: 'default',
+ },
+ } );
+ const parentWindow = {
+ isDestroyed: vi.fn().mockReturnValue( false ),
+ } as unknown as BrowserWindow;
+ vi.mocked( BrowserWindow.fromWebContents ).mockReturnValue( parentWindow );
+
+ await setStudioUiMode( mockEvent, 'agentic' );
+ await vi.runAllTimersAsync();
+
+ expect( saveUserData ).toHaveBeenCalledWith( {
+ ...mockUserData,
+ desks: {
+ defaultUiMode: 'studio2',
+ },
+ } );
+ expect( loadMainWindowRenderer ).toHaveBeenCalledWith( parentWindow, 'studio2' );
+ } );
+
+ it( 'rejects invalid mode before locking or saving', async () => {
+ await expect( setStudioUiMode( mockEvent, 'invalid' as never ) ).rejects.toThrow(
+ 'Invalid Studio UI mode.'
+ );
+
+ expect( lockAppdata ).not.toHaveBeenCalled();
+ expect( saveUserData ).not.toHaveBeenCalled();
+ expect( unlockAppdata ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/apps/studio/src/modules/desks/lib/ipc-handlers.ts b/apps/studio/src/modules/desks/lib/ipc-handlers.ts
index fcd2c4d308..9ee2c213df 100644
--- a/apps/studio/src/modules/desks/lib/ipc-handlers.ts
+++ b/apps/studio/src/modules/desks/lib/ipc-handlers.ts
@@ -3,7 +3,16 @@ import fsPromises from 'fs/promises';
import nodePath from 'path';
import { assertDeskConfig } from '@studio/common/lib/desk-config';
import { normalizeDeskSettings } from '@studio/common/lib/desk-settings';
-import { type DeskConfig, type DeskSettings, type StudioUiMode } from '@studio/common/types/desk';
+import {
+ assertSupportedStudioUiMode,
+ normalizeStudioUiMode,
+} from '@studio/common/lib/studio-ui-mode';
+import {
+ type DeskConfig,
+ type DeskSettings,
+ type StoredStudioUiMode,
+ type StudioUiMode,
+} from '@studio/common/types/desk';
import { __ } from '@wordpress/i18n';
import { loadMainWindowRenderer } from 'src/main-window';
import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data';
@@ -18,12 +27,6 @@ function assertSiteId( siteId: unknown ): asserts siteId is string {
}
}
-function assertStudioUiMode( mode: unknown ): asserts mode is StudioUiMode {
- if ( mode !== 'default' && mode !== 'desks' && mode !== 'agentic' ) {
- throw new Error( 'Invalid Studio UI mode.' );
- }
-}
-
function getParentWindow( event: IpcMainInvokeEvent, channel: string ) {
const parentWindow = BrowserWindow.fromWebContents( event.sender );
if ( ! parentWindow ) {
@@ -52,15 +55,15 @@ export async function getDeskSettings( _event: IpcMainInvokeEvent ): Promise< De
export async function getStudioUiMode( _event: IpcMainInvokeEvent ): Promise< StudioUiMode > {
const userData = await loadUserData();
- const mode = userData.desks?.defaultUiMode;
- return mode === 'desks' || mode === 'agentic' ? mode : 'default';
+ return normalizeStudioUiMode( userData.desks?.defaultUiMode );
}
export async function setStudioUiMode(
event: IpcMainInvokeEvent,
- mode: StudioUiMode
+ mode: StoredStudioUiMode
): Promise< void > {
- assertStudioUiMode( mode );
+ assertSupportedStudioUiMode( mode );
+ const normalizedMode = normalizeStudioUiMode( mode );
await lockAppdata();
try {
const userData = await loadUserData();
@@ -68,7 +71,7 @@ export async function setStudioUiMode(
...userData,
desks: {
...userData.desks,
- defaultUiMode: mode,
+ defaultUiMode: normalizedMode,
},
} );
} finally {
@@ -78,7 +81,7 @@ export async function setStudioUiMode(
const parentWindow = BrowserWindow.fromWebContents( event.sender );
if ( parentWindow && ! parentWindow.isDestroyed() ) {
setTimeout( () => {
- void loadMainWindowRenderer( parentWindow, mode );
+ void loadMainWindowRenderer( parentWindow, normalizedMode );
}, 0 );
}
}
diff --git a/apps/studio/src/modules/user-settings/components/preferences-tab.tsx b/apps/studio/src/modules/user-settings/components/preferences-tab.tsx
index 0c03c5ecab..dae653d116 100644
--- a/apps/studio/src/modules/user-settings/components/preferences-tab.tsx
+++ b/apps/studio/src/modules/user-settings/components/preferences-tab.tsx
@@ -130,12 +130,8 @@ export const PreferencesTab = ( { onClose }: { onClose: () => void } ) => {
}
};
- const switchToDesksUi = () => {
- void getIpcApi().setStudioUiMode( 'desks' );
- };
-
- const switchToAgenticUi = () => {
- void getIpcApi().setStudioUiMode( 'agentic' );
+ const switchToStudio2Ui = () => {
+ void getIpcApi().setStudioUiMode( 'studio2' );
};
return (
@@ -161,14 +157,9 @@ export const PreferencesTab = ( { onClose }: { onClose: () => void } ) => {
) }
{ enableDesksUiSwitch && (
-
-
-
-
+
) }
diff --git a/apps/ui/src/app/use-ui-mode.test.ts b/apps/ui/src/app/use-ui-mode.test.ts
new file mode 100644
index 0000000000..b7929d15ba
--- /dev/null
+++ b/apps/ui/src/app/use-ui-mode.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { resolveLaunchUiMode } from './use-ui-mode';
+
+describe( 'resolveLaunchUiMode', () => {
+ it( 'maps Studio 2 launch modes to the classic shell', () => {
+ expect( resolveLaunchUiMode( 'studio2' ) ).toBe( 'classic' );
+ expect( resolveLaunchUiMode( 'agentic' ) ).toBe( 'classic' );
+ expect( resolveLaunchUiMode( 'desks' ) ).toBe( 'classic' );
+ } );
+
+ it( 'ignores missing and unknown launch modes', () => {
+ expect( resolveLaunchUiMode( null ) ).toBeUndefined();
+ expect( resolveLaunchUiMode( 'default' ) ).toBeUndefined();
+ expect( resolveLaunchUiMode( 'bogus' ) ).toBeUndefined();
+ } );
+} );
diff --git a/apps/ui/src/app/use-ui-mode.ts b/apps/ui/src/app/use-ui-mode.ts
index fd234760ca..c99194cc12 100644
--- a/apps/ui/src/app/use-ui-mode.ts
+++ b/apps/ui/src/app/use-ui-mode.ts
@@ -3,7 +3,14 @@ import { useCallback, useState } from 'react';
export type UiMode = 'classic' | 'desks';
const STUDIO_UI_MODE_PARAM = 'studio-ui-mode';
-const DEFAULT_UI_MODE: UiMode = 'desks';
+const DEFAULT_UI_MODE: UiMode = 'classic';
+
+export function resolveLaunchUiMode( mode: string | null ): UiMode | undefined {
+ if ( mode === 'studio2' || mode === 'agentic' || mode === 'desks' ) {
+ return 'classic';
+ }
+ return undefined;
+}
function readLaunchUiMode(): UiMode | undefined {
if ( typeof window === 'undefined' ) {
@@ -12,12 +19,7 @@ function readLaunchUiMode(): UiMode | undefined {
try {
const mode = new URLSearchParams( window.location.search ).get( STUDIO_UI_MODE_PARAM );
- if ( mode === 'desks' ) {
- return 'desks';
- }
- if ( mode === 'agentic' ) {
- return 'classic';
- }
+ return resolveLaunchUiMode( mode );
} catch {
return undefined;
}
diff --git a/apps/ui/src/components/settings-view/index.test.tsx b/apps/ui/src/components/settings-view/index.test.tsx
new file mode 100644
index 0000000000..e30f5b78ab
--- /dev/null
+++ b/apps/ui/src/components/settings-view/index.test.tsx
@@ -0,0 +1,115 @@
+import '@testing-library/jest-dom/vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useConnector } from '@/data/core';
+import { useInstalledApps } from '@/data/queries/use-installed-apps';
+import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
+import { useFullscreen } from '@/hooks/use-fullscreen';
+import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
+import { SettingsView } from './index';
+import type { ReactNode } from 'react';
+
+const mocks = vi.hoisted( () => ( {
+ setStudioUiMode: vi.fn(),
+ mutate: vi.fn(),
+} ) );
+
+vi.mock( '@wordpress/dataviews', () => ( {
+ DataForm: () =>
,
+} ) );
+
+vi.mock( '@wordpress/i18n', () => ( {
+ __: ( text: string ) => text,
+} ) );
+
+vi.mock( '@wordpress/ui', () => ( {
+ Button: ( {
+ children,
+ disabled,
+ loading,
+ onClick,
+ type = 'button',
+ }: {
+ children?: ReactNode;
+ disabled?: boolean;
+ loading?: boolean;
+ onClick?: () => void;
+ type?: 'button' | 'submit';
+ } ) => (
+
+ ),
+} ) );
+
+vi.mock( '@/components/tabs', () => ( {
+ Root: ( { children }: { children?: ReactNode } ) =>
{ children }
,
+ List: ( { children }: { children?: ReactNode } ) =>
{ children }
,
+ Tab: ( { children }: { children?: ReactNode } ) =>
,
+ Panel: ( { children }: { children?: ReactNode } ) =>
{ children }
,
+} ) );
+
+vi.mock( '@/data/core', () => ( {
+ useConnector: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-installed-apps', () => ( {
+ useInstalledApps: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-user-preferences', () => ( {
+ useSaveUserPreferences: vi.fn(),
+ useUserPreferences: vi.fn(),
+} ) );
+
+vi.mock( '@/hooks/use-fullscreen', () => ( {
+ useFullscreen: vi.fn(),
+} ) );
+
+vi.mock( '@/hooks/use-sidebar-collapsed', () => ( {
+ useSidebarCollapsed: vi.fn(),
+} ) );
+
+const useConnectorMock = vi.mocked( useConnector );
+const useInstalledAppsMock = vi.mocked( useInstalledApps );
+const useUserPreferencesMock = vi.mocked( useUserPreferences );
+const useSaveUserPreferencesMock = vi.mocked( useSaveUserPreferences );
+const useFullscreenMock = vi.mocked( useFullscreen );
+const useSidebarCollapsedMock = vi.mocked( useSidebarCollapsed );
+
+describe( 'SettingsView', () => {
+ beforeEach( () => {
+ mocks.setStudioUiMode.mockReset().mockResolvedValue( undefined );
+ mocks.mutate.mockReset();
+
+ useConnectorMock.mockReturnValue( {
+ setStudioUiMode: mocks.setStudioUiMode,
+ } as unknown as ReturnType< typeof useConnector > );
+ useInstalledAppsMock.mockReturnValue( { data: undefined } as ReturnType<
+ typeof useInstalledApps
+ > );
+ useUserPreferencesMock.mockReturnValue( {
+ data: {
+ editor: null,
+ terminal: null,
+ colorScheme: 'system',
+ locale: 'en',
+ },
+ isLoading: false,
+ } as ReturnType< typeof useUserPreferences > );
+ useSaveUserPreferencesMock.mockReturnValue( {
+ isPending: false,
+ mutate: mocks.mutate,
+ } as unknown as ReturnType< typeof useSaveUserPreferences > );
+ useFullscreenMock.mockReturnValue( false );
+ useSidebarCollapsedMock.mockReturnValue( false );
+ } );
+
+ it( 'switches back to the default Studio UI from preferences', () => {
+ render(
);
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Switch to Default Studio UI' } ) );
+
+ expect( mocks.setStudioUiMode ).toHaveBeenCalledWith( 'default' );
+ } );
+} );
diff --git a/apps/ui/src/components/settings-view/index.tsx b/apps/ui/src/components/settings-view/index.tsx
index 359e8d4a7a..dc1d55c93e 100644
--- a/apps/ui/src/components/settings-view/index.tsx
+++ b/apps/ui/src/components/settings-view/index.tsx
@@ -6,6 +6,7 @@ import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/ui';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as Tabs from '@/components/tabs';
+import { useConnector } from '@/data/core';
import { persister } from '@/data/core/query-client';
import { useInstalledApps } from '@/data/queries/use-installed-apps';
import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
@@ -125,6 +126,7 @@ export function SettingsView( {
activeTab: TabId;
onTabChange: ( tab: TabId ) => void;
} ) {
+ const connector = useConnector();
const { data: saved, isLoading } = useUserPreferences();
const { data: installedApps } = useInstalledApps();
const savePreferences = useSaveUserPreferences();
@@ -186,6 +188,10 @@ export function SettingsView( {
setData( ( prev ) => ( prev ? { ...prev, ...( update as Partial< FormData > ) } : prev ) );
}, [] );
+ const switchToDefaultStudioUi = useCallback( () => {
+ void connector.setStudioUiMode( 'default' );
+ }, [ connector ] );
+
if ( isLoading || ! data || ! saved ) {
return
{ __( 'Loading…' ) }
;
}
@@ -248,6 +254,12 @@ export function SettingsView( {
form={ preferencesForm }
onChange={ handleChange }
/>
+
+
+
+
diff --git a/apps/ui/src/components/settings-view/style.module.css b/apps/ui/src/components/settings-view/style.module.css
index ffd22b33d4..67da02385b 100644
--- a/apps/ui/src/components/settings-view/style.module.css
+++ b/apps/ui/src/components/settings-view/style.module.css
@@ -75,6 +75,20 @@
gap: var(--wpds-dimension-padding-xl);
}
+.studioUiControl {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--wpds-dimension-padding-sm);
+ margin-top: var(--wpds-dimension-padding-xl);
+}
+
+.studioUiLabel {
+ font-size: var(--wpds-typography-font-size-sm);
+ font-weight: 500;
+ color: var(--wpds-color-fg-content-neutral);
+}
+
.actions {
display: flex;
justify-content: flex-end;
diff --git a/apps/ui/src/components/sidebar-nav/index.test.tsx b/apps/ui/src/components/sidebar-nav/index.test.tsx
new file mode 100644
index 0000000000..714d0055d5
--- /dev/null
+++ b/apps/ui/src/components/sidebar-nav/index.test.tsx
@@ -0,0 +1,59 @@
+import '@testing-library/jest-dom/vitest';
+import { render, screen, within } from '@testing-library/react';
+import { cloneElement, isValidElement } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { SidebarNav } from './index';
+import type { ReactNode } from 'react';
+
+vi.mock( '@tanstack/react-router', () => ( {
+ Link: ( { children, to }: { children?: ReactNode; to: string } ) => (
+
{ children }
+ ),
+} ) );
+
+vi.mock( '@wordpress/i18n', () => ( {
+ __: ( text: string ) => text,
+} ) );
+
+vi.mock( '@wordpress/icons', () => ( {
+ category: {},
+ cog: {},
+ comment: {},
+ layout: {},
+} ) );
+
+vi.mock( '@wordpress/ui', () => ( {
+ Button: ( { children, render }: { children?: ReactNode; render?: ReactNode } ) => {
+ if ( render ) {
+ return <>{ render }>;
+ }
+
+ return
;
+ },
+ Icon: () => null,
+} ) );
+
+vi.mock( '@/components/sidebar-button', () => ( {
+ SidebarButton: ( { children, render }: { children?: ReactNode; render?: ReactNode } ) => {
+ if ( isValidElement( render ) ) {
+ return cloneElement( render, undefined, children );
+ }
+
+ return
;
+ },
+} ) );
+
+describe( 'SidebarNav', () => {
+ it( 'renders Chat, Desk, Settings as links and keeps Skills visible as a label', () => {
+ render(
);
+
+ const nav = screen.getByRole( 'navigation' );
+ const links = within( nav ).getAllByRole( 'link' );
+
+ expect( links.map( ( link ) => link.textContent ) ).toEqual( [ 'Chat', 'Desk', 'Settings' ] );
+ expect( screen.getByRole( 'link', { name: 'Desk' } ) ).toHaveAttribute( 'href', '/desk' );
+
+ expect( screen.getByText( 'Skills' ) ).toBeVisible();
+ expect( screen.queryByRole( 'link', { name: 'Skills' } ) ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/apps/ui/src/components/sidebar-nav/index.tsx b/apps/ui/src/components/sidebar-nav/index.tsx
index 05b1afd9a0..a5112098b7 100644
--- a/apps/ui/src/components/sidebar-nav/index.tsx
+++ b/apps/ui/src/components/sidebar-nav/index.tsx
@@ -1,6 +1,6 @@
import { Link } from '@tanstack/react-router';
import { __ } from '@wordpress/i18n';
-import { category, cog, comment } from '@wordpress/icons';
+import { category, cog, comment, layout } from '@wordpress/icons';
import { Icon } from '@wordpress/ui';
import { clsx } from 'clsx';
import { SidebarButton } from '@/components/sidebar-button';
@@ -17,6 +17,7 @@ type NavItem = {
function getItems(): NavItem[] {
return [
{ key: 'chat', label: __( 'Chat' ), icon: comment, to: '/dashboard' },
+ { key: 'desk', label: __( 'Desk' ), icon: layout, to: '/desk' },
{ key: 'settings', label: __( 'Settings' ), icon: cog, to: '/settings' },
{ key: 'skills', label: __( 'Skills' ), icon: category },
];
diff --git a/apps/ui/src/ui-classic/router/route-desk/index.tsx b/apps/ui/src/ui-classic/router/route-desk/index.tsx
new file mode 100644
index 0000000000..162cbb51e7
--- /dev/null
+++ b/apps/ui/src/ui-classic/router/route-desk/index.tsx
@@ -0,0 +1,24 @@
+import { createRoute } from '@tanstack/react-router';
+import { __ } from '@wordpress/i18n';
+import { Desk } from '@/ui-desks/desk';
+import { dashboardLayoutRoute } from '../layout-dashboard';
+import styles from './style.module.css';
+
+function DeskPage() {
+ return (
+
+ );
+}
+
+export const deskRoute = createRoute( {
+ getParentRoute: () => dashboardLayoutRoute,
+ path: '/desk',
+ component: DeskPage,
+} );
diff --git a/apps/ui/src/ui-classic/router/route-desk/style.module.css b/apps/ui/src/ui-classic/router/route-desk/style.module.css
new file mode 100644
index 0000000000..46490a03ce
--- /dev/null
+++ b/apps/ui/src/ui-classic/router/route-desk/style.module.css
@@ -0,0 +1,6 @@
+.root {
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ position: relative;
+}
diff --git a/apps/ui/src/ui-classic/router/route-site-desk/index.tsx b/apps/ui/src/ui-classic/router/route-site-desk/index.tsx
new file mode 100644
index 0000000000..58ac62832d
--- /dev/null
+++ b/apps/ui/src/ui-classic/router/route-site-desk/index.tsx
@@ -0,0 +1,29 @@
+import { createRoute } from '@tanstack/react-router';
+import { __ } from '@wordpress/i18n';
+import { WordPressDataProvider } from '@/data/wordpress/provider';
+import { Desk } from '@/ui-desks/desk';
+import { dashboardLayoutRoute } from '../layout-dashboard';
+import styles from '../route-desk/style.module.css';
+
+function SiteDeskPage() {
+ const { siteId } = siteDeskRoute.useParams();
+
+ return (
+
+ );
+}
+
+export const siteDeskRoute = createRoute( {
+ getParentRoute: () => dashboardLayoutRoute,
+ path: '/sites/$siteId',
+ component: SiteDeskPage,
+} );
diff --git a/apps/ui/src/ui-classic/router/router.test.ts b/apps/ui/src/ui-classic/router/router.test.ts
new file mode 100644
index 0000000000..27c7d8d6a9
--- /dev/null
+++ b/apps/ui/src/ui-classic/router/router.test.ts
@@ -0,0 +1,23 @@
+import { QueryClient } from '@tanstack/react-query';
+import { describe, expect, it, vi } from 'vitest';
+import { createAppRouter } from './router';
+import type { ReactNode } from 'react';
+
+vi.mock( '@/ui-desks/desk', () => ( {
+ Desk: () => null,
+} ) );
+
+vi.mock( '@/data/wordpress/provider', () => ( {
+ WordPressDataProvider: ( { children }: { children: ReactNode } ) => children,
+} ) );
+
+describe( 'createAppRouter', () => {
+ it( 'registers embedded site Desk routes in the Agentic shell', () => {
+ const router = createAppRouter( {
+ connector: {} as never,
+ queryClient: new QueryClient(),
+ } );
+
+ expect( router.routesByPath[ '/sites/$siteId' ] ).toBeDefined();
+ } );
+} );
diff --git a/apps/ui/src/ui-classic/router/router.tsx b/apps/ui/src/ui-classic/router/router.tsx
index 28c826f134..b6e2957cf0 100644
--- a/apps/ui/src/ui-classic/router/router.tsx
+++ b/apps/ui/src/ui-classic/router/router.tsx
@@ -4,6 +4,7 @@ import { dashboardLayoutRoute } from './layout-dashboard';
import { onboardingLayoutRoute } from './layout-onboarding';
import { rootRoute } from './layout-root';
import { dashboardRoute } from './route-dashboard';
+import { deskRoute } from './route-desk';
import { indexRoute } from './route-index';
import { newSessionRoute } from './route-new-session';
import { onboardingBlueprintRoute } from './route-onboarding-blueprint';
@@ -12,6 +13,7 @@ import { onboardingHomeRoute } from './route-onboarding-home';
import { onboardingImportRoute } from './route-onboarding-import';
import { sessionDetailRoute } from './route-session-detail';
import { settingsRoute } from './route-settings';
+import { siteDeskRoute } from './route-site-desk';
import { siteSettingsRoute } from './route-site-settings';
import type { RouterContext } from './layout-root';
@@ -21,7 +23,9 @@ const routeTree = rootRoute.addChildren( [
dashboardRoute,
newSessionRoute,
sessionDetailRoute,
+ siteDeskRoute,
siteSettingsRoute,
+ deskRoute,
settingsRoute,
] ),
onboardingLayoutRoute.addChildren( [
diff --git a/apps/ui/src/ui-desks/chats/panel/index.tsx b/apps/ui/src/ui-desks/chats/panel/index.tsx
index d4722647a1..23c28fcd59 100644
--- a/apps/ui/src/ui-desks/chats/panel/index.tsx
+++ b/apps/ui/src/ui-desks/chats/panel/index.tsx
@@ -24,6 +24,8 @@ interface ChatsProps {
siteId?: string;
side: ChatPanelSide;
panel: ChatPanelResizeState;
+ embedded?: boolean;
+ container?: HTMLElement | null;
}
const COMPACT_STORAGE_KEY = 'ui-desks-chat-list-compact';
@@ -210,7 +212,7 @@ export function ChatsTrigger() {
return
setOpen( ! open ) } />;
}
-export function Chats( { siteId, side, panel }: ChatsProps ) {
+export function Chats( { siteId, side, panel, embedded = false, container }: ChatsProps ) {
const {
open,
setOpen,
@@ -255,6 +257,13 @@ export function Chats( { siteId, side, panel }: ChatsProps ) {
chatSessions.find( ( session ) => session.id === selectedSessionId ) ??
( sessions ?? [] ).find( ( session ) => session.id === selectedSessionId );
const isListCollapsed = expanded && listCollapsed;
+ const embeddedBounds = embedded ? container?.getBoundingClientRect() : undefined;
+ const widgetDragPreviewX = composerWidgetDragPreview
+ ? composerWidgetDragPreview.x - ( embeddedBounds?.left ?? 0 )
+ : 0;
+ const widgetDragPreviewY = composerWidgetDragPreview
+ ? composerWidgetDragPreview.y - ( embeddedBounds?.top ?? 0 )
+ : 0;
useEffect( () => {
if ( selectedSessionId && sessions && ! isFetchingSessions && ! selectedSession ) {
@@ -278,7 +287,7 @@ export function Chats( { siteId, side, panel }: ChatsProps ) {
return (
-
+
+
{ __( 'Studio' ) }
{ children }
{ centerChildren &&
{ centerChildren }
}
diff --git a/apps/ui/src/ui-desks/chrome/header/style.module.css b/apps/ui/src/ui-desks/chrome/header/style.module.css
index f576c8b0b3..b46496662b 100644
--- a/apps/ui/src/ui-desks/chrome/header/style.module.css
+++ b/apps/ui/src/ui-desks/chrome/header/style.module.css
@@ -73,3 +73,27 @@
.fullscreen .centerActions {
top: var(--desk-chrome-edge-offset);
}
+
+.embedded {
+ --desk-chrome-edge-offset: var(--wpds-dimension-padding-xl);
+ --desk-embedded-top-offset: var(--desk-chrome-edge-offset);
+}
+
+.embedded .title {
+ display: none;
+}
+
+.embedded .actions {
+ top: var(--desk-embedded-top-offset);
+ left: var(--desk-chrome-edge-offset);
+}
+
+.embedded .rightActions {
+ top: var(--desk-embedded-top-offset);
+ right: var(--desk-chrome-edge-offset);
+}
+
+.embedded .centerActions {
+ top: var(--desk-embedded-top-offset);
+ max-width: min(360px, calc(100% - 320px));
+}
diff --git a/apps/ui/src/ui-desks/chrome/index.tsx b/apps/ui/src/ui-desks/chrome/index.tsx
index 0f118edbad..19ef07818f 100644
--- a/apps/ui/src/ui-desks/chrome/index.tsx
+++ b/apps/ui/src/ui-desks/chrome/index.tsx
@@ -20,6 +20,7 @@ import { DeskMenu } from './user-menu';
interface DeskChromeProps {
siteId?: string;
+ embedded?: boolean;
siteMapOpen?: boolean;
siteMapPageCount?: number;
settingsOpen: boolean;
@@ -30,6 +31,7 @@ interface DeskChromeProps {
export function DeskChrome( {
siteId,
+ embedded = false,
siteMapOpen = false,
siteMapPageCount,
settingsOpen,
@@ -86,6 +88,7 @@ export function DeskChrome( {
return (
: null }
rightChildren={
diff --git a/apps/ui/src/ui-desks/chrome/user-menu/index.test.ts b/apps/ui/src/ui-desks/chrome/user-menu/index.test.ts
new file mode 100644
index 0000000000..45388d8d12
--- /dev/null
+++ b/apps/ui/src/ui-desks/chrome/user-menu/index.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+import { getDeskMenuRouteTargets } from './index';
+
+describe( 'getDeskMenuRouteTargets', () => {
+ it( 'keeps standalone Desk navigation on the standalone routes', () => {
+ expect( getDeskMenuRouteTargets( false ) ).toEqual( {
+ userDesk: '/',
+ siteDesk: '/sites/$siteId',
+ } );
+ } );
+
+ it( 'returns to the embedded user Desk route in Studio 2.0', () => {
+ expect( getDeskMenuRouteTargets( true ) ).toEqual( {
+ userDesk: '/desk',
+ siteDesk: '/sites/$siteId',
+ } );
+ } );
+} );
diff --git a/apps/ui/src/ui-desks/chrome/user-menu/index.tsx b/apps/ui/src/ui-desks/chrome/user-menu/index.tsx
index a2df3ae1b7..4fbe7812d2 100644
--- a/apps/ui/src/ui-desks/chrome/user-menu/index.tsx
+++ b/apps/ui/src/ui-desks/chrome/user-menu/index.tsx
@@ -17,15 +17,33 @@ const WPCOM_PROFILE_URL = 'https://wordpress.com/me';
interface DeskMenuProps {
siteId?: string;
+ embedded?: boolean;
disabled?: boolean;
showSiteName?: boolean;
}
+interface DeskMenuRouteTargets {
+ userDesk: '/' | '/desk';
+ siteDesk: '/sites/$siteId';
+}
+
+export function getDeskMenuRouteTargets( embedded = false ): DeskMenuRouteTargets {
+ return {
+ userDesk: embedded ? '/desk' : '/',
+ siteDesk: '/sites/$siteId',
+ };
+}
+
function getSiteIconSeed( site: SiteDetails ) {
return `${ site.id }:${ site.name }:${ site.path }`;
}
-export function DeskMenu( { siteId, disabled = false, showSiteName = true }: DeskMenuProps ) {
+export function DeskMenu( {
+ siteId,
+ embedded = false,
+ disabled = false,
+ showSiteName = true,
+}: DeskMenuProps ) {
const navigate = useNavigate();
const connector = useConnector();
const { data: user } = useAuthUser();
@@ -43,13 +61,14 @@ export function DeskMenu( { siteId, disabled = false, showSiteName = true }: Des
const switcherSites = activeSite
? [ activeSite, ...( sites ?? [] ).filter( ( candidate ) => candidate.id !== activeSite.id ) ]
: sites ?? [];
+ const routes = getDeskMenuRouteTargets( embedded );
const openLink = ( url: string ) => {
void connector.openExternalUrl( url );
};
const openUserDashboard = () => {
- void navigate( { to: '/' } );
+ void navigate( { to: routes.userDesk } );
};
const switchToDefaultUi = () => {
@@ -60,7 +79,7 @@ export function DeskMenu( { siteId, disabled = false, showSiteName = true }: Des
if ( nextSiteId === siteId ) {
return;
}
- void navigate( { to: '/sites/$siteId', params: { siteId: nextSiteId } } );
+ void navigate( { to: routes.siteDesk, params: { siteId: nextSiteId } } );
};
const trigger = siteId ? (
diff --git a/apps/ui/src/ui-desks/desk/canvas/index.tsx b/apps/ui/src/ui-desks/desk/canvas/index.tsx
index 22cf3f5cda..9eb10a7f4e 100644
--- a/apps/ui/src/ui-desks/desk/canvas/index.tsx
+++ b/apps/ui/src/ui-desks/desk/canvas/index.tsx
@@ -87,7 +87,14 @@ export function DeskCanvas() {
event.preventDefault();
event.stopPropagation();
- setContextMenu( resolveDeskContextMenuState( editor, event.clientX, event.clientY ) );
+ const embeddedRoot = event.currentTarget.closest(
+ '[data-ui-desks-embedded="true"]'
+ ) as HTMLElement | null;
+ setContextMenu(
+ resolveDeskContextMenuState( editor, event.clientX, event.clientY, {
+ boundaryRect: embeddedRoot?.getBoundingClientRect(),
+ } )
+ );
},
[ editor, isReadOnly ]
);
diff --git a/apps/ui/src/ui-desks/desk/canvas/style.module.css b/apps/ui/src/ui-desks/desk/canvas/style.module.css
index 4cee581b6a..7eedc881b4 100644
--- a/apps/ui/src/ui-desks/desk/canvas/style.module.css
+++ b/apps/ui/src/ui-desks/desk/canvas/style.module.css
@@ -11,6 +11,13 @@
min-height: 100vh;
}
+:global([data-ui-desks-embedded='true']) .canvas,
+:global([data-ui-desks-embedded='true']) .loading {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+}
+
.statusMessage {
position: absolute;
inset: 0;
diff --git a/apps/ui/src/ui-desks/desk/context-menu/index.tsx b/apps/ui/src/ui-desks/desk/context-menu/index.tsx
index 3033a26c0f..97db39112d 100644
--- a/apps/ui/src/ui-desks/desk/context-menu/index.tsx
+++ b/apps/ui/src/ui-desks/desk/context-menu/index.tsx
@@ -793,7 +793,8 @@ function ContextMenuSeparator() {
}
function getMenuPosition( state: DeskContextMenuState, menuMode: MenuMode ) {
- if ( typeof window === 'undefined' ) {
+ const boundary = state.boundary;
+ if ( typeof window === 'undefined' && ! boundary ) {
return {
left: state.x,
top: state.y,
@@ -804,14 +805,13 @@ function getMenuPosition( state: DeskContextMenuState, menuMode: MenuMode ) {
menuMode === 'pick-post' || menuMode === 'pick-page' || menuMode === 'pick-site-card'
? PICKER_WIDTH
: MENU_WIDTH;
+ const maxWidth = boundary?.width ?? window.innerWidth;
+ const maxHeight = boundary?.height ?? window.innerHeight;
return {
- left: Math.max(
- VIEWPORT_MARGIN,
- Math.min( state.x, window.innerWidth - width - VIEWPORT_MARGIN )
- ),
+ left: Math.max( VIEWPORT_MARGIN, Math.min( state.x, maxWidth - width - VIEWPORT_MARGIN ) ),
top: Math.max(
VIEWPORT_MARGIN,
- Math.min( state.y, window.innerHeight - MENU_MAX_HEIGHT - VIEWPORT_MARGIN )
+ Math.min( state.y, maxHeight - MENU_MAX_HEIGHT - VIEWPORT_MARGIN )
),
};
}
diff --git a/apps/ui/src/ui-desks/desk/context-menu/state/index.test.ts b/apps/ui/src/ui-desks/desk/context-menu/state/index.test.ts
index 8cfbbe37b9..5288c52f2f 100644
--- a/apps/ui/src/ui-desks/desk/context-menu/state/index.test.ts
+++ b/apps/ui/src/ui-desks/desk/context-menu/state/index.test.ts
@@ -64,6 +64,34 @@ describe( 'resolveDeskContextMenuState', () => {
expect( state.shapeIds ).toEqual( [ 'shape:b' ] );
expect( editor.setSelectedShapes ).toHaveBeenCalledWith( [ 'shape:b' ] );
} );
+
+ it( 'keeps page coordinates viewport-based while positioning menus inside an embedded boundary', () => {
+ const editor = createEditorMock( {
+ shapes: [],
+ hitShapeId: null,
+ } );
+
+ const state = resolveDeskContextMenuState( editor, 312, 144, {
+ boundaryRect: {
+ left: 240,
+ top: 40,
+ width: 800,
+ height: 600,
+ },
+ } );
+
+ expect( state ).toMatchObject( {
+ kind: 'empty',
+ pagePoint: { x: 312, y: 144 },
+ x: 72,
+ y: 104,
+ boundary: {
+ width: 800,
+ height: 600,
+ },
+ } );
+ expect( editor.screenToPage ).toHaveBeenCalledWith( { x: 312, y: 144 } );
+ } );
} );
function createShape( id: string, stackId?: string ) {
diff --git a/apps/ui/src/ui-desks/desk/context-menu/state/index.ts b/apps/ui/src/ui-desks/desk/context-menu/state/index.ts
index 6fedd2d36b..d2937a7758 100644
--- a/apps/ui/src/ui-desks/desk/context-menu/state/index.ts
+++ b/apps/ui/src/ui-desks/desk/context-menu/state/index.ts
@@ -7,6 +7,10 @@ export interface DeskContextMenuState {
kind: DeskContextMenuKind;
x: number;
y: number;
+ boundary?: {
+ width: number;
+ height: number;
+ };
pagePoint: {
x: number;
y: number;
@@ -26,9 +30,24 @@ export type ContextMenuResolverEditor = Pick<
export function resolveDeskContextMenuState(
editor: ContextMenuResolverEditor,
x: number,
- y: number
+ y: number,
+ options: {
+ boundaryRect?: Pick< DOMRect, 'left' | 'top' | 'width' | 'height' >;
+ } = {}
): DeskContextMenuState {
const pagePoint = editor.screenToPage( { x, y } );
+ const boundary = options.boundaryRect
+ ? {
+ width: options.boundaryRect.width,
+ height: options.boundaryRect.height,
+ }
+ : undefined;
+ const menuPoint = options.boundaryRect
+ ? {
+ x: x - options.boundaryRect.left,
+ y: y - options.boundaryRect.top,
+ }
+ : { x, y };
const shape = editor.getShapeAtPoint( pagePoint, {
hitInside: true,
renderingOnly: true,
@@ -36,7 +55,7 @@ export function resolveDeskContextMenuState(
if ( ! shape ) {
editor.setSelectedShapes( [] );
- return { kind: 'empty', shapeIds: [], pagePoint, x, y };
+ return { kind: 'empty', shapeIds: [], pagePoint, x: menuPoint.x, y: menuPoint.y, boundary };
}
const stackId = getStackId( shape );
@@ -46,14 +65,35 @@ export function resolveDeskContextMenuState(
.filter( ( member ) => getStackId( member ) === stackId )
.map( ( member ) => member.id );
editor.setSelectedShapes( memberIds );
- return { kind: 'multi', shapeIds: memberIds, pagePoint, x, y };
+ return {
+ kind: 'multi',
+ shapeIds: memberIds,
+ pagePoint,
+ x: menuPoint.x,
+ y: menuPoint.y,
+ boundary,
+ };
}
const currentSelection = editor.getSelectedShapeIds();
if ( currentSelection.includes( shape.id ) && currentSelection.length > 1 ) {
- return { kind: 'multi', shapeIds: currentSelection, pagePoint, x, y };
+ return {
+ kind: 'multi',
+ shapeIds: currentSelection,
+ pagePoint,
+ x: menuPoint.x,
+ y: menuPoint.y,
+ boundary,
+ };
}
editor.setSelectedShapes( [ shape.id ] );
- return { kind: 'single', shapeIds: [ shape.id ], pagePoint, x, y };
+ return {
+ kind: 'single',
+ shapeIds: [ shape.id ],
+ pagePoint,
+ x: menuPoint.x,
+ y: menuPoint.y,
+ boundary,
+ };
}
diff --git a/apps/ui/src/ui-desks/desk/context-menu/style.module.css b/apps/ui/src/ui-desks/desk/context-menu/style.module.css
index f500a2b39c..9c25cc5b85 100644
--- a/apps/ui/src/ui-desks/desk/context-menu/style.module.css
+++ b/apps/ui/src/ui-desks/desk/context-menu/style.module.css
@@ -13,8 +13,8 @@
flex-direction: column;
gap: 2px;
width: 240px;
- max-width: calc(100vw - 16px);
- max-height: min(420px, calc(100vh - 16px));
+ max-width: calc(100% - 16px);
+ max-height: min(420px, calc(100% - 16px));
overflow-y: auto;
padding: 6px;
background: var(--ui-desks-material, #fff);
diff --git a/apps/ui/src/ui-desks/desk/index.tsx b/apps/ui/src/ui-desks/desk/index.tsx
index 1d26adfcf2..fef303ba51 100644
--- a/apps/ui/src/ui-desks/desk/index.tsx
+++ b/apps/ui/src/ui-desks/desk/index.tsx
@@ -22,21 +22,22 @@ import type { CSSProperties, ReactNode } from 'react';
interface DeskProps {
siteId?: string;
+ embedded?: boolean;
}
-export function Desk( { siteId }: DeskProps ) {
+export function Desk( { siteId, embedded = false }: DeskProps ) {
if ( siteId ) {
- return ;
+ return ;
}
- return ;
+ return ;
}
-function UserDesk() {
+function UserDesk( { embedded = false }: Pick< DeskProps, 'embedded' > ) {
return (
-
+
@@ -44,7 +45,10 @@ function UserDesk() {
);
}
-function SiteDesk( { siteId }: Required< DeskProps > ) {
+function SiteDesk( {
+ siteId,
+ embedded = false,
+}: Required< Pick< DeskProps, 'siteId' > > & Pick< DeskProps, 'embedded' > ) {
const [ siteMapOpen, setSiteMapOpen ] = useState( false );
const siteMap = useSiteMapDeskConfig( siteId, siteMapOpen );
const providerKey = siteMapOpen ? `${ siteId }:site-map:${ siteMap.signature }` : siteId;
@@ -63,6 +67,7 @@ function SiteDesk( { siteId }: Required< DeskProps > ) {
>
) {
function DeskShell( {
siteId,
+ embedded = false,
siteMapOpen,
siteMapIsLoading,
siteMapPageCount,
@@ -89,6 +95,7 @@ function DeskShell( {
onToggleSiteMap?: () => void;
children: ReactNode;
} ) {
+ const [ rootElement, setRootElement ] = useState< HTMLElement | null >( null );
const updateDeskSettings = useUpdateDeskSettings();
const { data: savedSettings } = useDeskSettings();
const fallbackSettings = useMemo( () => createDefaultDeskSettings(), [] );
@@ -110,74 +117,81 @@ function DeskShell( {
} as CSSProperties;
const [ settingsOpen, setSettingsOpen ] = useState( false );
const [ editingToolbar, setEditingToolbar ] = useState( false );
+ const ShellElement = embedded ? 'div' : 'main';
return (
- <>
-
-
- setSettingsOpen( ( open ) => ! open ) }
- />
- { children }
- { siteMapIsLoading && }
-
- setEditingToolbar( true ) }
- />
- { editingToolbar && (
- <>
-