From 10aa84d70531aabfbeed2580e7c8b39df3301318 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Tue, 26 May 2026 10:47:09 -0400 Subject: [PATCH 1/2] Add Studio 2.0 UI design spec --- .../specs/2026-05-26-studio-2-ui-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-studio-2-ui-design.md diff --git a/docs/superpowers/specs/2026-05-26-studio-2-ui-design.md b/docs/superpowers/specs/2026-05-26-studio-2-ui-design.md new file mode 100644 index 0000000000..61971d4e5d --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-studio-2-ui-design.md @@ -0,0 +1,158 @@ +# Studio 2.0 UI Design Spec + +## Goal + +Create a new branch that turns the current Agentic UI into **Studio 2.0** by adding the global Desks experience as a sidebar destination. Studio 2.0 replaces the separate user-facing Agentic UI and Desks UI mode options. + +## Product Direction + +Studio 2.0 is not a third independent shell. It is the current Agentic UI, renamed, with Desk added as a first-class view. + +The default Studio UI remains available as the stable/current experience. Studio 2.0 becomes the single experimental next-generation experience. Users should no longer see separate **Agentic UI** and **Desks UI** choices in Settings or switcher menus. + +## User Experience + +When Studio 2.0 is active, the existing Agentic sidebar remains the primary navigation surface. The sidebar should include: + +- Chat +- Desk +- Settings +- Skills + +Clicking **Desk** opens the global user desk by default. It does not infer a site-specific desk from the current session or selected site. + +The Desk view replaces only the right-side content area of the Agentic shell. The Agentic sidebar remains visible and usable. The embedded Desk keeps the full Desks experience: + +- Desk toolbar +- Desk create menu +- Desk settings +- Desk canvas +- Desk widget toolbar +- Desk chat panel +- User desk persistence + +The standalone Desks UI can remain in code as reusable internals, but it should no longer be exposed as a top-level user-facing mode. + +## Mode Model + +The app should expose two user-facing UI modes: + +- Default Studio UI +- Studio 2.0 + +The persisted Studio 2.0 mode should use a stable internal key, recommended: `studio2`. + +Legacy stored values should be treated as compatibility aliases: + +- `agentic` resolves to `studio2` +- `desks` resolves to `studio2` +- `studio2` resolves to `studio2` +- `default` resolves to `default` +- invalid or missing values resolve to `default` + +New writes should persist only normalized values. If a user selects Studio 2.0, config should store `desks.defaultUiMode: "studio2"`, not `agentic` or `desks`. + +Renderer launch URLs should use `studio-ui-mode=studio2` for Studio 2.0. Legacy query values `agentic` and `desks` should still launch the Studio 2.0 shell for compatibility. + +## Architecture + +Studio 2.0 should reuse the current Agentic shell by rendering `ClassicUiApp`. The existing `ClassicUiApp` router already owns the Agentic sidebar layout and right-side content area, so the Desk integration should be route-based inside that router. + +Add a new route under the existing dashboard layout: + +- Path: `/desk` +- Parent: `dashboardLayoutRoute` +- Component: a small wrapper that renders the global Desks `` without a `siteId` + +This route should inherit `SidebarLayout`, so the Desk surface appears in the Agentic right-side panel while the sidebar remains visible. + +The route should import the reusable Desks `Desk` component from `apps/ui/src/ui-desks/desk`. It should not mount `DesksUiApp`, because that would create a second top-level router and app shell inside the Agentic shell. + +## Component Boundaries + +Primary files expected to change: + +- `tools/common/types/desk.ts`: add the normalized `studio2` mode while preserving legacy type handling where needed. +- `apps/studio/src/main-window.ts`: normalize stored UI mode and load the Studio 2.0 renderer path/query. +- `apps/studio/src/modules/desks/lib/ipc-handlers.ts`: normalize mode reads and writes, including legacy values. +- `apps/studio/src/modules/user-settings/components/preferences-tab.tsx`: show Studio 2.0 as the single next-generation option instead of separate Desks and Agentic buttons. +- `apps/ui/src/app/use-ui-mode.ts`: map `studio2`, `agentic`, and `desks` launch params to the Agentic shell mode. +- `apps/ui/src/components/sidebar-nav/index.tsx`: add the Desk sidebar item. +- `apps/ui/src/ui-classic/router/router.tsx`: register the new Desk route. +- `apps/ui/src/ui-classic/router/route-desk/index.tsx`: render the embedded global Desk. + +Tests should live near the affected code, following the repo's current patterns. + +## Data Flow + +Settings calls `setStudioUiMode( "studio2" )`. The main process validates and normalizes the value, persists it under `userData.desks.defaultUiMode`, and reloads the renderer. + +At app launch, the main process reads `userData.desks.defaultUiMode`, normalizes it, and selects the renderer: + +- `default`: existing default renderer +- `studio2`, `agentic`, or `desks`: Desks renderer bundle with `studio-ui-mode=studio2` + +Inside the Desks renderer bundle, `useUiMode()` treats `studio2`, `agentic`, and `desks` as the Agentic shell. The `/desk` route is then responsible for embedding the global Desk within that shell. + +## Error Handling + +Invalid mode values should never break startup. They should fall back to `default`. + +Legacy values should not be destructive. Reading `agentic` or `desks` should launch Studio 2.0 even before the next settings write normalizes the stored value. + +The embedded Desk route should use the existing global Desk behavior. If Desk data is still loading, existing Desks loading and placeholder states should render unchanged. + +## Accessibility And Interaction + +The Desk sidebar item should behave like the existing Chat and Settings items: + +- Uses the existing `SidebarButton` pattern +- Has an icon and localized label +- Supports active route styling +- Works with keyboard navigation through the existing link/button semantics + +The embedded Desk should keep its existing accessible labels, toolbar controls, and modal behavior. The integration should not add a second app-level sidebar inside the Desk content. + +## Testing Plan + +Add or update unit tests for mode normalization: + +- `default` stays `default` +- `studio2` stays `studio2` +- stored `agentic` resolves to `studio2` +- stored `desks` resolves to `studio2` +- invalid values fall back to `default` +- `setStudioUiMode( "studio2" )` persists `studio2` + +Add UI/router coverage for the Agentic shell: + +- Sidebar includes Chat, Desk, Settings, and Skills +- Clicking Desk navigates to `/desk` +- `/desk` renders the global Desk surface +- The sidebar remains visible while Desk is shown + +Manual verification: + +- Launch with `ENABLE_DESKS_UI_SWITCH=true` +- Confirm Settings shows Studio 2.0, not separate Agentic UI and Desks UI choices +- Switch to Studio 2.0 +- Click Desk in the sidebar +- Confirm the full Desk toolbar and Desk chat panel remain available +- Restart the app and confirm Studio 2.0 is restored +- Force stored config values `agentic` and `desks`, then confirm both open Studio 2.0 + +## Out Of Scope + +This branch should not: + +- Merge all Desks routes into the Agentic router +- Add site-specific Desk routing from the sidebar item +- Remove the standalone Desks code paths if they are still needed as reusable internals +- Redesign Desk chrome or Agentic chrome beyond the new sidebar item and mode labels +- Change Desk widget behavior, site map behavior, or Desk chat behavior + +## Open Risks + +The main risk is duplicated chat surfaces: Agentic already has chat-oriented navigation, and the embedded Desk keeps its own Desk chat panel. This is intentional for this branch because the requirement is to keep the full Desks experience. If it proves noisy in practice, it should be evaluated in a follow-up branch rather than folded into this initial integration. + +The second risk is route/search interaction. The Desks chat provider uses route search params for chat state. The new `/desk` route should preserve those search params through the existing packaged router history. Tests should cover navigation enough to catch obvious regressions. From 588b5afb8df7f5edb73e4595c24fe491dbb6151b Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Thu, 28 May 2026 10:19:07 -0400 Subject: [PATCH 2/2] Add Studio 2.0 UI mode proof of concept --- apps/studio/src/main-window.ts | 11 +- .../modules/desks/lib/ipc-handlers.test.ts | 102 +++++++++++++ .../src/modules/desks/lib/ipc-handlers.ts | 29 ++-- .../components/preferences-tab.tsx | 19 +-- apps/ui/src/app/use-ui-mode.test.ts | 16 ++ apps/ui/src/app/use-ui-mode.ts | 16 +- .../components/settings-view/index.test.tsx | 115 ++++++++++++++ .../ui/src/components/settings-view/index.tsx | 12 ++ .../components/settings-view/style.module.css | 14 ++ .../src/components/sidebar-nav/index.test.tsx | 59 +++++++ apps/ui/src/components/sidebar-nav/index.tsx | 3 +- .../ui-classic/router/route-desk/index.tsx | 24 +++ .../router/route-desk/style.module.css | 6 + .../router/route-site-desk/index.tsx | 29 ++++ apps/ui/src/ui-classic/router/router.test.ts | 23 +++ apps/ui/src/ui-classic/router/router.tsx | 4 + apps/ui/src/ui-desks/chats/panel/index.tsx | 18 ++- .../src/ui-desks/chats/panel/style.module.css | 8 + apps/ui/src/ui-desks/chrome/header/index.tsx | 17 ++- .../ui-desks/chrome/header/style.module.css | 24 +++ apps/ui/src/ui-desks/chrome/index.tsx | 4 + .../ui-desks/chrome/user-menu/index.test.ts | 18 +++ .../src/ui-desks/chrome/user-menu/index.tsx | 25 ++- apps/ui/src/ui-desks/desk/canvas/index.tsx | 9 +- .../src/ui-desks/desk/canvas/style.module.css | 7 + .../src/ui-desks/desk/context-menu/index.tsx | 12 +- .../desk/context-menu/state/index.test.ts | 28 ++++ .../ui-desks/desk/context-menu/state/index.ts | 50 +++++- .../desk/context-menu/style.module.css | 4 +- apps/ui/src/ui-desks/desk/index.tsx | 144 ++++++++++-------- apps/ui/src/ui-desks/desk/style.module.css | 21 +++ .../specs/2026-05-26-studio-2-ui-design.md | 4 + tools/common/lib/studio-ui-mode.ts | 23 +++ tools/common/lib/tests/studio-ui-mode.test.ts | 31 ++++ tools/common/types/desk.ts | 6 +- 35 files changed, 805 insertions(+), 130 deletions(-) create mode 100644 apps/studio/src/modules/desks/lib/ipc-handlers.test.ts create mode 100644 apps/ui/src/app/use-ui-mode.test.ts create mode 100644 apps/ui/src/components/settings-view/index.test.tsx create mode 100644 apps/ui/src/components/sidebar-nav/index.test.tsx create mode 100644 apps/ui/src/ui-classic/router/route-desk/index.tsx create mode 100644 apps/ui/src/ui-classic/router/route-desk/style.module.css create mode 100644 apps/ui/src/ui-classic/router/route-site-desk/index.tsx create mode 100644 apps/ui/src/ui-classic/router/router.test.ts create mode 100644 apps/ui/src/ui-desks/chrome/user-menu/index.test.ts create mode 100644 tools/common/lib/studio-ui-mode.ts create mode 100644 tools/common/lib/tests/studio-ui-mode.test.ts 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 ( - +
+ + ) } + ); } function SiteMapLoadingWidget() { return ( -
+
); diff --git a/apps/ui/src/ui-desks/desk/style.module.css b/apps/ui/src/ui-desks/desk/style.module.css index 07268d261a..b987458ac1 100644 --- a/apps/ui/src/ui-desks/desk/style.module.css +++ b/apps/ui/src/ui-desks/desk/style.module.css @@ -3,6 +3,15 @@ background: var(--ui-desks-bg, rgb(232, 234, 235)); } +.root[data-ui-desks-embedded='true'] { + position: relative; + height: 100%; + min-height: 0; + overflow: hidden; + isolation: isolate; + transform: translateZ(0); +} + .root[data-toolbar-editing='true'] [data-ui-desks-canvas] { filter: blur(8px) brightness(0.96); transition: filter 220ms ease; @@ -61,3 +70,15 @@ border: 3px solid #14171a; border-radius: 8px; } + +.root[data-ui-desks-embedded='true'] .toolbarEditBackdrop { + position: fixed; +} + +.root[data-ui-desks-embedded='true'] .toolbarEditActions { + top: var(--wpds-dimension-padding-xl); +} + +.root[data-ui-desks-embedded='true'] .siteMapLoadingWidget { + position: fixed; +} diff --git a/docs/superpowers/specs/2026-05-26-studio-2-ui-design.md b/docs/superpowers/specs/2026-05-26-studio-2-ui-design.md index 61971d4e5d..c0f1b8dd8c 100644 --- a/docs/superpowers/specs/2026-05-26-studio-2-ui-design.md +++ b/docs/superpowers/specs/2026-05-26-studio-2-ui-design.md @@ -40,6 +40,8 @@ The app should expose two user-facing UI modes: - Default Studio UI - Studio 2.0 +Both settings surfaces need an escape path. The existing default Studio Settings modal should offer **Studio 2.0**. The Studio 2.0 settings route should offer a way to return to **Default Studio UI**. This prevents logged-out users from getting trapped in Studio 2.0. + The persisted Studio 2.0 mode should use a stable internal key, recommended: `studio2`. Legacy stored values should be treated as compatibility aliases: @@ -77,6 +79,7 @@ Primary files expected to change: - `apps/studio/src/modules/desks/lib/ipc-handlers.ts`: normalize mode reads and writes, including legacy values. - `apps/studio/src/modules/user-settings/components/preferences-tab.tsx`: show Studio 2.0 as the single next-generation option instead of separate Desks and Agentic buttons. - `apps/ui/src/app/use-ui-mode.ts`: map `studio2`, `agentic`, and `desks` launch params to the Agentic shell mode. +- `apps/ui/src/components/settings-view/index.tsx`: add a Studio UI setting that can switch Studio 2.0 back to Default Studio UI. - `apps/ui/src/components/sidebar-nav/index.tsx`: add the Desk sidebar item. - `apps/ui/src/ui-classic/router/router.tsx`: register the new Desk route. - `apps/ui/src/ui-classic/router/route-desk/index.tsx`: render the embedded global Desk. @@ -136,6 +139,7 @@ Manual verification: - Launch with `ENABLE_DESKS_UI_SWITCH=true` - Confirm Settings shows Studio 2.0, not separate Agentic UI and Desks UI choices - Switch to Studio 2.0 +- From Studio 2.0 Settings, switch back to Default Studio UI while logged out - Click Desk in the sidebar - Confirm the full Desk toolbar and Desk chat panel remain available - Restart the app and confirm Studio 2.0 is restored diff --git a/tools/common/lib/studio-ui-mode.ts b/tools/common/lib/studio-ui-mode.ts new file mode 100644 index 0000000000..46058e1d47 --- /dev/null +++ b/tools/common/lib/studio-ui-mode.ts @@ -0,0 +1,23 @@ +import type { StoredStudioUiMode, StudioUiMode } from '../types/desk'; + +export const STUDIO_UI_MODE_DEFAULT: StudioUiMode = 'default'; +export const STUDIO_UI_MODE_STUDIO2: StudioUiMode = 'studio2'; + +const SUPPORTED_STUDIO_UI_MODES = new Set< StoredStudioUiMode >( [ + 'default', + 'studio2', + 'agentic', + 'desks', +] ); + +export function normalizeStudioUiMode( mode: unknown ): StudioUiMode { + return mode === 'studio2' || mode === 'agentic' || mode === 'desks' + ? STUDIO_UI_MODE_STUDIO2 + : STUDIO_UI_MODE_DEFAULT; +} + +export function assertSupportedStudioUiMode( mode: unknown ): asserts mode is StoredStudioUiMode { + if ( ! SUPPORTED_STUDIO_UI_MODES.has( mode as StoredStudioUiMode ) ) { + throw new Error( 'Invalid Studio UI mode.' ); + } +} diff --git a/tools/common/lib/tests/studio-ui-mode.test.ts b/tools/common/lib/tests/studio-ui-mode.test.ts new file mode 100644 index 0000000000..24eec5371c --- /dev/null +++ b/tools/common/lib/tests/studio-ui-mode.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + assertSupportedStudioUiMode, + normalizeStudioUiMode, + STUDIO_UI_MODE_DEFAULT, + STUDIO_UI_MODE_STUDIO2, +} from '../studio-ui-mode'; + +describe( 'normalizeStudioUiMode', () => { + it.each( [ + [ 'default', STUDIO_UI_MODE_DEFAULT ], + [ 'studio2', STUDIO_UI_MODE_STUDIO2 ], + [ 'agentic', STUDIO_UI_MODE_STUDIO2 ], + [ 'desks', STUDIO_UI_MODE_STUDIO2 ], + [ 'bogus', STUDIO_UI_MODE_DEFAULT ], + [ undefined, STUDIO_UI_MODE_DEFAULT ], + [ null, STUDIO_UI_MODE_DEFAULT ], + ] )( 'normalizes %s to %s', ( input, expected ) => { + expect( normalizeStudioUiMode( input ) ).toBe( expected ); + } ); +} ); + +describe( 'assertSupportedStudioUiMode', () => { + it.each( [ 'default', 'studio2', 'agentic', 'desks' ] )( 'accepts %s', ( input ) => { + expect( () => assertSupportedStudioUiMode( input ) ).not.toThrow(); + } ); + + it( 'rejects unknown modes', () => { + expect( () => assertSupportedStudioUiMode( 'bogus' ) ).toThrow( 'Invalid Studio UI mode.' ); + } ); +} ); diff --git a/tools/common/types/desk.ts b/tools/common/types/desk.ts index 84f1272534..e70c0c4a5b 100644 --- a/tools/common/types/desk.ts +++ b/tools/common/types/desk.ts @@ -1,7 +1,9 @@ export const DESK_CONFIG_VERSION = 1; export const DESK_SETTINGS_VERSION = 1; -export type StudioUiMode = 'default' | 'desks' | 'agentic'; +export type StudioUiMode = 'default' | 'studio2'; +export type LegacyStudioUiMode = 'desks' | 'agentic'; +export type StoredStudioUiMode = StudioUiMode | LegacyStudioUiMode; export interface DeskToolbarLayout { left: string[]; @@ -77,7 +79,7 @@ export interface DeskConfig< TWidget extends DeskWidgetBase = DeskWidgetBase > { } export interface DesksConfig { - defaultUiMode?: StudioUiMode; + defaultUiMode?: StoredStudioUiMode; settings?: DeskSettings; user?: DeskConfig; sites?: Record< string, DeskConfig >;