From 8f0cbb8f10e6229de2f1e1bbd7b4917f4ac867ad Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Sat, 9 May 2026 12:46:34 +0100 Subject: [PATCH 1/3] Unify desk components and add site widget persistence --- apps/studio/src/ipc-handlers.ts | 7 +- .../modules/desks/lib/ipc-handlers.test.ts | 75 +++++++++++++++++++ .../src/modules/desks/lib/ipc-handlers.ts | 40 ++++++++++ apps/studio/src/preload.ts | 3 + apps/ui/src/data/core/connectors/ipc/index.ts | 8 ++ apps/ui/src/data/core/types.ts | 2 + apps/ui/src/data/queries/use-desk-config.ts | 48 ++++++++++++ apps/ui/src/ui-desks/chrome/create-menu.tsx | 28 ++++++- apps/ui/src/ui-desks/chrome/index.tsx | 8 +- apps/ui/src/ui-desks/desk/actions-context.tsx | 71 ++++++++++++++++++ .../user-desk-canvas.tsx => desk/canvas.tsx} | 22 ++++-- .../default-desk.ts} | 0 apps/ui/src/ui-desks/desk/index.tsx | 63 ++++++++++++++++ .../{user-desk => desk}/style.module.css | 6 ++ .../tldraw-adapter.test.ts | 0 .../{user-desk => desk}/tldraw-adapter.ts | 0 apps/ui/src/ui-desks/site-desk/index.tsx | 18 +---- .../src/ui-desks/site-desk/style.module.css | 4 - apps/ui/src/ui-desks/user-desk/index.tsx | 39 +--------- .../ui-desks/widgets/create-widget.test.ts | 51 +++++++++++++ apps/ui/src/ui-desks/widgets/create-widget.ts | 45 +++++++++++ .../src/ui-desks/widgets/note/definition.ts | 21 ++++++ apps/ui/src/ui-desks/widgets/registry.ts | 16 ++++ apps/ui/src/ui-desks/widgets/types.ts | 13 +++- 24 files changed, 520 insertions(+), 68 deletions(-) create mode 100644 apps/studio/src/modules/desks/lib/ipc-handlers.test.ts create mode 100644 apps/ui/src/ui-desks/desk/actions-context.tsx rename apps/ui/src/ui-desks/{user-desk/user-desk-canvas.tsx => desk/canvas.tsx} (89%) rename apps/ui/src/ui-desks/{user-desk/default-user-desk.ts => desk/default-desk.ts} (100%) create mode 100644 apps/ui/src/ui-desks/desk/index.tsx rename apps/ui/src/ui-desks/{user-desk => desk}/style.module.css (81%) rename apps/ui/src/ui-desks/{user-desk => desk}/tldraw-adapter.test.ts (100%) rename apps/ui/src/ui-desks/{user-desk => desk}/tldraw-adapter.ts (100%) delete mode 100644 apps/ui/src/ui-desks/site-desk/style.module.css create mode 100644 apps/ui/src/ui-desks/widgets/create-widget.test.ts create mode 100644 apps/ui/src/ui-desks/widgets/create-widget.ts diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index f29669881a..f2c347f8ec 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -186,7 +186,12 @@ export { getDefaultSiteDirectory, saveDefaultSiteDirectory }; export { importSite, exportSite } from 'src/modules/import-export/lib/ipc-handlers'; -export { getUserDeskConfig, saveUserDeskConfig } from 'src/modules/desks/lib/ipc-handlers'; +export { + getSiteDeskConfig, + getUserDeskConfig, + saveSiteDeskConfig, + saveUserDeskConfig, +} from 'src/modules/desks/lib/ipc-handlers'; export { studioCodeSendMessage, 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..c00213c12c --- /dev/null +++ b/apps/studio/src/modules/desks/lib/ipc-handlers.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getSiteDeskConfig, saveSiteDeskConfig } from 'src/modules/desks/lib/ipc-handlers'; +import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; +import type { DeskConfig } from '@studio/common/types/desk'; +import type { IpcMainInvokeEvent } from 'electron'; + +vi.mock( 'src/storage/user-data', () => ( { + loadUserData: vi.fn(), + lockAppdata: vi.fn(), + saveUserData: vi.fn(), + unlockAppdata: vi.fn(), +} ) ); + +const event = {} as IpcMainInvokeEvent; + +const deskConfig: DeskConfig = { + version: 1, + updatedAt: '2026-05-09T00:00:00.000Z', + widgets: [], +}; + +describe( 'desks IPC handlers', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'loads a site desk config by site id', async () => { + vi.mocked( loadUserData ).mockResolvedValue( { + version: 1, + siteMetadata: {}, + desks: { + sites: { + 'site-1': deskConfig, + }, + }, + } ); + + await expect( getSiteDeskConfig( event, 'site-1' ) ).resolves.toBe( deskConfig ); + } ); + + it( 'saves a site desk config without replacing other desks', async () => { + const existingSiteDesk: DeskConfig = { + ...deskConfig, + updatedAt: '2026-05-08T00:00:00.000Z', + }; + vi.mocked( loadUserData ).mockResolvedValue( { + version: 1, + siteMetadata: {}, + desks: { + user: existingSiteDesk, + sites: { + 'site-1': existingSiteDesk, + }, + }, + } ); + + await saveSiteDeskConfig( event, 'site-2', deskConfig ); + + expect( vi.mocked( lockAppdata ).mock.invocationCallOrder[ 0 ] ).toBeLessThan( + vi.mocked( saveUserData ).mock.invocationCallOrder[ 0 ] + ); + expect( saveUserData ).toHaveBeenCalledWith( { + version: 1, + siteMetadata: {}, + desks: { + user: existingSiteDesk, + sites: { + 'site-1': existingSiteDesk, + 'site-2': deskConfig, + }, + }, + } ); + expect( unlockAppdata ).toHaveBeenCalled(); + } ); +} ); diff --git a/apps/studio/src/modules/desks/lib/ipc-handlers.ts b/apps/studio/src/modules/desks/lib/ipc-handlers.ts index a799f297df..fd5d12b685 100644 --- a/apps/studio/src/modules/desks/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/desks/lib/ipc-handlers.ts @@ -86,3 +86,43 @@ export async function saveUserDeskConfig( await unlockAppdata(); } } + +export async function getSiteDeskConfig( + _event: IpcMainInvokeEvent, + siteId: string +): Promise< DeskConfig | undefined > { + if ( typeof siteId !== 'string' || ! siteId ) { + throw new Error( 'Invalid site desk config: expected site id.' ); + } + + const userData = await loadUserData(); + return userData.desks?.sites?.[ siteId ]; +} + +export async function saveSiteDeskConfig( + _event: IpcMainInvokeEvent, + siteId: string, + config: DeskConfig +): Promise< void > { + if ( typeof siteId !== 'string' || ! siteId ) { + throw new Error( 'Invalid site desk config: expected site id.' ); + } + + assertDeskConfig( config ); + await lockAppdata(); + try { + const userData = await loadUserData(); + await saveUserData( { + ...userData, + desks: { + ...userData.desks, + sites: { + ...userData.desks?.sites, + [ siteId ]: config, + }, + }, + } ); + } finally { + await unlockAppdata(); + } +} diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index dd32066385..3ab35ecc5e 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -223,6 +223,9 @@ const api: IpcApi = { ipcRendererInvoke( 'setSessionEnvironment', sessionId, environment ), getUserDeskConfig: () => ipcRendererInvoke( 'getUserDeskConfig' ), saveUserDeskConfig: ( config ) => ipcRendererInvoke( 'saveUserDeskConfig', config ), + getSiteDeskConfig: ( siteId ) => ipcRendererInvoke( 'getSiteDeskConfig', siteId ), + saveSiteDeskConfig: ( siteId, config ) => + ipcRendererInvoke( 'saveSiteDeskConfig', siteId, config ), }; contextBridge.exposeInMainWorld( 'ipcApi', api ); diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index 807d0df5cf..1137c6caaf 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -606,6 +606,14 @@ export function createIpcConnector(): Connector { await ipcApi.saveUserDeskConfig( config ); }, + async getSiteDeskConfig( siteId ): Promise< DeskConfig | undefined > { + return ( await ipcApi.getSiteDeskConfig( siteId ) ) as DeskConfig | undefined; + }, + + async saveSiteDeskConfig( siteId, config ): Promise< void > { + await ipcApi.saveSiteDeskConfig( siteId, config ); + }, + async openSiteFolder( siteId ): Promise< void > { const sitePath = await resolveSiteFolder( siteId ); ipcApi.openLocalPath( sitePath ); diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 5ab56b0a96..2203e544b8 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -235,6 +235,8 @@ export interface Connector { // Desks getUserDeskConfig(): Promise< DeskConfig | undefined >; saveUserDeskConfig( config: DeskConfig ): Promise< void >; + getSiteDeskConfig( siteId: string ): Promise< DeskConfig | undefined >; + saveSiteDeskConfig( siteId: string, config: DeskConfig ): Promise< void >; // Open the given site's folder in the system file manager, preferred // editor, or preferred terminal. When no editor/terminal preference is diff --git a/apps/ui/src/data/queries/use-desk-config.ts b/apps/ui/src/data/queries/use-desk-config.ts index ddcbe9466f..390b7d1c77 100644 --- a/apps/ui/src/data/queries/use-desk-config.ts +++ b/apps/ui/src/data/queries/use-desk-config.ts @@ -3,6 +3,10 @@ import { useConnector } from '@/data/core'; import type { DeskConfig } from '@/data/core'; export const USER_DESK_CONFIG_QUERY_KEY = [ 'desk-config', 'user' ] as const; +export const siteDeskConfigQueryKey = ( siteId: string ) => + [ 'desk-config', 'site', siteId ] as const; +export const deskConfigQueryKey = ( siteId?: string ) => + siteId ? siteDeskConfigQueryKey( siteId ) : USER_DESK_CONFIG_QUERY_KEY; export function useUserDeskConfig() { const connector = useConnector(); @@ -23,3 +27,47 @@ export function useSaveUserDeskConfig() { }, } ); } + +export function useDeskConfig( siteId?: string ) { + const connector = useConnector(); + return useQuery( { + queryKey: deskConfigQueryKey( siteId ), + queryFn: () => + siteId ? connector.getSiteDeskConfig( siteId ) : connector.getUserDeskConfig(), + } ); +} + +export function useSaveDeskConfig( siteId?: string ) { + const connector = useConnector(); + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: ( config: DeskConfig ) => + ( siteId + ? connector.saveSiteDeskConfig( siteId, config ) + : connector.saveUserDeskConfig( config ) + ).then( () => config ), + onSuccess: ( config ) => { + queryClient.setQueryData( deskConfigQueryKey( siteId ), config ); + }, + } ); +} + +export function useSiteDeskConfig( siteId: string ) { + const connector = useConnector(); + return useQuery( { + queryKey: siteDeskConfigQueryKey( siteId ), + queryFn: () => connector.getSiteDeskConfig( siteId ), + } ); +} + +export function useSaveSiteDeskConfig( siteId: string ) { + const connector = useConnector(); + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: ( config: DeskConfig ) => + connector.saveSiteDeskConfig( siteId, config ).then( () => config ), + onSuccess: ( config ) => { + queryClient.setQueryData( siteDeskConfigQueryKey( siteId ), config ); + }, + } ); +} diff --git a/apps/ui/src/ui-desks/chrome/create-menu.tsx b/apps/ui/src/ui-desks/chrome/create-menu.tsx index 85c7fc8984..a31a21e2f0 100644 --- a/apps/ui/src/ui-desks/chrome/create-menu.tsx +++ b/apps/ui/src/ui-desks/chrome/create-menu.tsx @@ -3,16 +3,25 @@ import { __ } from '@wordpress/i18n'; import { comment, download, globe, plus } from '@wordpress/icons'; import { Icon } from '@wordpress/ui'; import * as Menu from '@/components/menu'; +import { useDeskActions } from '@/ui-desks/desk/actions-context'; +import { getCreatableWidgetDefinitions } from '@/ui-desks/widgets/registry'; import { DeskHeaderIconButton } from './header-button'; import styles from './style.module.css'; +import type { DeskChatsSearch } from '../chats/search'; export function DeskCreateMenu() { const navigate = useNavigate(); + const deskActions = useDeskActions(); + const creatableWidgetDefinitions = getCreatableWidgetDefinitions(); const createChat = () => { void navigate( { - to: '/', - search: { chats: true, newChat: Date.now() }, + to: '.', + search: ( previous: DeskChatsSearch ) => ( { + ...previous, + chats: true, + newChat: Date.now(), + } ), } ); }; @@ -30,6 +39,21 @@ export function DeskCreateMenu() { render={ } /> + { deskActions && ( + <> + { creatableWidgetDefinitions.map( ( definition ) => ( + deskActions.createWidget( definition.type ) } + > + { definition.creation.icon && } + { definition.creation.getLabel() } + + ) ) } + { creatableWidgetDefinitions.length > 0 && } + + ) } { __( 'New chat' ) } diff --git a/apps/ui/src/ui-desks/chrome/index.tsx b/apps/ui/src/ui-desks/chrome/index.tsx index 888918290d..216f732e8a 100644 --- a/apps/ui/src/ui-desks/chrome/index.tsx +++ b/apps/ui/src/ui-desks/chrome/index.tsx @@ -22,10 +22,14 @@ export function DeskHeader( { children }: DeskHeaderProps ) { ); } -export function DeskChrome() { +interface DeskChromeProps { + activeSiteId?: string; +} + +export function DeskChrome( { activeSiteId }: DeskChromeProps ) { return ( - + diff --git a/apps/ui/src/ui-desks/desk/actions-context.tsx b/apps/ui/src/ui-desks/desk/actions-context.tsx new file mode 100644 index 0000000000..31cf80e17a --- /dev/null +++ b/apps/ui/src/ui-desks/desk/actions-context.tsx @@ -0,0 +1,71 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { createShapeId, type Editor } from 'tldraw'; +import { createWidgetShape } from '@/ui-desks/widgets/create-widget'; +import type { ReactNode } from 'react'; + +interface DeskActions { + canCreateWidgets: boolean; + createWidget: ( type: string ) => boolean; + registerEditor: ( editor: Editor | null ) => void; +} + +const DeskActionsContext = createContext< DeskActions | null >( null ); + +export function DeskActionsProvider( { children }: { children: ReactNode } ) { + const [ editor, setEditor ] = useState< Editor | null >( null ); + const creationOffsetRef = useRef( 0 ); + + const createWidget = useCallback( + ( type: string ) => { + if ( ! editor ) { + return false; + } + + const viewportCenter = editor.getViewportPageBounds().center; + const offset = ( creationOffsetRef.current % 6 ) * 24; + + const createdWidget = createWidgetShape( { + id: createShapeId(), + type, + center: { + x: viewportCenter.x + offset, + y: viewportCenter.y + offset, + }, + } ); + + if ( ! createdWidget ) { + return false; + } + + creationOffsetRef.current += 1; + editor.createShape( createdWidget.shape ).select( createdWidget.shape.id ); + if ( createdWidget.startEditing ) { + editor.setEditingShape( createdWidget.shape.id ); + } + editor.focus(); + return true; + }, + [ editor ] + ); + + const value = useMemo( + () => ( { + canCreateWidgets: Boolean( editor ), + createWidget, + registerEditor: setEditor, + } ), + [ createWidget, editor ] + ); + + return { children }; +} + +export function useDeskActions() { + return useContext( DeskActionsContext ); +} + +export function useRegisterDeskEditor() { + return useContext( DeskActionsContext )?.registerEditor ?? noopRegisterEditor; +} + +function noopRegisterEditor() {} diff --git a/apps/ui/src/ui-desks/user-desk/user-desk-canvas.tsx b/apps/ui/src/ui-desks/desk/canvas.tsx similarity index 89% rename from apps/ui/src/ui-desks/user-desk/user-desk-canvas.tsx rename to apps/ui/src/ui-desks/desk/canvas.tsx index 7817ac8848..4a0d05cb22 100644 --- a/apps/ui/src/ui-desks/user-desk/user-desk-canvas.tsx +++ b/apps/ui/src/ui-desks/desk/canvas.tsx @@ -3,6 +3,7 @@ import { Tldraw, type Editor, type TldrawOptions } from 'tldraw'; import 'tldraw/tldraw.css'; import { DESK_CONFIG_VERSION, type DeskConfig } from '@/ui-desks/desk/types'; import { deskShapeUtils } from '@/ui-desks/shapes/registry'; +import { useRegisterDeskEditor } from './actions-context'; import styles from './style.module.css'; import { canvasCameraToDeskViewport, @@ -14,19 +15,30 @@ const deskCanvasOptions = { createTextOnCanvasDoubleClick: false, } satisfies Partial< TldrawOptions >; -interface UserDeskCanvasProps { +interface DeskCanvasProps { desk: DeskConfig; onChange: ( desk: DeskConfig ) => void; } -export function UserDeskCanvas( { desk, onChange }: UserDeskCanvasProps ) { +export function DeskCanvas( { desk, onChange }: DeskCanvasProps ) { const [ editor, setEditor ] = useState< Editor | null >( null ); const hydratedRef = useRef( false ); const saveTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + const registerEditor = useRegisterDeskEditor(); + + const handleMount = useCallback( + ( nextEditor: Editor ) => { + setEditor( nextEditor ); + registerEditor( nextEditor ); + }, + [ registerEditor ] + ); - const handleMount = useCallback( ( nextEditor: Editor ) => { - setEditor( nextEditor ); - }, [] ); + useEffect( () => { + return () => { + registerEditor( null ); + }; + }, [ registerEditor ] ); useEffect( () => { if ( ! editor || hydratedRef.current ) { diff --git a/apps/ui/src/ui-desks/user-desk/default-user-desk.ts b/apps/ui/src/ui-desks/desk/default-desk.ts similarity index 100% rename from apps/ui/src/ui-desks/user-desk/default-user-desk.ts rename to apps/ui/src/ui-desks/desk/default-desk.ts diff --git a/apps/ui/src/ui-desks/desk/index.tsx b/apps/ui/src/ui-desks/desk/index.tsx new file mode 100644 index 0000000000..54b84e69e4 --- /dev/null +++ b/apps/ui/src/ui-desks/desk/index.tsx @@ -0,0 +1,63 @@ +import { __ } from '@wordpress/i18n'; +import { useEffect, useMemo } from 'react'; +import { useDeskConfig, useSaveDeskConfig } from '@/data/queries/use-desk-config'; +import { DeskChats } from '../chats'; +import { DeskChrome } from '../chrome'; +import { DeskActionsProvider } from './actions-context'; +import { DeskCanvas as TldrawDeskCanvas } from './canvas'; +import { defaultUserDesk } from './default-desk'; +import styles from './style.module.css'; +import { DESK_CONFIG_VERSION, type DeskConfig } from './types'; + +interface DeskProps { + siteId?: string; +} + +export function Desk( { siteId }: DeskProps ) { + const canvasKey = siteId ?? 'user'; + + return ( + + +
+ + +
+
+ ); +} + +function DeskCanvas( { siteId }: DeskProps ) { + const { data: savedDesk, isLoading } = useDeskConfig( siteId ); + const { mutate: saveDeskConfig } = useSaveDeskConfig( siteId ); + const defaultDesk = useMemo( () => createDefaultDeskConfig( siteId ), [ siteId ] ); + const desk = ( savedDesk as DeskConfig | undefined ) ?? defaultDesk; + + useEffect( () => { + if ( ! isLoading && ! savedDesk ) { + saveDeskConfig( defaultDesk ); + } + }, [ defaultDesk, isLoading, savedDesk, saveDeskConfig ] ); + + if ( isLoading ) { + return
; + } + + return ; +} + +function createDefaultDeskConfig( siteId?: string ): DeskConfig { + if ( siteId ) { + return { + version: DESK_CONFIG_VERSION, + updatedAt: new Date().toISOString(), + widgets: [], + }; + } + + return defaultUserDesk; +} + +function getDeskLabel( siteId?: string ) { + return siteId ? __( 'Site desk' ) : __( 'User desk' ); +} diff --git a/apps/ui/src/ui-desks/user-desk/style.module.css b/apps/ui/src/ui-desks/desk/style.module.css similarity index 81% rename from apps/ui/src/ui-desks/user-desk/style.module.css rename to apps/ui/src/ui-desks/desk/style.module.css index bb0dce5623..26dfed05e5 100644 --- a/apps/ui/src/ui-desks/user-desk/style.module.css +++ b/apps/ui/src/ui-desks/desk/style.module.css @@ -1,3 +1,9 @@ +.root, +.loading { + min-height: 100vh; + background: rgb(232, 234, 235); +} + .canvas, .loading { width: 100vw; diff --git a/apps/ui/src/ui-desks/user-desk/tldraw-adapter.test.ts b/apps/ui/src/ui-desks/desk/tldraw-adapter.test.ts similarity index 100% rename from apps/ui/src/ui-desks/user-desk/tldraw-adapter.test.ts rename to apps/ui/src/ui-desks/desk/tldraw-adapter.test.ts diff --git a/apps/ui/src/ui-desks/user-desk/tldraw-adapter.ts b/apps/ui/src/ui-desks/desk/tldraw-adapter.ts similarity index 100% rename from apps/ui/src/ui-desks/user-desk/tldraw-adapter.ts rename to apps/ui/src/ui-desks/desk/tldraw-adapter.ts diff --git a/apps/ui/src/ui-desks/site-desk/index.tsx b/apps/ui/src/ui-desks/site-desk/index.tsx index a445586afe..5df22cba04 100644 --- a/apps/ui/src/ui-desks/site-desk/index.tsx +++ b/apps/ui/src/ui-desks/site-desk/index.tsx @@ -1,25 +1,11 @@ import { createRoute } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; -import { DeskChats, DeskChatsTrigger } from '../chats'; -import { DeskHeader } from '../chrome'; -import { DeskMenu } from '../chrome/user-menu'; +import { Desk } from '../desk'; import { desksRootRoute } from '../router/root'; -import styles from './style.module.css'; export function SiteDesk() { const { siteId } = siteDeskRoute.useParams(); - return ( - <> - -
- - - - -
- - ); + return ; } export const siteDeskRoute = createRoute( { diff --git a/apps/ui/src/ui-desks/site-desk/style.module.css b/apps/ui/src/ui-desks/site-desk/style.module.css deleted file mode 100644 index 421e4ae415..0000000000 --- a/apps/ui/src/ui-desks/site-desk/style.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.root { - min-height: 100vh; - background: var(--wpds-color-bg-canvas, #e9ecef); -} diff --git a/apps/ui/src/ui-desks/user-desk/index.tsx b/apps/ui/src/ui-desks/user-desk/index.tsx index 1aa080985b..35d1e9b49f 100644 --- a/apps/ui/src/ui-desks/user-desk/index.tsx +++ b/apps/ui/src/ui-desks/user-desk/index.tsx @@ -1,44 +1,9 @@ import { createRoute } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { useSaveUserDeskConfig, useUserDeskConfig } from '@/data/queries/use-desk-config'; -import { DeskChats } from '../chats'; -import { DeskChrome } from '../chrome'; +import { Desk } from '../desk'; import { desksRootRoute } from '../router/root'; -import { defaultUserDesk } from './default-user-desk'; -import styles from './style.module.css'; -import { UserDeskCanvas } from './user-desk-canvas'; -import type { DeskConfig } from '@/ui-desks/desk/types'; - -function UserDeskRoute() { - return ( - <> - - - - - ); -} - -export function UserDesk() { - const { data: savedDesk, isLoading } = useUserDeskConfig(); - const { mutate: saveUserDeskConfig } = useSaveUserDeskConfig(); - const desk = ( savedDesk as DeskConfig | undefined ) ?? defaultUserDesk; - - useEffect( () => { - if ( ! isLoading && ! savedDesk ) { - saveUserDeskConfig( defaultUserDesk ); - } - }, [ isLoading, savedDesk, saveUserDeskConfig ] ); - - if ( isLoading ) { - return
; - } - - return ; -} export const userDeskRoute = createRoute( { getParentRoute: () => desksRootRoute, path: '/', - component: UserDeskRoute, + component: Desk, } ); diff --git a/apps/ui/src/ui-desks/widgets/create-widget.test.ts b/apps/ui/src/ui-desks/widgets/create-widget.test.ts new file mode 100644 index 0000000000..202de753f8 --- /dev/null +++ b/apps/ui/src/ui-desks/widgets/create-widget.test.ts @@ -0,0 +1,51 @@ +import { createShapeId } from 'tldraw'; +import { describe, expect, it } from 'vitest'; +import { RECTANGLE_WIDGET_SHAPE_TYPE } from '@/ui-desks/shapes/rectangle-widget/types'; +import { createWidgetShape } from './create-widget'; + +describe( 'createWidgetShape', () => { + it( 'creates a note widget shape centered on the requested point', () => { + const createdWidget = createWidgetShape( { + id: createShapeId( 'note-1' ), + type: 'note', + center: { + x: 400, + y: 300, + }, + } ); + + expect( createdWidget ).toEqual( { + shape: { + id: 'shape:note-1', + type: RECTANGLE_WIDGET_SHAPE_TYPE, + x: 300, + y: 200, + props: { + widgetType: 'note', + shapeProps: { + w: 200, + h: 200, + }, + widgetProps: { + text: '', + tone: 'yellow', + }, + }, + }, + startEditing: true, + } ); + } ); + + it( 'ignores widget types without creation metadata', () => { + expect( + createWidgetShape( { + id: createShapeId( 'unsupported' ), + type: 'unsupported', + center: { + x: 0, + y: 0, + }, + } ) + ).toBeNull(); + } ); +} ); diff --git a/apps/ui/src/ui-desks/widgets/create-widget.ts b/apps/ui/src/ui-desks/widgets/create-widget.ts new file mode 100644 index 0000000000..649f4f9d69 --- /dev/null +++ b/apps/ui/src/ui-desks/widgets/create-widget.ts @@ -0,0 +1,45 @@ +import { getWidgetDefinition } from './registry'; +import type { TLShapeId, TLShapePartial, TLUnknownShape } from 'tldraw'; + +interface CreateWidgetShapeOptions { + id: TLShapeId; + type: string; + center: { + x: number; + y: number; + }; +} + +export interface CreatedWidgetShape { + shape: TLShapePartial< TLUnknownShape >; + startEditing: boolean; +} + +export function createWidgetShape( { + id, + type, + center, +}: CreateWidgetShapeOptions ): CreatedWidgetShape | null { + const definition = getWidgetDefinition( type ); + if ( ! definition?.creation ) { + return null; + } + + const initialWidget = definition.creation.getInitialWidget(); + const size = definition.creation.getInitialSize( initialWidget.shapeProps ); + + return { + shape: { + id, + type: definition.shapeType, + x: center.x - size.w / 2, + y: center.y - size.h / 2, + props: { + widgetType: definition.type, + shapeProps: initialWidget.shapeProps, + widgetProps: initialWidget.widgetProps, + }, + }, + startEditing: Boolean( definition.creation.startEditing ), + }; +} diff --git a/apps/ui/src/ui-desks/widgets/note/definition.ts b/apps/ui/src/ui-desks/widgets/note/definition.ts index 8f443bd00f..6f357096ab 100644 --- a/apps/ui/src/ui-desks/widgets/note/definition.ts +++ b/apps/ui/src/ui-desks/widgets/note/definition.ts @@ -1,3 +1,5 @@ +import { __ } from '@wordpress/i18n'; +import { pencil } from '@wordpress/icons'; import { RECTANGLE_WIDGET_SHAPE_TYPE } from '@/ui-desks/shapes/rectangle-widget/types'; import { NoteWidgetComponent } from '@/ui-desks/widgets/note/component'; import { @@ -30,4 +32,23 @@ export const noteWidgetDefinition = { cornerRadius: 14, stroke: NOTE_TONE_STROKE[ widgetProps.tone ], } ), + creation: { + getLabel: () => __( 'New sticky note' ), + icon: pencil, + getInitialWidget: () => ( { + shapeProps: { + w: 200, + h: 200, + }, + widgetProps: { + text: '', + tone: 'yellow', + }, + } ), + getInitialSize: ( shapeProps ) => ( { + w: shapeProps.w, + h: shapeProps.h, + } ), + startEditing: true, + }, } satisfies WidgetDefinition< NoteWidget >; diff --git a/apps/ui/src/ui-desks/widgets/registry.ts b/apps/ui/src/ui-desks/widgets/registry.ts index 7563d09108..bd0cb0357f 100644 --- a/apps/ui/src/ui-desks/widgets/registry.ts +++ b/apps/ui/src/ui-desks/widgets/registry.ts @@ -1,9 +1,25 @@ import { noteWidgetDefinition } from '@/ui-desks/widgets/note/definition'; +import type { WidgetCreationDefinition } from './types'; export const widgetDefinitions = { [ noteWidgetDefinition.type ]: noteWidgetDefinition, }; +type RegisteredWidgetDefinition = ( typeof widgetDefinitions )[ keyof typeof widgetDefinitions ]; +export type CreatableWidgetDefinition = RegisteredWidgetDefinition & { + creation: WidgetCreationDefinition; +}; + export function getWidgetDefinition( type: string ) { return widgetDefinitions[ type as keyof typeof widgetDefinitions ]; } + +export function getCreatableWidgetDefinitions() { + return Object.values( widgetDefinitions ).filter( hasCreation ); +} + +function hasCreation( + definition: RegisteredWidgetDefinition +): definition is CreatableWidgetDefinition { + return 'creation' in definition && Boolean( definition.creation ); +} diff --git a/apps/ui/src/ui-desks/widgets/types.ts b/apps/ui/src/ui-desks/widgets/types.ts index e655887093..5cf50aa8fd 100644 --- a/apps/ui/src/ui-desks/widgets/types.ts +++ b/apps/ui/src/ui-desks/widgets/types.ts @@ -1,6 +1,6 @@ import type { NoteWidget } from '@/ui-desks/widgets/note/types'; import type { DeskWidgetBase } from '@studio/common/types/desk'; -import type { ComponentType } from 'react'; +import type { ComponentProps, ComponentType, ReactElement } from 'react'; import type { TLShapeId } from 'tldraw'; export interface WidgetIndicator { @@ -16,12 +16,23 @@ export interface DeskWidgetComponentProps< widgetProps: TWidgetProps; } +export type WidgetIcon = ReactElement< ComponentProps< 'svg' > >; + +export interface WidgetCreationDefinition< TWidget extends DeskWidgetBase = DeskWidgetBase > { + getLabel: () => string; + icon?: WidgetIcon; + getInitialWidget: () => Pick< TWidget, 'shapeProps' | 'widgetProps' >; + getInitialSize: ( shapeProps: TWidget[ 'shapeProps' ] ) => { w: number; h: number }; + startEditing?: boolean; +} + export interface WidgetDefinition< TWidget extends DeskWidgetBase = DeskWidgetBase > { type: TWidget[ 'type' ]; shapeType: string; Component: ComponentType< DeskWidgetComponentProps< TWidget[ 'widgetProps' ] > >; isWidgetProps: ( props: unknown ) => props is TWidget[ 'widgetProps' ]; getIndicator?: ( widgetProps: TWidget[ 'widgetProps' ] ) => WidgetIndicator; + creation?: WidgetCreationDefinition< TWidget >; } export type DeskWidget = NoteWidget; From 3c6fe49070b37de149e594455afcd6bef85e8c2d Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Sat, 9 May 2026 13:36:20 +0100 Subject: [PATCH 2/3] Keep tldraw as desk canvas source of truth --- .../modules/desks/lib/ipc-handlers.test.ts | 75 -------------- apps/ui/src/ui-desks/chats/index.tsx | 3 +- .../ui/src/ui-desks/chats/session-surface.tsx | 9 +- apps/ui/src/ui-desks/chrome/create-menu.tsx | 20 ++-- apps/ui/src/ui-desks/chrome/index.tsx | 10 +- apps/ui/src/ui-desks/chrome/user-menu.tsx | 14 +-- apps/ui/src/ui-desks/desk/actions-context.tsx | 71 -------------- apps/ui/src/ui-desks/desk/canvas.tsx | 6 +- apps/ui/src/ui-desks/desk/index.tsx | 29 ++++-- apps/ui/src/ui-desks/desk/provider.tsx | 98 +++++++++++++++++++ apps/ui/src/ui-desks/desk/tldraw-adapter.ts | 8 +- .../shapes/rectangle-widget/shape-util.tsx | 51 ++++++++-- .../ui-desks/shapes/rectangle-widget/types.ts | 20 ++-- .../ui-desks/widgets/create-widget.test.ts | 49 +++++----- apps/ui/src/ui-desks/widgets/create-widget.ts | 51 +++++----- apps/ui/src/ui-desks/widgets/geometry.ts | 13 +++ .../src/ui-desks/widgets/note/component.tsx | 32 +++--- .../src/ui-desks/widgets/note/definition.ts | 33 +++---- apps/ui/src/ui-desks/widgets/note/types.ts | 2 +- apps/ui/src/ui-desks/widgets/registry.ts | 13 +-- apps/ui/src/ui-desks/widgets/types.ts | 20 ++-- 21 files changed, 306 insertions(+), 321 deletions(-) delete mode 100644 apps/studio/src/modules/desks/lib/ipc-handlers.test.ts delete mode 100644 apps/ui/src/ui-desks/desk/actions-context.tsx create mode 100644 apps/ui/src/ui-desks/desk/provider.tsx create mode 100644 apps/ui/src/ui-desks/widgets/geometry.ts diff --git a/apps/studio/src/modules/desks/lib/ipc-handlers.test.ts b/apps/studio/src/modules/desks/lib/ipc-handlers.test.ts deleted file mode 100644 index c00213c12c..0000000000 --- a/apps/studio/src/modules/desks/lib/ipc-handlers.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getSiteDeskConfig, saveSiteDeskConfig } from 'src/modules/desks/lib/ipc-handlers'; -import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; -import type { DeskConfig } from '@studio/common/types/desk'; -import type { IpcMainInvokeEvent } from 'electron'; - -vi.mock( 'src/storage/user-data', () => ( { - loadUserData: vi.fn(), - lockAppdata: vi.fn(), - saveUserData: vi.fn(), - unlockAppdata: vi.fn(), -} ) ); - -const event = {} as IpcMainInvokeEvent; - -const deskConfig: DeskConfig = { - version: 1, - updatedAt: '2026-05-09T00:00:00.000Z', - widgets: [], -}; - -describe( 'desks IPC handlers', () => { - beforeEach( () => { - vi.clearAllMocks(); - } ); - - it( 'loads a site desk config by site id', async () => { - vi.mocked( loadUserData ).mockResolvedValue( { - version: 1, - siteMetadata: {}, - desks: { - sites: { - 'site-1': deskConfig, - }, - }, - } ); - - await expect( getSiteDeskConfig( event, 'site-1' ) ).resolves.toBe( deskConfig ); - } ); - - it( 'saves a site desk config without replacing other desks', async () => { - const existingSiteDesk: DeskConfig = { - ...deskConfig, - updatedAt: '2026-05-08T00:00:00.000Z', - }; - vi.mocked( loadUserData ).mockResolvedValue( { - version: 1, - siteMetadata: {}, - desks: { - user: existingSiteDesk, - sites: { - 'site-1': existingSiteDesk, - }, - }, - } ); - - await saveSiteDeskConfig( event, 'site-2', deskConfig ); - - expect( vi.mocked( lockAppdata ).mock.invocationCallOrder[ 0 ] ).toBeLessThan( - vi.mocked( saveUserData ).mock.invocationCallOrder[ 0 ] - ); - expect( saveUserData ).toHaveBeenCalledWith( { - version: 1, - siteMetadata: {}, - desks: { - user: existingSiteDesk, - sites: { - 'site-1': existingSiteDesk, - 'site-2': deskConfig, - }, - }, - } ); - expect( unlockAppdata ).toHaveBeenCalled(); - } ); -} ); diff --git a/apps/ui/src/ui-desks/chats/index.tsx b/apps/ui/src/ui-desks/chats/index.tsx index 95ffbcf29e..0b672746d9 100644 --- a/apps/ui/src/ui-desks/chats/index.tsx +++ b/apps/ui/src/ui-desks/chats/index.tsx @@ -55,7 +55,7 @@ function useDeskChatsSearch() { return { open, setOpen, createChatRequestId }; } -export function DeskChatsTrigger() { +export function DeskChatsTrigger( _props: DeskChatsProps ) { const { open, setOpen } = useDeskChatsSearch(); return setOpen( ! open ) } />; @@ -172,6 +172,7 @@ export function DeskChats( { siteId }: DeskChatsProps ) { { selectedSessionId ? ( void; autoFocus?: boolean; @@ -43,6 +44,7 @@ function Frame( { composer, scrollRef, children }: FrameProps ) { } function DeskSessionSurfaceContent( { + siteId, sessionId, onSwitchSession, autoFocus = false, @@ -53,9 +55,10 @@ function DeskSessionSurfaceContent( { const ownerSite = ownerSitePath ? sites?.find( ( candidate ) => candidate.path === ownerSitePath ) : undefined; - const { data: connectedSites } = useConnectedWpcomSites( ownerSite?.id ); + const ownerSiteId = ownerSite?.id ?? siteId; + const { data: connectedSites } = useConnectedWpcomSites( ownerSiteId ); const liveSite = pickLiveSite( connectedSites ); - const effectiveEnvironment = useSessionEffectiveEnvironment( data?.summary, ownerSite?.id ); + const effectiveEnvironment = useSessionEffectiveEnvironment( data?.summary, ownerSiteId ); const { isRunning, hasActiveRun, @@ -141,7 +144,7 @@ function DeskSessionSurfaceContent( { effectiveEnvironment={ effectiveEnvironment } liveSite={ liveSite } entries={ data.entries } - ownerSiteId={ ownerSite?.id } + ownerSiteId={ ownerSiteId } onSwitchSession={ onSwitchSession } autoFocus={ autoFocus } /> diff --git a/apps/ui/src/ui-desks/chrome/create-menu.tsx b/apps/ui/src/ui-desks/chrome/create-menu.tsx index a31a21e2f0..19610743f6 100644 --- a/apps/ui/src/ui-desks/chrome/create-menu.tsx +++ b/apps/ui/src/ui-desks/chrome/create-menu.tsx @@ -3,15 +3,19 @@ import { __ } from '@wordpress/i18n'; import { comment, download, globe, plus } from '@wordpress/icons'; import { Icon } from '@wordpress/ui'; import * as Menu from '@/components/menu'; -import { useDeskActions } from '@/ui-desks/desk/actions-context'; +import { useDesk } from '@/ui-desks/desk/provider'; import { getCreatableWidgetDefinitions } from '@/ui-desks/widgets/registry'; import { DeskHeaderIconButton } from './header-button'; import styles from './style.module.css'; import type { DeskChatsSearch } from '../chats/search'; -export function DeskCreateMenu() { +interface DeskCreateMenuProps { + siteId?: string; +} + +export function DeskCreateMenu( _props: DeskCreateMenuProps ) { const navigate = useNavigate(); - const deskActions = useDeskActions(); + const desk = useDesk(); const creatableWidgetDefinitions = getCreatableWidgetDefinitions(); const createChat = () => { @@ -39,16 +43,16 @@ export function DeskCreateMenu() { render={ } /> - { deskActions && ( + { desk && ( <> { creatableWidgetDefinitions.map( ( definition ) => ( deskActions.createWidget( definition.type ) } + disabled={ ! desk.canAddWidgets } + onClick={ () => desk.addWidget( definition.type ) } > - { definition.creation.icon && } - { definition.creation.getLabel() } + { definition.icon && } + { definition.labels.add() } ) ) } { creatableWidgetDefinitions.length > 0 && } diff --git a/apps/ui/src/ui-desks/chrome/index.tsx b/apps/ui/src/ui-desks/chrome/index.tsx index 216f732e8a..6db8810f68 100644 --- a/apps/ui/src/ui-desks/chrome/index.tsx +++ b/apps/ui/src/ui-desks/chrome/index.tsx @@ -23,15 +23,15 @@ export function DeskHeader( { children }: DeskHeaderProps ) { } interface DeskChromeProps { - activeSiteId?: string; + siteId?: string; } -export function DeskChrome( { activeSiteId }: DeskChromeProps ) { +export function DeskChrome( { siteId }: DeskChromeProps ) { return ( - - - + + + ); } diff --git a/apps/ui/src/ui-desks/chrome/user-menu.tsx b/apps/ui/src/ui-desks/chrome/user-menu.tsx index 8f4b4a69bb..885e36b09f 100644 --- a/apps/ui/src/ui-desks/chrome/user-menu.tsx +++ b/apps/ui/src/ui-desks/chrome/user-menu.tsx @@ -17,14 +17,14 @@ import type { SiteDetails } from '@/data/core'; const WPCOM_PROFILE_URL = 'https://wordpress.com/me'; interface DeskMenuProps { - activeSiteId?: string; + siteId?: string; } function getSiteIconSeed( site: SiteDetails ) { return `${ site.id }:${ site.name }:${ site.path }`; } -export function DeskMenu( { activeSiteId }: DeskMenuProps ) { +export function DeskMenu( { siteId }: DeskMenuProps ) { const navigate = useNavigate(); const connector = useConnector(); const { data: user } = useAuthUser(); @@ -36,9 +36,9 @@ export function DeskMenu( { activeSiteId }: DeskMenuProps ) { const savedScheme = preferences?.colorScheme; const themeIsDark = savedScheme === 'dark' || ( savedScheme !== 'light' && effectiveScheme === 'dark' ); - const activeSite = sites?.find( ( candidate ) => candidate.id === activeSiteId ); + const activeSite = sites?.find( ( candidate ) => candidate.id === siteId ); const activeSiteName = activeSite?.name ?? __( 'Site' ); - const activeSiteIconSeed = activeSite ? getSiteIconSeed( activeSite ) : activeSiteId; + const activeSiteIconSeed = activeSite ? getSiteIconSeed( activeSite ) : siteId; const switcherSites = activeSite ? [ activeSite, ...( sites ?? [] ).filter( ( candidate ) => candidate.id !== activeSite.id ) ] : sites ?? []; @@ -52,13 +52,13 @@ export function DeskMenu( { activeSiteId }: DeskMenuProps ) { }; const openSite = ( nextSiteId: string ) => { - if ( nextSiteId === activeSiteId ) { + if ( nextSiteId === siteId ) { return; } void navigate( { to: '/sites/$siteId', params: { siteId: nextSiteId } } ); }; - const trigger = activeSiteId ? ( + const trigger = siteId ? (